From 1fc5d467e1da9f4daafb61010b2421819de64ee5 Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Fri, 19 Dec 2025 10:13:16 +0100 Subject: [PATCH 01/26] refactor: filter optional database fields --- src/DependencyInjection/migration.xml | 1 + src/Exception/MigrationException.php | 14 +- .../Validation/MigrationValidationService.php | 176 ++++++++++++------ 3 files changed, 136 insertions(+), 55 deletions(-) diff --git a/src/DependencyInjection/migration.xml b/src/DependencyInjection/migration.xml index b008b1b85..297e01376 100644 --- a/src/DependencyInjection/migration.xml +++ b/src/DependencyInjection/migration.xml @@ -426,6 +426,7 @@ + diff --git a/src/Exception/MigrationException.php b/src/Exception/MigrationException.php index ff1c28442..95340b2e8 100644 --- a/src/Exception/MigrationException.php +++ b/src/Exception/MigrationException.php @@ -95,7 +95,9 @@ class MigrationException extends HttpException final public const INVALID_ID = 'SWAG_MIGRATION__INVALID_ID'; - public const DUPLICATE_SOURCE_CONNECTION = 'SWAG_MIGRATION__DUPLICATE_SOURCE_CONNECTION'; + final public const DUPLICATE_SOURCE_CONNECTION = 'SWAG_MIGRATION__DUPLICATE_SOURCE_CONNECTION'; + + final public const TABLE_NOT_FOUND = 'SWAG_MIGRATION__TABLE_NOT_FOUND'; public static function associationEntityRequiredMissing(string $entity, string $missingEntity): self { @@ -500,4 +502,14 @@ public static function duplicateSourceConnection(): self 'A connection to this source system already exists.', ); } + + public static function tableNotFound(string $tableName): self + { + return new self( + Response::HTTP_NOT_FOUND, + self::TABLE_NOT_FOUND, + 'The table "{{ tableName }}" was not found.', + ['tableName' => $tableName] + ); + } } diff --git a/src/Migration/Validation/MigrationValidationService.php b/src/Migration/Validation/MigrationValidationService.php index e1d939c2a..fd7acbcd3 100644 --- a/src/Migration/Validation/MigrationValidationService.php +++ b/src/Migration/Validation/MigrationValidationService.php @@ -7,11 +7,14 @@ namespace SwagMigrationAssistant\Migration\Validation; +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Exception; use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\DataAbstractionLayer\CompiledFieldCollection; use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry; -use Shopware\Core\Framework\DataAbstractionLayer\Field\Field; use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField; use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required; +use Shopware\Core\Framework\DataAbstractionLayer\Field\StorageAware; use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\WriteCommandQueue; use Shopware\Core\Framework\DataAbstractionLayer\Write\DataStack\KeyValuePair; use Shopware\Core\Framework\DataAbstractionLayer\Write\EntityExistence; @@ -37,13 +40,19 @@ * @internal */ #[Package('fundamentals@after-sales')] -readonly class MigrationValidationService +final class MigrationValidationService { + /** + * @var array> + */ + private array $requiredColumnsCache = []; + public function __construct( - private DefinitionInstanceRegistry $definitionRegistry, - private EventDispatcherInterface $eventDispatcher, - private LoggingServiceInterface $loggingService, - private MappingServiceInterface $mappingService, + private readonly DefinitionInstanceRegistry $definitionRegistry, + private readonly EventDispatcherInterface $eventDispatcher, + private readonly LoggingServiceInterface $loggingService, + private readonly MappingServiceInterface $mappingService, + private readonly Connection $connection, ) { } @@ -83,7 +92,7 @@ public function validate( } catch (\Throwable $exception) { $validationContext->getValidationResult()->addLog( MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) - ->withEntityName($validationContext->getEntityDefinition()->getEntityName()) + ->withEntityName($entityDefinition->getEntityName()) ->withSourceData($validationContext->getSourceData()) ->withConvertedData($validationContext->getConvertedData()) ->withExceptionMessage($exception->getMessage()) @@ -108,23 +117,33 @@ public function validate( private function validateEntityStructure(MigrationValidationContext $validationContext): void { - $fields = $validationContext->getEntityDefinition()->getFields(); + $entityDefinition = $validationContext->getEntityDefinition(); + + $fields = $entityDefinition->getFields(); + $entityName = $entityDefinition->getEntityName(); - $requiredFields = array_values(array_map( - static fn (Field $field) => $field->getPropertyName(), - $fields->filterByFlag(Required::class)->getElements() - )); + $convertedData = $validationContext->getConvertedData(); + $validationResult = $validationContext->getValidationResult(); - $convertedFieldNames = array_keys($validationContext->getConvertedData()); - $missingRequiredFields = array_diff($requiredFields, $convertedFieldNames); + $requiredDatabaseColumns = $this->getRequiredDatabaseColumns($entityName); + $requiredFields = $this->filterRequiredFields( + $fields, + $requiredDatabaseColumns + ); + + $convertedFieldNames = array_keys($convertedData); + $missingRequiredFields = array_diff( + $requiredFields, + $convertedFieldNames + ); foreach ($missingRequiredFields as $missingField) { - $validationContext->getValidationResult()->addLog( + $validationResult->addLog( MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) - ->withEntityName($validationContext->getEntityDefinition()->getEntityName()) + ->withEntityName($entityName) ->withFieldName($missingField) - ->withConvertedData($validationContext->getConvertedData()) - ->withEntityId($validationContext->getConvertedData()['id'] ?? null) + ->withConvertedData($convertedData) + ->withEntityId($convertedData['id'] ?? null) ->build(MigrationValidationMissingRequiredFieldLog::class) ); } @@ -132,12 +151,12 @@ private function validateEntityStructure(MigrationValidationContext $validationC $unexpectedFields = array_diff($convertedFieldNames, array_keys($fields->getElements())); foreach ($unexpectedFields as $unexpectedField) { - $validationContext->getValidationResult()->addLog( + $validationResult->addLog( MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) - ->withEntityName($validationContext->getEntityDefinition()->getEntityName()) + ->withEntityName($entityName) ->withFieldName($unexpectedField) - ->withConvertedData($validationContext->getConvertedData()) - ->withEntityId($validationContext->getConvertedData()['id'] ?? null) + ->withConvertedData($convertedData) + ->withEntityId($convertedData['id'] ?? null) ->build(MigrationValidationUnexpectedFieldLog::class) ); } @@ -145,29 +164,35 @@ private function validateEntityStructure(MigrationValidationContext $validationC private function validateFields(MigrationValidationContext $validationContext): void { - $fields = $validationContext->getEntityDefinition()->getFields(); + $entityDefinition = $validationContext->getEntityDefinition(); + $fields = $entityDefinition->getFields(); + + $convertedData = $validationContext->getConvertedData(); + $validationResult = $validationContext->getValidationResult(); + + $id = $convertedData['id'] ?? null; - if (!isset($validationContext->getConvertedData()['id'])) { + if ($id === null) { throw MigrationException::unexpectedNullValue('id'); } - if (!Uuid::isValid($validationContext->getConvertedData()['id'])) { - throw MigrationException::invalidId($validationContext->getConvertedData()['id'], $validationContext->getEntityDefinition()->getEntityName()); + if (!Uuid::isValid($id)) { + throw MigrationException::invalidId($id, $entityDefinition->getEntityName()); } $entityExistence = EntityExistence::createForEntity( - $validationContext->getEntityDefinition()->getEntityName(), - ['id' => $validationContext->getConvertedData()['id']], + $entityDefinition->getEntityName(), + ['id' => $id], ); $parameters = new WriteParameterBag( - $validationContext->getEntityDefinition(), + $entityDefinition, WriteContext::createFromContext($validationContext->getContext()), '', new WriteCommandQueue(), ); - foreach ($validationContext->getConvertedData() as $fieldName => $value) { + foreach ($convertedData as $fieldName => $value) { if (!$fields->has($fieldName)) { continue; } @@ -185,15 +210,15 @@ private function validateFields(MigrationValidationContext $validationContext): $serializer = $field->getSerializer(); \iterator_to_array($serializer->encode($field, $entityExistence, $keyValue, $parameters), false); } catch (\Throwable $e) { - $validationContext->getValidationResult()->addLog( + $validationResult->addLog( MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) - ->withEntityName($validationContext->getEntityDefinition()->getEntityName()) + ->withEntityName($entityDefinition->getEntityName()) ->withFieldName($fieldName) ->withConvertedData([$fieldName => $value]) ->withSourceData($validationContext->getSourceData()) ->withExceptionMessage($e->getMessage()) ->withExceptionTrace($e->getTrace()) - ->withEntityId($validationContext->getConvertedData()['id'] ?? null) + ->withEntityId($id) ->build(MigrationValidationInvalidFieldValueLog::class) ); } @@ -202,30 +227,21 @@ private function validateFields(MigrationValidationContext $validationContext): private function validateAssociations(MigrationValidationContext $validationContext): void { - $fields = $validationContext->getEntityDefinition()->getFields(); - - $fkFields = array_values(array_map( - static fn (Field $field) => $field->getPropertyName(), - $fields->filterInstance(FkField::class)->getElements() - )); + $entityDefinition = $validationContext->getEntityDefinition(); + $fkFields = $entityDefinition->getFields()->filterInstance(FkField::class); - foreach ($fkFields as $fkFieldName) { - if (!isset($validationContext->getConvertedData()[$fkFieldName])) { - continue; - } + $convertedData = $validationContext->getConvertedData(); + $validationResult = $validationContext->getValidationResult(); - $fkValue = $validationContext->getConvertedData()[$fkFieldName]; + /** @var FkField $fkField */ + foreach ($fkFields as $fkField) { + $fkFieldName = $fkField->getPropertyName(); + $fkValue = $convertedData[$fkFieldName] ?? null; - if ($fkValue === '') { + if ($fkValue === null || $fkValue === '') { continue; } - $fkField = $fields->get($fkFieldName); - - if (!$fkField instanceof FkField) { - throw MigrationException::unexpectedNullValue($fkFieldName); - } - $referenceEntity = $fkField->getReferenceEntity(); if (!$referenceEntity) { @@ -240,16 +256,68 @@ private function validateAssociations(MigrationValidationContext $validationCont ); if (!$hasMapping) { - $validationContext->getValidationResult()->addLog( + $validationResult->addLog( MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) - ->withEntityName($validationContext->getEntityDefinition()->getEntityName()) + ->withEntityName($entityDefinition->getEntityName()) ->withFieldName($fkFieldName) ->withConvertedData([$fkFieldName => $fkValue]) ->withSourceData($validationContext->getSourceData()) - ->withEntityId($validationContext->getConvertedData()['id'] ?? null) + ->withEntityId($convertedData['id'] ?? null) ->build(MigrationValidationInvalidForeignKeyLog::class) ); } } } + + /** + * @param array $requiredDbColumns + * + * @return array + */ + private function filterRequiredFields(CompiledFieldCollection $fields, array $requiredDbColumns): array + { + $requiredFields = []; + + foreach ($fields->filterByFlag(Required::class) as $field) { + if (!($field instanceof StorageAware)) { + $requiredFields[] = $field->getPropertyName(); + + continue; + } + + if (!\in_array($field->getStorageName(), $requiredDbColumns, true)) { + continue; + } + + $requiredFields[] = $field->getPropertyName(); + } + + return $requiredFields; + } + + /** + * @return list + */ + private function getRequiredDatabaseColumns(string $entityName): array + { + if (isset($this->requiredColumnsCache[$entityName])) { + return $this->requiredColumnsCache[$entityName]; + } + + $this->requiredColumnsCache[$entityName] = []; + + try { + $columns = $this->connection->createSchemaManager()->listTableColumns($entityName); + } catch (Exception) { + throw MigrationException::tableNotFound($entityName); + } + + foreach ($columns as $column) { + if ($column->getNotnull() && $column->getDefault() === null && !$column->getAutoincrement()) { + $this->requiredColumnsCache[$entityName][] = $column->getName(); + } + } + + return $this->requiredColumnsCache[$entityName]; + } } From 9d5ab1358cc7b489ec18200fdcb82f867d38900b Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Fri, 19 Dec 2025 11:42:31 +0100 Subject: [PATCH 02/26] test: nullable validation fields --- src/Exception/MigrationException.php | 12 -- .../Validation/MigrationValidationService.php | 24 ++- tests/MigrationServicesTrait.php | 4 +- .../MigrationValidationServiceTest.php | 137 ++++++++++++++---- 4 files changed, 129 insertions(+), 48 deletions(-) diff --git a/src/Exception/MigrationException.php b/src/Exception/MigrationException.php index 95340b2e8..65bbf8a1c 100644 --- a/src/Exception/MigrationException.php +++ b/src/Exception/MigrationException.php @@ -97,8 +97,6 @@ class MigrationException extends HttpException final public const DUPLICATE_SOURCE_CONNECTION = 'SWAG_MIGRATION__DUPLICATE_SOURCE_CONNECTION'; - final public const TABLE_NOT_FOUND = 'SWAG_MIGRATION__TABLE_NOT_FOUND'; - public static function associationEntityRequiredMissing(string $entity, string $missingEntity): self { return new self( @@ -502,14 +500,4 @@ public static function duplicateSourceConnection(): self 'A connection to this source system already exists.', ); } - - public static function tableNotFound(string $tableName): self - { - return new self( - Response::HTTP_NOT_FOUND, - self::TABLE_NOT_FOUND, - 'The table "{{ tableName }}" was not found.', - ['tableName' => $tableName] - ); - } } diff --git a/src/Migration/Validation/MigrationValidationService.php b/src/Migration/Validation/MigrationValidationService.php index fd7acbcd3..6a48ba283 100644 --- a/src/Migration/Validation/MigrationValidationService.php +++ b/src/Migration/Validation/MigrationValidationService.php @@ -8,7 +8,6 @@ namespace SwagMigrationAssistant\Migration\Validation; use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Exception; use Shopware\Core\Framework\Context; use Shopware\Core\Framework\DataAbstractionLayer\CompiledFieldCollection; use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry; @@ -40,7 +39,7 @@ * @internal */ #[Package('fundamentals@after-sales')] -final class MigrationValidationService +class MigrationValidationService { /** * @var array> @@ -115,6 +114,10 @@ public function validate( return $validationContext->getValidationResult(); } + /** + * Validates that all required fields are present and that no unexpected fields exist. + * Required fields are determined by checking which database columns are non-nullable without a default value + */ private function validateEntityStructure(MigrationValidationContext $validationContext): void { $entityDefinition = $validationContext->getEntityDefinition(); @@ -162,6 +165,9 @@ private function validateEntityStructure(MigrationValidationContext $validationC } } + /** + * Validates that all field values conform to their field definitions by attempting to serialize them. + */ private function validateFields(MigrationValidationContext $validationContext): void { $entityDefinition = $validationContext->getEntityDefinition(); @@ -225,6 +231,9 @@ private function validateFields(MigrationValidationContext $validationContext): } } + /** + * Validates that all foreign key fields reference existing entities by checking the mapping service. + */ private function validateAssociations(MigrationValidationContext $validationContext): void { $entityDefinition = $validationContext->getEntityDefinition(); @@ -296,6 +305,9 @@ private function filterRequiredFields(CompiledFieldCollection $fields, array $re } /** + * Gets the list of required database columns for the given entity and caches the result for future calls. + * A required database column is defined as a column that is non-nullable, has no default value, and is not auto-incrementing. + * * @return list */ private function getRequiredDatabaseColumns(string $entityName): array @@ -306,11 +318,9 @@ private function getRequiredDatabaseColumns(string $entityName): array $this->requiredColumnsCache[$entityName] = []; - try { - $columns = $this->connection->createSchemaManager()->listTableColumns($entityName); - } catch (Exception) { - throw MigrationException::tableNotFound($entityName); - } + $columns = $this->connection + ->createSchemaManager() + ->listTableColumns($entityName); foreach ($columns as $column) { if ($column->getNotnull() && $column->getDefault() === null && !$column->getAutoincrement()) { diff --git a/tests/MigrationServicesTrait.php b/tests/MigrationServicesTrait.php index 9135d0557..ab565ba14 100644 --- a/tests/MigrationServicesTrait.php +++ b/tests/MigrationServicesTrait.php @@ -7,6 +7,7 @@ namespace SwagMigrationAssistant\Test; +use Doctrine\DBAL\Connection; use Psr\Log\NullLogger; use Shopware\Core\Checkout\Cart\Tax\TaxCalculator; use Shopware\Core\Checkout\Order\Aggregate\OrderDelivery\OrderDeliveryStates; @@ -212,6 +213,7 @@ protected function getMigrationDataConverter( $this->getContainer()->get('event_dispatcher'), $loggingService, $mappingService, + $this->getContainer()->get(Connection::class), ); return new MigrationDataConverter( @@ -221,7 +223,7 @@ protected function getMigrationDataConverter( $loggingService, $dataDefinition, new DummyMappingService(), - $validationService + $validationService, ); } diff --git a/tests/integration/Migration/Validation/MigrationValidationServiceTest.php b/tests/integration/Migration/Validation/MigrationValidationServiceTest.php index fa4748df4..da00c5600 100644 --- a/tests/integration/Migration/Validation/MigrationValidationServiceTest.php +++ b/tests/integration/Migration/Validation/MigrationValidationServiceTest.php @@ -17,6 +17,7 @@ use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; use Shopware\Core\Framework\Uuid\Uuid; +use SwagMigrationAssistant\Exception\MigrationException; use SwagMigrationAssistant\Migration\Connection\SwagMigrationConnectionEntity; use SwagMigrationAssistant\Migration\Logging\SwagMigrationLoggingCollection; use SwagMigrationAssistant\Migration\Logging\SwagMigrationLoggingDefinition; @@ -47,6 +48,8 @@ class MigrationValidationServiceTest extends TestCase private const CONNECTION_ID = '01991554142d73348ea58793d98f1989'; + private MigrationContext $migrationContext; + private MigrationValidationService $validationService; /** @@ -76,7 +79,21 @@ protected function setUp(): void $this->mappingRepo = static::getContainer()->get(SwagMigrationMappingDefinition::ENTITY_NAME . '.repository'); $this->context = Context::createDefaultContext(); + $connection = new SwagMigrationConnectionEntity(); + $connection->setId(self::CONNECTION_ID); + $connection->setProfileName(Shopware54Profile::PROFILE_NAME); + $connection->setGatewayName(DummyLocalGateway::GATEWAY_NAME); + $this->runId = Uuid::randomHex(); + + $this->migrationContext = new MigrationContext( + $connection, + new Shopware54Profile(), + new DummyLocalGateway(), + null, + $this->runId, + ); + static::getContainer()->get('swag_migration_connection.repository')->create( [ [ @@ -134,21 +151,8 @@ public function testShouldEarlyReturnNullWhenConvertedDataIsEmpty(): void #[DataProvider('entityStructureAndFieldProvider')] public function testShouldValidateStructureAndFieldsValues(array $convertedData, array $expectedLogs): void { - $connection = new SwagMigrationConnectionEntity(); - $connection->setId(self::CONNECTION_ID); - $connection->setProfileName(Shopware54Profile::PROFILE_NAME); - $connection->setGatewayName(DummyLocalGateway::GATEWAY_NAME); - - $migrationContext = new MigrationContext( - $connection, - new Shopware54Profile(), - new DummyLocalGateway(), - null, - $this->runId, - ); - $result = $this->validationService->validate( - $migrationContext, + $this->migrationContext, $this->context, $convertedData, SwagMigrationLoggingDefinition::ENTITY_NAME, @@ -170,6 +174,96 @@ public function testShouldValidateStructureAndFieldsValues(array $convertedData, static::assertSame($expectedLogs, $logCodes); } + public function testShouldFilterNullableFields(): void + { + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + [ + 'id' => Uuid::randomHex(), + ], + ProductDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result); + + $missingFields = \array_map(fn ($log) => $log->getFieldName(), $result->getLogs()); + static::assertCount(3, $missingFields); + + $expectedMissingFields = [ + 'active', // has no required flag + 'price', // is nullable, but has required flag + 'cmsPageVersionId', // has default value, but has required flag + ]; + + static::assertCount( + 0, + \array_intersect($expectedMissingFields, $missingFields) + ); + } + + public function testShouldLogWhenEntityHasNowId(): void + { + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + [ + 'level' => 'error', + 'code' => 'some_code', + 'userFixable' => true, + 'createdAt' => (new \DateTime())->format(\DATE_ATOM), + ], + SwagMigrationLoggingDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result); + + $logs = \array_filter($result->getLogs(), fn ($log) => $log instanceof MigrationValidationExceptionLog); + static::assertCount(1, $logs); + + $exceptionLog = array_values($logs)[0]; + static::assertInstanceOf(MigrationValidationExceptionLog::class, $exceptionLog); + + static::assertSame( + MigrationException::unexpectedNullValue('id')->getMessage(), + $exceptionLog->getExceptionMessage() + ); + } + + public function testShouldLogWhenEntityHasInvalidId(): void + { + $id = 'invalid-uuid'; + + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + [ + 'id' => $id, + 'level' => 'error', + 'code' => 'some_code', + 'userFixable' => true, + 'createdAt' => (new \DateTime())->format(\DATE_ATOM), + ], + SwagMigrationLoggingDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result); + + $logs = \array_filter($result->getLogs(), fn ($log) => $log instanceof MigrationValidationExceptionLog); + static::assertCount(1, $logs); + + $exceptionLog = array_values($logs)[0]; + static::assertInstanceOf(MigrationValidationExceptionLog::class, $exceptionLog); + + static::assertSame( + MigrationException::invalidId($id, SwagMigrationLoggingDefinition::ENTITY_NAME)->getMessage(), + $exceptionLog->getExceptionMessage(), + ); + } + /** * @param array $convertedData * @param array> $mappings @@ -178,25 +272,12 @@ public function testShouldValidateStructureAndFieldsValues(array $convertedData, #[DataProvider('associationProvider')] public function testValidateAssociations(array $convertedData, array $mappings, array $expectedLogs): void { - $connection = new SwagMigrationConnectionEntity(); - $connection->setId(self::CONNECTION_ID); - $connection->setProfileName(Shopware54Profile::PROFILE_NAME); - $connection->setGatewayName(DummyLocalGateway::GATEWAY_NAME); - - $migrationContext = new MigrationContext( - $connection, - new Shopware54Profile(), - new DummyLocalGateway(), - null, - $this->runId, - ); - if (!empty($mappings)) { $this->mappingRepo->create($mappings, $this->context); } $result = $this->validationService->validate( - $migrationContext, + $this->migrationContext, $this->context, $convertedData, SwagMigrationLoggingDefinition::ENTITY_NAME, From 5bfc5e45e6be9137027d5f8da432b044358858a6 Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Fri, 19 Dec 2025 15:54:37 +0100 Subject: [PATCH 03/26] refactor: implement reset interface --- src/DependencyInjection/migration.xml | 2 ++ .../Service/MediaFileProcessorService.php | 1 + src/Migration/Service/MigrationDataWriter.php | 5 +++-- .../Validation/MigrationValidationService.php | 14 +++++++++++++- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/DependencyInjection/migration.xml b/src/DependencyInjection/migration.xml index 297e01376..071e8101c 100644 --- a/src/DependencyInjection/migration.xml +++ b/src/DependencyInjection/migration.xml @@ -427,6 +427,8 @@ + + diff --git a/src/Migration/Service/MediaFileProcessorService.php b/src/Migration/Service/MediaFileProcessorService.php index d4c8c2c56..ce3983fcc 100644 --- a/src/Migration/Service/MediaFileProcessorService.php +++ b/src/Migration/Service/MediaFileProcessorService.php @@ -108,6 +108,7 @@ private function getMediaFiles(MigrationContextInterface $migrationContext): arr ->from('swag_migration_media_file') ->where('run_id = :runId') ->andWhere('written = 1') + ->andWhere('processed = 0') ->orderBy('entity, file_size') ->setFirstResult($migrationContext->getOffset()) ->setMaxResults($migrationContext->getLimit()) diff --git a/src/Migration/Service/MigrationDataWriter.php b/src/Migration/Service/MigrationDataWriter.php index c62982dd0..40c9e5cf4 100644 --- a/src/Migration/Service/MigrationDataWriter.php +++ b/src/Migration/Service/MigrationDataWriter.php @@ -73,6 +73,7 @@ public function writeData(MigrationContextInterface $migrationContext, Context $ $criteria->addFilter(new EqualsFilter('entity', $dataSet::getEntity())); $criteria->addFilter(new EqualsFilter('runId', $migrationContext->getRunUuid())); $criteria->addFilter(new EqualsFilter('convertFailure', false)); + $criteria->addFilter(new EqualsFilter('written', false)); $criteria->setOffset($migrationContext->getOffset()); $criteria->setLimit($migrationContext->getLimit()); $criteria->addSorting(new FieldSorting('autoIncrement', FieldSorting::ASCENDING)); @@ -133,7 +134,7 @@ public function writeData(MigrationContextInterface $migrationContext, Context $ } unset($data); - return $migrationData->getTotal(); + return $migrationData->count(); } catch (WriteException $exception) { $this->handleWriteException( $exception, @@ -165,7 +166,7 @@ public function writeData(MigrationContextInterface $migrationContext, Context $ $context ); - return $migrationData->getTotal(); + return $migrationData->count(); } /** diff --git a/src/Migration/Validation/MigrationValidationService.php b/src/Migration/Validation/MigrationValidationService.php index 6a48ba283..fa23c3f67 100644 --- a/src/Migration/Validation/MigrationValidationService.php +++ b/src/Migration/Validation/MigrationValidationService.php @@ -34,12 +34,13 @@ use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationMissingRequiredFieldLog; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationUnexpectedFieldLog; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\Service\ResetInterface; /** * @internal */ #[Package('fundamentals@after-sales')] -class MigrationValidationService +class MigrationValidationService implements ResetInterface { /** * @var array> @@ -55,6 +56,11 @@ public function __construct( ) { } + public function reset(): void + { + $this->requiredColumnsCache = []; + } + /** * @param array|null $convertedEntity * @param array $sourceData @@ -204,6 +210,12 @@ private function validateFields(MigrationValidationContext $validationContext): } $field = clone $fields->get($fieldName); + + /** + * Forces validation to run even for null values. + * Without Required, AbstractFieldSerializer::requiresValidation() returns false + * for null values on optional fields, skipping type/format validation entirely. + */ $field->setFlags(new Required()); $keyValue = new KeyValuePair( From 2260f76428eb6f7c88deecbd6e36646058f66609 Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Mon, 22 Dec 2025 10:20:11 +0100 Subject: [PATCH 04/26] fix: search not flushed mappings for associations --- src/Migration/Mapping/MappingService.php | 9 +++++++++ .../Log/MigrationValidationUnexpectedFieldLog.php | 2 +- src/Migration/Validation/MigrationValidationService.php | 5 ++--- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/Migration/Mapping/MappingService.php b/src/Migration/Mapping/MappingService.php index d84ab7b04..dd1e30812 100644 --- a/src/Migration/Mapping/MappingService.php +++ b/src/Migration/Mapping/MappingService.php @@ -216,6 +216,15 @@ public function getMappings(string $connectionId, string $entityName, array $ids public function hasValidMappingByEntityId(string $connectionId, string $entityName, string $entityId, Context $context): bool { + // check in write array first to avoid unnecessary db calls and find not yet written mappings + foreach ($this->writeArray as $writeMapping) { + if ($writeMapping['connectionId'] !== $connectionId || $writeMapping['entityId'] !== $entityId) { + continue; + } + + return $writeMapping['oldIdentifier'] !== null; + } + $criteria = new Criteria(); $criteria->addFilter( new EqualsFilter('connectionId', $connectionId), diff --git a/src/Migration/Validation/Log/MigrationValidationUnexpectedFieldLog.php b/src/Migration/Validation/Log/MigrationValidationUnexpectedFieldLog.php index d363bf846..491f5bc83 100644 --- a/src/Migration/Validation/Log/MigrationValidationUnexpectedFieldLog.php +++ b/src/Migration/Validation/Log/MigrationValidationUnexpectedFieldLog.php @@ -15,7 +15,7 @@ { public function isUserFixable(): bool { - return true; + return false; } public function getLevel(): string diff --git a/src/Migration/Validation/MigrationValidationService.php b/src/Migration/Validation/MigrationValidationService.php index fa23c3f67..d783ce48c 100644 --- a/src/Migration/Validation/MigrationValidationService.php +++ b/src/Migration/Validation/MigrationValidationService.php @@ -212,9 +212,8 @@ private function validateFields(MigrationValidationContext $validationContext): $field = clone $fields->get($fieldName); /** - * Forces validation to run even for null values. - * Without Required, AbstractFieldSerializer::requiresValidation() returns false - * for null values on optional fields, skipping type/format validation entirely. + * The required flag controls flow in AbstractFieldSerializer::requiresValidation(). + * Without it, the serializer will skip validation for the field. */ $field->setFlags(new Required()); From b13a0b20d42b11b2eafd188e10c5aa36530fbf88 Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Tue, 6 Jan 2026 10:32:40 +0100 Subject: [PATCH 05/26] refactor: validation of migration --- src/DependencyInjection/migration.xml | 1 - src/Migration/Mapping/MappingService.php | 27 ---- .../Mapping/MappingServiceInterface.php | 2 - ...alidationInvalidOptionalFieldValueLog.php} | 4 +- ...alidationInvalidRequiredFieldValueLog.php} | 4 +- ...onValidationInvalidRequiredTranslation.php | 30 ++++ .../Validation/MigrationValidationService.php | 146 +++++++----------- .../index.ts | 4 +- .../src/module/swag-migration/snippet/de.json | 5 +- .../src/module/swag-migration/snippet/en.json | 5 +- tests/MigrationServicesTrait.php | 1 - .../MigrationValidationServiceTest.php | 30 ++-- .../Logging/Log/MigrationLogTest.php | 14 +- 13 files changed, 109 insertions(+), 164 deletions(-) rename src/Migration/Validation/Log/{MigrationValidationInvalidForeignKeyLog.php => MigrationValidationInvalidOptionalFieldValueLog.php} (79%) rename src/Migration/Validation/Log/{MigrationValidationInvalidFieldValueLog.php => MigrationValidationInvalidRequiredFieldValueLog.php} (79%) create mode 100644 src/Migration/Validation/Log/MigrationValidationInvalidRequiredTranslation.php diff --git a/src/DependencyInjection/migration.xml b/src/DependencyInjection/migration.xml index 071e8101c..c375ecf6a 100644 --- a/src/DependencyInjection/migration.xml +++ b/src/DependencyInjection/migration.xml @@ -425,7 +425,6 @@ - diff --git a/src/Migration/Mapping/MappingService.php b/src/Migration/Mapping/MappingService.php index dd1e30812..54c8f05ab 100644 --- a/src/Migration/Mapping/MappingService.php +++ b/src/Migration/Mapping/MappingService.php @@ -17,8 +17,6 @@ use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult; use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsAnyFilter; use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter; -use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\MultiFilter; -use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\NotFilter; use Shopware\Core\Framework\DataAbstractionLayer\Write\EntityWriterInterface; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Uuid\Uuid; @@ -214,31 +212,6 @@ public function getMappings(string $connectionId, string $entityName, array $ids return $this->migrationMappingRepo->search($criteria, $context); } - public function hasValidMappingByEntityId(string $connectionId, string $entityName, string $entityId, Context $context): bool - { - // check in write array first to avoid unnecessary db calls and find not yet written mappings - foreach ($this->writeArray as $writeMapping) { - if ($writeMapping['connectionId'] !== $connectionId || $writeMapping['entityId'] !== $entityId) { - continue; - } - - return $writeMapping['oldIdentifier'] !== null; - } - - $criteria = new Criteria(); - $criteria->addFilter( - new EqualsFilter('connectionId', $connectionId), - new EqualsFilter('entity', $entityName), - new EqualsFilter('entityId', $entityId), - new NotFilter(MultiFilter::CONNECTION_AND, [ - new EqualsFilter('oldIdentifier', null), - ]), - ); - $criteria->setLimit(1); - - return $this->migrationMappingRepo->searchIds($criteria, $context)->getTotal() > 0; - } - public function preloadMappings(array $mappingIds, Context $context): void { if (empty($mappingIds)) { diff --git a/src/Migration/Mapping/MappingServiceInterface.php b/src/Migration/Mapping/MappingServiceInterface.php index 20dd09660..a2e9467d4 100644 --- a/src/Migration/Mapping/MappingServiceInterface.php +++ b/src/Migration/Mapping/MappingServiceInterface.php @@ -84,7 +84,5 @@ public function writeMapping(): void; */ public function getMappings(string $connectionId, string $entityName, array $ids, Context $context): EntitySearchResult; - public function hasValidMappingByEntityId(string $connectionId, string $entityName, string $entityId, Context $context): bool; - public function preloadMappings(array $mappingIds, Context $context): void; } diff --git a/src/Migration/Validation/Log/MigrationValidationInvalidForeignKeyLog.php b/src/Migration/Validation/Log/MigrationValidationInvalidOptionalFieldValueLog.php similarity index 79% rename from src/Migration/Validation/Log/MigrationValidationInvalidForeignKeyLog.php rename to src/Migration/Validation/Log/MigrationValidationInvalidOptionalFieldValueLog.php index bdd2a0ae3..71c295243 100644 --- a/src/Migration/Validation/Log/MigrationValidationInvalidForeignKeyLog.php +++ b/src/Migration/Validation/Log/MigrationValidationInvalidOptionalFieldValueLog.php @@ -11,7 +11,7 @@ use SwagMigrationAssistant\Migration\Logging\Log\Builder\AbstractMigrationLogEntry; #[Package('fundamentals@after-sales')] -readonly class MigrationValidationInvalidForeignKeyLog extends AbstractMigrationLogEntry +readonly class MigrationValidationInvalidOptionalFieldValueLog extends AbstractMigrationLogEntry { public function isUserFixable(): bool { @@ -25,6 +25,6 @@ public function getLevel(): string public function getCode(): string { - return 'SWAG_MIGRATION_VALIDATION_INVALID_FOREIGN_KEY'; + return 'SWAG_MIGRATION_VALIDATION_INVALID_OPTIONAL_FIELD_VALUE'; } } diff --git a/src/Migration/Validation/Log/MigrationValidationInvalidFieldValueLog.php b/src/Migration/Validation/Log/MigrationValidationInvalidRequiredFieldValueLog.php similarity index 79% rename from src/Migration/Validation/Log/MigrationValidationInvalidFieldValueLog.php rename to src/Migration/Validation/Log/MigrationValidationInvalidRequiredFieldValueLog.php index 5b5340165..9a4cc1b38 100644 --- a/src/Migration/Validation/Log/MigrationValidationInvalidFieldValueLog.php +++ b/src/Migration/Validation/Log/MigrationValidationInvalidRequiredFieldValueLog.php @@ -11,7 +11,7 @@ use SwagMigrationAssistant\Migration\Logging\Log\Builder\AbstractMigrationLogEntry; #[Package('fundamentals@after-sales')] -readonly class MigrationValidationInvalidFieldValueLog extends AbstractMigrationLogEntry +readonly class MigrationValidationInvalidRequiredFieldValueLog extends AbstractMigrationLogEntry { public function isUserFixable(): bool { @@ -25,6 +25,6 @@ public function getLevel(): string public function getCode(): string { - return 'SWAG_MIGRATION_VALIDATION_INVALID_FIELD_VALUE'; + return 'SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_FIELD_VALUE'; } } diff --git a/src/Migration/Validation/Log/MigrationValidationInvalidRequiredTranslation.php b/src/Migration/Validation/Log/MigrationValidationInvalidRequiredTranslation.php new file mode 100644 index 000000000..9633c1f90 --- /dev/null +++ b/src/Migration/Validation/Log/MigrationValidationInvalidRequiredTranslation.php @@ -0,0 +1,30 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SwagMigrationAssistant\Migration\Validation\Log; + +use Shopware\Core\Framework\Log\Package; +use SwagMigrationAssistant\Migration\Logging\Log\Builder\AbstractMigrationLogEntry; + +#[Package('fundamentals@after-sales')] +readonly class MigrationValidationInvalidRequiredTranslation extends AbstractMigrationLogEntry +{ + public function isUserFixable(): bool + { + return false; + } + + public function getLevel(): string + { + return self::LOG_LEVEL_ERROR; + } + + public function getCode(): string + { + return 'SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_TRANSLATION'; + } +} diff --git a/src/Migration/Validation/MigrationValidationService.php b/src/Migration/Validation/MigrationValidationService.php index d783ce48c..19aa56316 100644 --- a/src/Migration/Validation/MigrationValidationService.php +++ b/src/Migration/Validation/MigrationValidationService.php @@ -11,9 +11,9 @@ use Shopware\Core\Framework\Context; use Shopware\Core\Framework\DataAbstractionLayer\CompiledFieldCollection; use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry; -use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField; use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required; use Shopware\Core\Framework\DataAbstractionLayer\Field\StorageAware; +use Shopware\Core\Framework\DataAbstractionLayer\Field\TranslationsAssociationField; use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\WriteCommandQueue; use Shopware\Core\Framework\DataAbstractionLayer\Write\DataStack\KeyValuePair; use Shopware\Core\Framework\DataAbstractionLayer\Write\EntityExistence; @@ -24,15 +24,14 @@ use SwagMigrationAssistant\Exception\MigrationException; use SwagMigrationAssistant\Migration\Logging\Log\Builder\MigrationLogBuilder; use SwagMigrationAssistant\Migration\Logging\LoggingServiceInterface; -use SwagMigrationAssistant\Migration\Mapping\MappingServiceInterface; use SwagMigrationAssistant\Migration\MigrationContextInterface; use SwagMigrationAssistant\Migration\Validation\Event\MigrationPostValidationEvent; use SwagMigrationAssistant\Migration\Validation\Event\MigrationPreValidationEvent; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationExceptionLog; -use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidFieldValueLog; -use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidForeignKeyLog; +use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidOptionalFieldValueLog; +use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidRequiredFieldValueLog; +use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidRequiredTranslation; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationMissingRequiredFieldLog; -use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationUnexpectedFieldLog; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\Service\ResetInterface; @@ -43,22 +42,30 @@ class MigrationValidationService implements ResetInterface { /** - * @var array> + * Maps entity name to an associative array of required field property names. + * + * Example: + * [ + * 'entity_name' => [ + * 'required_field_name' => true, + * ], + * ] + * + * @var array> */ - private array $requiredColumnsCache = []; + private array $requiredDefinitionFieldsCache = []; public function __construct( private readonly DefinitionInstanceRegistry $definitionRegistry, private readonly EventDispatcherInterface $eventDispatcher, private readonly LoggingServiceInterface $loggingService, - private readonly MappingServiceInterface $mappingService, private readonly Connection $connection, ) { } public function reset(): void { - $this->requiredColumnsCache = []; + $this->requiredDefinitionFieldsCache = []; } /** @@ -93,7 +100,6 @@ public function validate( try { $this->validateEntityStructure($validationContext); $this->validateFields($validationContext); - $this->validateAssociations($validationContext); } catch (\Throwable $exception) { $validationContext->getValidationResult()->addLog( MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) @@ -121,7 +127,7 @@ public function validate( } /** - * Validates that all required fields are present and that no unexpected fields exist. + * Validates that all required fields are present. * Required fields are determined by checking which database columns are non-nullable without a default value */ private function validateEntityStructure(MigrationValidationContext $validationContext): void @@ -134,15 +140,14 @@ private function validateEntityStructure(MigrationValidationContext $validationC $convertedData = $validationContext->getConvertedData(); $validationResult = $validationContext->getValidationResult(); - $requiredDatabaseColumns = $this->getRequiredDatabaseColumns($entityName); - $requiredFields = $this->filterRequiredFields( + $requiredFields = $this->getRequiredFields( $fields, - $requiredDatabaseColumns + $entityName ); $convertedFieldNames = array_keys($convertedData); $missingRequiredFields = array_diff( - $requiredFields, + array_keys($requiredFields), $convertedFieldNames ); @@ -156,19 +161,6 @@ private function validateEntityStructure(MigrationValidationContext $validationC ->build(MigrationValidationMissingRequiredFieldLog::class) ); } - - $unexpectedFields = array_diff($convertedFieldNames, array_keys($fields->getElements())); - - foreach ($unexpectedFields as $unexpectedField) { - $validationResult->addLog( - MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) - ->withEntityName($entityName) - ->withFieldName($unexpectedField) - ->withConvertedData($convertedData) - ->withEntityId($convertedData['id'] ?? null) - ->build(MigrationValidationUnexpectedFieldLog::class) - ); - } } /** @@ -178,6 +170,7 @@ private function validateFields(MigrationValidationContext $validationContext): { $entityDefinition = $validationContext->getEntityDefinition(); $fields = $entityDefinition->getFields(); + $entityName = $entityDefinition->getEntityName(); $convertedData = $validationContext->getConvertedData(); $validationResult = $validationContext->getValidationResult(); @@ -204,12 +197,15 @@ private function validateFields(MigrationValidationContext $validationContext): new WriteCommandQueue(), ); + $requiredFields = $this->getRequiredFields($fields, $entityName); + foreach ($convertedData as $fieldName => $value) { if (!$fields->has($fieldName)) { continue; } $field = clone $fields->get($fieldName); + $isRequired = isset($requiredFields[$fieldName]); /** * The required flag controls flow in AbstractFieldSerializer::requiresValidation(). @@ -227,6 +223,14 @@ private function validateFields(MigrationValidationContext $validationContext): $serializer = $field->getSerializer(); \iterator_to_array($serializer->encode($field, $entityExistence, $keyValue, $parameters), false); } catch (\Throwable $e) { + $logClass = $isRequired + ? MigrationValidationInvalidRequiredFieldValueLog::class + : MigrationValidationInvalidOptionalFieldValueLog::class; + + if ($field instanceof TranslationsAssociationField) { + $logClass = MigrationValidationInvalidRequiredTranslation::class; + } + $validationResult->addLog( MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) ->withEntityName($entityDefinition->getEntityName()) @@ -236,98 +240,56 @@ private function validateFields(MigrationValidationContext $validationContext): ->withExceptionMessage($e->getMessage()) ->withExceptionTrace($e->getTrace()) ->withEntityId($id) - ->build(MigrationValidationInvalidFieldValueLog::class) + ->build($logClass) ); } } } /** - * Validates that all foreign key fields reference existing entities by checking the mapping service. + * Gets the map of required field property names for the given entity and caches the result for future calls. + * + * A field is considered required if: + * - It has the Required flag in the entity definition, AND + * - It's either not StorageAware (no direct database column), OR its database column is non-nullable without a default value + * + * @return array */ - private function validateAssociations(MigrationValidationContext $validationContext): void + private function getRequiredFields(CompiledFieldCollection $fields, string $entityName): array { - $entityDefinition = $validationContext->getEntityDefinition(); - $fkFields = $entityDefinition->getFields()->filterInstance(FkField::class); - - $convertedData = $validationContext->getConvertedData(); - $validationResult = $validationContext->getValidationResult(); - - /** @var FkField $fkField */ - foreach ($fkFields as $fkField) { - $fkFieldName = $fkField->getPropertyName(); - $fkValue = $convertedData[$fkFieldName] ?? null; - - if ($fkValue === null || $fkValue === '') { - continue; - } - - $referenceEntity = $fkField->getReferenceEntity(); - - if (!$referenceEntity) { - throw MigrationException::unexpectedNullValue($fkFieldName); - } - - $hasMapping = $this->mappingService->hasValidMappingByEntityId( - $validationContext->getMigrationContext()->getConnection()->getId(), - $referenceEntity, - $fkValue, - $validationContext->getContext() - ); - - if (!$hasMapping) { - $validationResult->addLog( - MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) - ->withEntityName($entityDefinition->getEntityName()) - ->withFieldName($fkFieldName) - ->withConvertedData([$fkFieldName => $fkValue]) - ->withSourceData($validationContext->getSourceData()) - ->withEntityId($convertedData['id'] ?? null) - ->build(MigrationValidationInvalidForeignKeyLog::class) - ); - } + if (isset($this->requiredDefinitionFieldsCache[$entityName])) { + return $this->requiredDefinitionFieldsCache[$entityName]; } - } - /** - * @param array $requiredDbColumns - * - * @return array - */ - private function filterRequiredFields(CompiledFieldCollection $fields, array $requiredDbColumns): array - { + $requiredDbColumns = $this->getRequiredDatabaseColumns($entityName); $requiredFields = []; foreach ($fields->filterByFlag(Required::class) as $field) { if (!($field instanceof StorageAware)) { - $requiredFields[] = $field->getPropertyName(); + $requiredFields[$field->getPropertyName()] = true; continue; } - if (!\in_array($field->getStorageName(), $requiredDbColumns, true)) { - continue; + if (isset($requiredDbColumns[$field->getStorageName()])) { + $requiredFields[$field->getPropertyName()] = true; } - - $requiredFields[] = $field->getPropertyName(); } + $this->requiredDefinitionFieldsCache[$entityName] = $requiredFields; + return $requiredFields; } /** - * Gets the list of required database columns for the given entity and caches the result for future calls. + * Gets the map of required database columns for the given entity. * A required database column is defined as a column that is non-nullable, has no default value, and is not auto-incrementing. * - * @return list + * @return array */ private function getRequiredDatabaseColumns(string $entityName): array { - if (isset($this->requiredColumnsCache[$entityName])) { - return $this->requiredColumnsCache[$entityName]; - } - - $this->requiredColumnsCache[$entityName] = []; + $requiredColumns = []; $columns = $this->connection ->createSchemaManager() @@ -335,10 +297,10 @@ private function getRequiredDatabaseColumns(string $entityName): array foreach ($columns as $column) { if ($column->getNotnull() && $column->getDefault() === null && !$column->getAutoincrement()) { - $this->requiredColumnsCache[$entityName][] = $column->getName(); + $requiredColumns[$column->getName()] = true; } } - return $this->requiredColumnsCache[$entityName]; + return $requiredColumns; } } diff --git a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/index.ts b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/index.ts index e5d8cc7f1..0b6ea28be 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/index.ts +++ b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/index.ts @@ -17,8 +17,8 @@ const { Criteria } = Shopware.Data; * null will render an unresolvable message. */ export const ERROR_CODE_COMPONENT_MAPPING: Record = { - SWAG_MIGRATION_VALIDATION_INVALID_FIELD_VALUE: 'DEFAULT', - SWAG_MIGRATION_VALIDATION_INVALID_FOREIGN_KEY: 'DEFAULT', + SWAG_MIGRATION_VALIDATION_INVALID_OPTIONAL_FIELD_VALUE: 'DEFAULT', + SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_FIELD_VALUE: 'DEFAULT', SWAG_MIGRATION_VALIDATION_MISSING_REQUIRED_FIELD: 'DEFAULT', } as const; diff --git a/src/Resources/app/administration/src/module/swag-migration/snippet/de.json b/src/Resources/app/administration/src/module/swag-migration/snippet/de.json index 0c803f695..6cd11735c 100644 --- a/src/Resources/app/administration/src/module/swag-migration/snippet/de.json +++ b/src/Resources/app/administration/src/module/swag-migration/snippet/de.json @@ -624,8 +624,9 @@ "SWAG_MIGRATION__SHOPWARE_UNSUPPORTED_OBJECT_TYPE": "Nicht unterstützter Objekttyp", "SWAG_MIGRATION__WRITE_EXCEPTION_OCCURRED": "Ein Schreibfehler ist aufgetreten", "SWAG_MIGRATION_VALIDATION_EXCEPTION": "Validierungsfehler aufgetreten", - "SWAG_MIGRATION_VALIDATION_INVALID_FIELD_VALUE": "Feld hat einen ungültigen Wert", - "SWAG_MIGRATION_VALIDATION_INVALID_FOREIGN_KEY": "Ungültige Fremdschlüssel-Referenz", + "SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_FIELD_VALUE": "Pflichtfeld hat einen ungültigen Wert", + "SWAG_MIGRATION_VALIDATION_INVALID_OPTIONAL_FIELD_VALUE": "Optionales Feld hat einen ungültigen Wert", + "SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_TRANSLATION": "Erforderliche Übersetzung ist ungültig", "SWAG_MIGRATION_VALIDATION_MISSING_REQUIRED_FIELD": "Erforderliches Feld fehlt", "SWAG_MIGRATION_VALIDATION_UNEXPECTED_FIELD": "Unerwartetes Feld in Daten gefunden", "SWAG_MIGRATION__DEACTIVATED_PACK_LANGUAGE": "Pack-Sprache ist deaktiviert", diff --git a/src/Resources/app/administration/src/module/swag-migration/snippet/en.json b/src/Resources/app/administration/src/module/swag-migration/snippet/en.json index 5d7ed6b56..2f810520b 100644 --- a/src/Resources/app/administration/src/module/swag-migration/snippet/en.json +++ b/src/Resources/app/administration/src/module/swag-migration/snippet/en.json @@ -475,8 +475,9 @@ "SWAG_MIGRATION__SHOPWARE_UNSUPPORTED_OBJECT_TYPE": "Unsupported object type", "SWAG_MIGRATION__WRITE_EXCEPTION_OCCURRED": "A write exception has occurred", "SWAG_MIGRATION_VALIDATION_EXCEPTION": "Validation exception occurred", - "SWAG_MIGRATION_VALIDATION_INVALID_FIELD_VALUE": "Field has an invalid value", - "SWAG_MIGRATION_VALIDATION_INVALID_FOREIGN_KEY": "Invalid foreign key reference", + "SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_FIELD_VALUE": "Required field has an invalid value", + "SWAG_MIGRATION_VALIDATION_INVALID_OPTIONAL_FIELD_VALUE": "Optional field has an invalid value", + "SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_TRANSLATION": "Required translation is invalid", "SWAG_MIGRATION_VALIDATION_MISSING_REQUIRED_FIELD": "Required field is missing", "SWAG_MIGRATION_VALIDATION_UNEXPECTED_FIELD": "Unexpected field found in data", "SWAG_MIGRATION__DEACTIVATED_PACK_LANGUAGE": "Pack language is deactivated", diff --git a/tests/MigrationServicesTrait.php b/tests/MigrationServicesTrait.php index ab565ba14..92d228953 100644 --- a/tests/MigrationServicesTrait.php +++ b/tests/MigrationServicesTrait.php @@ -212,7 +212,6 @@ protected function getMigrationDataConverter( $this->getContainer()->get(DefinitionInstanceRegistry::class), $this->getContainer()->get('event_dispatcher'), $loggingService, - $mappingService, $this->getContainer()->get(Connection::class), ); diff --git a/tests/integration/Migration/Validation/MigrationValidationServiceTest.php b/tests/integration/Migration/Validation/MigrationValidationServiceTest.php index da00c5600..bc48a6dbc 100644 --- a/tests/integration/Migration/Validation/MigrationValidationServiceTest.php +++ b/tests/integration/Migration/Validation/MigrationValidationServiceTest.php @@ -28,8 +28,7 @@ use SwagMigrationAssistant\Migration\Run\SwagMigrationRunCollection; use SwagMigrationAssistant\Migration\Run\SwagMigrationRunDefinition; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationExceptionLog; -use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidFieldValueLog; -use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidForeignKeyLog; +use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidRequiredFieldValueLog; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationMissingRequiredFieldLog; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationUnexpectedFieldLog; use SwagMigrationAssistant\Migration\Validation\MigrationValidationResult; @@ -203,7 +202,7 @@ public function testShouldFilterNullableFields(): void ); } - public function testShouldLogWhenEntityHasNowId(): void + public function testShouldLogWhenEntityHasNoId(): void { $result = $this->validationService->validate( $this->migrationContext, @@ -342,7 +341,7 @@ public static function entityStructureAndFieldProvider(): \Generator 'userFixable' => 'not_a_boolean', ], [ - MigrationValidationInvalidFieldValueLog::class, + MigrationValidationInvalidRequiredFieldValueLog::class, ], ]; @@ -352,7 +351,7 @@ public static function entityStructureAndFieldProvider(): \Generator 'code' => str_repeat('sw', 128), ], [ - MigrationValidationInvalidFieldValueLog::class, + MigrationValidationInvalidRequiredFieldValueLog::class, ], ]; @@ -372,7 +371,7 @@ public static function entityStructureAndFieldProvider(): \Generator 'sourceData' => "\xB1\x31", ], [ - MigrationValidationInvalidFieldValueLog::class, + MigrationValidationInvalidRequiredFieldValueLog::class, ], ]; @@ -389,9 +388,9 @@ public static function entityStructureAndFieldProvider(): \Generator [ MigrationValidationMissingRequiredFieldLog::class, MigrationValidationUnexpectedFieldLog::class, - MigrationValidationInvalidFieldValueLog::class, - MigrationValidationInvalidFieldValueLog::class, - MigrationValidationInvalidFieldValueLog::class, + MigrationValidationInvalidRequiredFieldValueLog::class, + MigrationValidationInvalidRequiredFieldValueLog::class, + MigrationValidationInvalidRequiredFieldValueLog::class, ], ]; } @@ -429,15 +428,6 @@ public static function associationProvider(): \Generator [], ]; - yield 'invalid fk' => [ - [ - ...$log, - 'runId' => Uuid::randomHex(), - ], - [$mapping], - [MigrationValidationInvalidForeignKeyLog::class], - ]; - yield 'fk field not in converted data' => [ $log, [], @@ -451,7 +441,7 @@ public static function associationProvider(): \Generator ], [], [ - MigrationValidationInvalidFieldValueLog::class, + MigrationValidationInvalidRequiredFieldValueLog::class, ], ]; @@ -462,7 +452,7 @@ public static function associationProvider(): \Generator ], [], [ - MigrationValidationInvalidFieldValueLog::class, + MigrationValidationInvalidRequiredFieldValueLog::class, ], ]; } diff --git a/tests/unit/Migration/Logging/Log/MigrationLogTest.php b/tests/unit/Migration/Logging/Log/MigrationLogTest.php index cdf99f432..acad12cbf 100644 --- a/tests/unit/Migration/Logging/Log/MigrationLogTest.php +++ b/tests/unit/Migration/Logging/Log/MigrationLogTest.php @@ -39,8 +39,7 @@ use SwagMigrationAssistant\Migration\Logging\Log\WriteExceptionRunLog; use SwagMigrationAssistant\Migration\MigrationContext; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationExceptionLog; -use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidFieldValueLog; -use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidForeignKeyLog; +use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidRequiredFieldValueLog; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationMissingRequiredFieldLog; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationUnexpectedFieldLog; use SwagMigrationAssistant\Profile\Shopware54\Shopware54Profile; @@ -107,20 +106,13 @@ public static function logProvider(): \Generator 'userFixable' => false, ]; - yield MigrationValidationInvalidFieldValueLog::class => [ - 'logClass' => MigrationValidationInvalidFieldValueLog::class, + yield MigrationValidationInvalidRequiredFieldValueLog::class => [ + 'logClass' => MigrationValidationInvalidRequiredFieldValueLog::class, 'code' => 'SWAG_MIGRATION_VALIDATION_INVALID_FIELD_VALUE', 'level' => AbstractMigrationLogEntry::LOG_LEVEL_ERROR, 'userFixable' => true, ]; - yield MigrationValidationInvalidForeignKeyLog::class => [ - 'logClass' => MigrationValidationInvalidForeignKeyLog::class, - 'code' => 'SWAG_MIGRATION_VALIDATION_INVALID_FOREIGN_KEY', - 'level' => AbstractMigrationLogEntry::LOG_LEVEL_WARNING, - 'userFixable' => true, - ]; - yield MigrationValidationMissingRequiredFieldLog::class => [ 'logClass' => MigrationValidationMissingRequiredFieldLog::class, 'code' => 'SWAG_MIGRATION_VALIDATION_MISSING_REQUIRED_FIELD', From 99968d0f4ed063f8157a9638edae74270b74f676 Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Tue, 6 Jan 2026 11:01:30 +0100 Subject: [PATCH 06/26] test: fix --- .../MigrationValidationUnexpectedFieldLog.php | 30 ------- .../Validation/MigrationValidationService.php | 1 + tests/Jest/src/fixture.js | 2 +- ...g-migration-error-resolution-modal.spec.js | 4 +- ...ag-migration-error-resolution-step.spec.js | 2 +- .../MigrationValidationServiceTest.php | 89 ++++++++++++++----- .../Logging/Log/MigrationLogTest.php | 8 -- 7 files changed, 70 insertions(+), 66 deletions(-) delete mode 100644 src/Migration/Validation/Log/MigrationValidationUnexpectedFieldLog.php diff --git a/src/Migration/Validation/Log/MigrationValidationUnexpectedFieldLog.php b/src/Migration/Validation/Log/MigrationValidationUnexpectedFieldLog.php deleted file mode 100644 index 491f5bc83..000000000 --- a/src/Migration/Validation/Log/MigrationValidationUnexpectedFieldLog.php +++ /dev/null @@ -1,30 +0,0 @@ - - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace SwagMigrationAssistant\Migration\Validation\Log; - -use Shopware\Core\Framework\Log\Package; -use SwagMigrationAssistant\Migration\Logging\Log\Builder\AbstractMigrationLogEntry; - -#[Package('fundamentals@after-sales')] -readonly class MigrationValidationUnexpectedFieldLog extends AbstractMigrationLogEntry -{ - public function isUserFixable(): bool - { - return false; - } - - public function getLevel(): string - { - return self::LOG_LEVEL_WARNING; - } - - public function getCode(): string - { - return 'SWAG_MIGRATION_VALIDATION_UNEXPECTED_FIELD'; - } -} diff --git a/src/Migration/Validation/MigrationValidationService.php b/src/Migration/Validation/MigrationValidationService.php index 19aa56316..743bec839 100644 --- a/src/Migration/Validation/MigrationValidationService.php +++ b/src/Migration/Validation/MigrationValidationService.php @@ -34,6 +34,7 @@ use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationMissingRequiredFieldLog; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\Service\ResetInterface; +use function var_dump; /** * @internal diff --git a/tests/Jest/src/fixture.js b/tests/Jest/src/fixture.js index cee9c18c6..95aabee19 100644 --- a/tests/Jest/src/fixture.js +++ b/tests/Jest/src/fixture.js @@ -112,7 +112,7 @@ export const fixtureLogGroups = Object.freeze([ }, { // relation - code: 'SWAG_MIGRATION_VALIDATION_INVALID_FIELD_VALUE', + code: 'SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_FIELD_VALUE', count: 161, entityName: 'product', fieldName: 'options', diff --git a/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal.spec.js b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal.spec.js index 6e806199c..a25bc3f87 100644 --- a/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal.spec.js +++ b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal.spec.js @@ -181,8 +181,8 @@ describe('module/swag-migration/component/swag-migration-error-resolution/swag-m describe('constants', () => { it('should provide error code to component mapping', () => { expect(ERROR_CODE_COMPONENT_MAPPING).toStrictEqual({ - SWAG_MIGRATION_VALIDATION_INVALID_FIELD_VALUE: 'DEFAULT', - SWAG_MIGRATION_VALIDATION_INVALID_FOREIGN_KEY: 'DEFAULT', + SWAG_MIGRATION_VALIDATION_INVALID_OPTIONAL_FIELD_VALUE: 'DEFAULT', + SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_FIELD_VALUE: 'DEFAULT', SWAG_MIGRATION_VALIDATION_MISSING_REQUIRED_FIELD: 'DEFAULT', }); }); diff --git a/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-step.spec.js b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-step.spec.js index 3eb9ea981..252b91ed7 100644 --- a/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-step.spec.js +++ b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-step.spec.js @@ -161,7 +161,7 @@ describe('src/module/swag-migration/component/swag-migration-error-resolution/sw resolved: true, }), expect.objectContaining({ - name: 'swag-migration.index.error-resolution.codes.SWAG_MIGRATION_VALIDATION_INVALID_FIELD_VALUE', + name: 'swag-migration.index.error-resolution.codes.SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_FIELD_VALUE', resolved: false, }), ]), diff --git a/tests/integration/Migration/Validation/MigrationValidationServiceTest.php b/tests/integration/Migration/Validation/MigrationValidationServiceTest.php index bc48a6dbc..3f8f1ec85 100644 --- a/tests/integration/Migration/Validation/MigrationValidationServiceTest.php +++ b/tests/integration/Migration/Validation/MigrationValidationServiceTest.php @@ -11,6 +11,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Shopware\Core\Content\Product\ProductDefinition; +use Shopware\Core\Defaults; use Shopware\Core\Framework\Context; use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; @@ -28,13 +29,16 @@ use SwagMigrationAssistant\Migration\Run\SwagMigrationRunCollection; use SwagMigrationAssistant\Migration\Run\SwagMigrationRunDefinition; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationExceptionLog; +use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidOptionalFieldValueLog; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidRequiredFieldValueLog; +use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidRequiredTranslation; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationMissingRequiredFieldLog; -use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationUnexpectedFieldLog; use SwagMigrationAssistant\Migration\Validation\MigrationValidationResult; use SwagMigrationAssistant\Migration\Validation\MigrationValidationService; use SwagMigrationAssistant\Profile\Shopware54\Shopware54Profile; use SwagMigrationAssistant\Test\Mock\Gateway\Dummy\Local\DummyLocalGateway; +use function array_map; +use function var_dump; /** * @internal @@ -169,7 +173,7 @@ public function testShouldValidateStructureAndFieldsValues(array $convertedData, static::assertCount(\count($expectedLogs), $logs); static::assertCount(\count($expectedLogs), $result->getLogs()); - $logCodes = array_map(fn ($log) => $log::class, $result->getLogs()); + $logCodes = array_map(fn($log) => $log::class, $result->getLogs()); static::assertSame($expectedLogs, $logCodes); } @@ -187,7 +191,7 @@ public function testShouldFilterNullableFields(): void static::assertInstanceOf(MigrationValidationResult::class, $result); - $missingFields = \array_map(fn ($log) => $log->getFieldName(), $result->getLogs()); + $missingFields = \array_map(fn($log) => $log->getFieldName(), $result->getLogs()); static::assertCount(3, $missingFields); $expectedMissingFields = [ @@ -219,7 +223,7 @@ public function testShouldLogWhenEntityHasNoId(): void static::assertInstanceOf(MigrationValidationResult::class, $result); - $logs = \array_filter($result->getLogs(), fn ($log) => $log instanceof MigrationValidationExceptionLog); + $logs = \array_filter($result->getLogs(), fn($log) => $log instanceof MigrationValidationExceptionLog); static::assertCount(1, $logs); $exceptionLog = array_values($logs)[0]; @@ -251,7 +255,7 @@ public function testShouldLogWhenEntityHasInvalidId(): void static::assertInstanceOf(MigrationValidationResult::class, $result); - $logs = \array_filter($result->getLogs(), fn ($log) => $log instanceof MigrationValidationExceptionLog); + $logs = \array_filter($result->getLogs(), fn($log) => $log instanceof MigrationValidationExceptionLog); static::assertCount(1, $logs); $exceptionLog = array_values($logs)[0]; @@ -285,10 +289,61 @@ public function testValidateAssociations(array $convertedData, array $mappings, static::assertInstanceOf(MigrationValidationResult::class, $result); - $logClasses = array_map(static fn ($log) => $log::class, $result->getLogs()); + $logClasses = array_map(static fn($log) => $log::class, $result->getLogs()); static::assertEquals($expectedLogs, $logClasses); } + public function testMissingTranslationAssociation(): void + { + $convertedData = [ + 'id' => Uuid::randomHex(), + 'versionId' => Uuid::randomHex(), + 'stock' => 10, + 'translations' => ['lel'] + ]; + + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + $convertedData, + ProductDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result); + + $logClasses = array_map(static fn($log) => $log::class, $result->getLogs()); + static::assertCount(1, $logClasses); + static::assertEquals([MigrationValidationInvalidRequiredTranslation::class], $logClasses); + } + + public function testValidTranslationAssociation(): void + { + $convertedData = [ + 'id' => Uuid::randomHex(), + 'versionId' => Uuid::randomHex(), + 'stock' => 10, + 'translations' => [ + Defaults::LANGUAGE_SYSTEM => [ + 'name' => 'Valid name', + ], + ] + ]; + + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + $convertedData, + ProductDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result); + + $logClasses = array_map(static fn($log) => $log::class, $result->getLogs()); + static::assertCount(0, $logClasses); + } + public static function entityStructureAndFieldProvider(): \Generator { $log = [ @@ -323,25 +378,13 @@ public static function entityStructureAndFieldProvider(): \Generator ], ]; - yield 'structure - unexpected fields' => [ - [ - ...$log, - 'unexpectedField1' => 'value', - 'unexpectedField2' => 'value', - ], - [ - MigrationValidationUnexpectedFieldLog::class, - MigrationValidationUnexpectedFieldLog::class, - ], - ]; - yield 'fields - invalid type' => [ [ ...$log, 'userFixable' => 'not_a_boolean', ], [ - MigrationValidationInvalidRequiredFieldValueLog::class, + MigrationValidationInvalidOptionalFieldValueLog::class, ], ]; @@ -371,7 +414,7 @@ public static function entityStructureAndFieldProvider(): \Generator 'sourceData' => "\xB1\x31", ], [ - MigrationValidationInvalidRequiredFieldValueLog::class, + MigrationValidationInvalidOptionalFieldValueLog::class, ], ]; @@ -383,11 +426,9 @@ public static function entityStructureAndFieldProvider(): \Generator 'code' => ['sw'], 'userFixable' => true, 'createdAt' => (new \DateTime())->format(\DATE_ATOM), - 'unexpectedField' => 'value', ], [ MigrationValidationMissingRequiredFieldLog::class, - MigrationValidationUnexpectedFieldLog::class, MigrationValidationInvalidRequiredFieldValueLog::class, MigrationValidationInvalidRequiredFieldValueLog::class, MigrationValidationInvalidRequiredFieldValueLog::class, @@ -441,7 +482,7 @@ public static function associationProvider(): \Generator ], [], [ - MigrationValidationInvalidRequiredFieldValueLog::class, + MigrationValidationInvalidOptionalFieldValueLog::class, ], ]; @@ -452,7 +493,7 @@ public static function associationProvider(): \Generator ], [], [ - MigrationValidationInvalidRequiredFieldValueLog::class, + MigrationValidationInvalidOptionalFieldValueLog::class, ], ]; } diff --git a/tests/unit/Migration/Logging/Log/MigrationLogTest.php b/tests/unit/Migration/Logging/Log/MigrationLogTest.php index acad12cbf..c7059e4f8 100644 --- a/tests/unit/Migration/Logging/Log/MigrationLogTest.php +++ b/tests/unit/Migration/Logging/Log/MigrationLogTest.php @@ -41,7 +41,6 @@ use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationExceptionLog; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidRequiredFieldValueLog; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationMissingRequiredFieldLog; -use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationUnexpectedFieldLog; use SwagMigrationAssistant\Profile\Shopware54\Shopware54Profile; use SwagMigrationAssistant\Test\Mock\Gateway\Dummy\Local\DummyLocalGateway; @@ -120,13 +119,6 @@ public static function logProvider(): \Generator 'userFixable' => false, ]; - yield MigrationValidationUnexpectedFieldLog::class => [ - 'logClass' => MigrationValidationUnexpectedFieldLog::class, - 'code' => 'SWAG_MIGRATION_VALIDATION_UNEXPECTED_FIELD', - 'level' => AbstractMigrationLogEntry::LOG_LEVEL_WARNING, - 'userFixable' => true, - ]; - yield AssociationRequiredMissingLog::class => [ 'logClass' => AssociationRequiredMissingLog::class, 'code' => 'SWAG_MIGRATION__SHOPWARE_ASSOCIATION_REQUIRED_MISSING', From 49d4f79f563de55bd98aec631b127f90209396b7 Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Tue, 6 Jan 2026 15:48:40 +0100 Subject: [PATCH 07/26] refactor: validation --- src/Exception/MigrationException.php | 24 -- .../MigrationValidationException.php | 91 +++++ ...grationValidationInvalidAssociationLog.php | 30 ++ ...ationValidationMissingRequiredFieldLog.php | 2 +- .../Validation/MigrationValidationService.php | 338 ++++++++++++++---- .../swag-migration-shop-information.html.twig | 7 +- .../swag-migration-shop-information.scss | 7 +- .../swag-migration-error-resolution-step.scss | 2 +- .../MigrationValidationServiceTest.php | 208 ++++++++++- 9 files changed, 591 insertions(+), 118 deletions(-) create mode 100644 src/Migration/Validation/Exception/MigrationValidationException.php create mode 100644 src/Migration/Validation/Log/MigrationValidationInvalidAssociationLog.php diff --git a/src/Exception/MigrationException.php b/src/Exception/MigrationException.php index 65bbf8a1c..4b5cffb2f 100644 --- a/src/Exception/MigrationException.php +++ b/src/Exception/MigrationException.php @@ -87,14 +87,10 @@ class MigrationException extends HttpException final public const API_CONNECTION_ERROR = 'SWAG_MIGRATION__API_CONNECTION_ERROR'; - final public const UNEXPECTED_NULL_VALUE = 'SWAG_MIGRATION__UNEXPECTED_NULL_VALUE'; - final public const COULD_NOT_CONVERT_FIX = 'SWAG_MIGRATION__COULD_NOT_CONVERT_FIX'; final public const MIGRATION_NOT_IN_STEP = 'SWAG_MIGRATION__MIGRATION_NOT_IN_STEP'; - final public const INVALID_ID = 'SWAG_MIGRATION__INVALID_ID'; - final public const DUPLICATE_SOURCE_CONNECTION = 'SWAG_MIGRATION__DUPLICATE_SOURCE_CONNECTION'; public static function associationEntityRequiredMissing(string $entity, string $missingEntity): self @@ -452,16 +448,6 @@ public static function invalidWriteContext(Context $invalidContext): self ); } - public static function unexpectedNullValue(string $fieldName): self - { - return new self( - Response::HTTP_INTERNAL_SERVER_ERROR, - self::UNEXPECTED_NULL_VALUE, - 'Unexpected null value for field "{{ fieldName }}".', - ['fieldName' => $fieldName] - ); - } - public static function couldNotConvertFix(string $missingKey): self { return new self( @@ -482,16 +468,6 @@ public static function migrationNotInStep(string $runUuid, string $step): self ); } - public static function invalidId(string $entityId, string $entityName): self - { - return new self( - Response::HTTP_INTERNAL_SERVER_ERROR, - self::INVALID_ID, - 'The id "{{ entityId }}" for entity "{{ entityName }}" is not a valid Uuid', - ['entityId' => $entityId, 'entityName' => $entityName] - ); - } - public static function duplicateSourceConnection(): self { return new self( diff --git a/src/Migration/Validation/Exception/MigrationValidationException.php b/src/Migration/Validation/Exception/MigrationValidationException.php new file mode 100644 index 000000000..6e557120a --- /dev/null +++ b/src/Migration/Validation/Exception/MigrationValidationException.php @@ -0,0 +1,91 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SwagMigrationAssistant\Migration\Validation\Exception; + +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Migration\MigrationException; +use Symfony\Component\HttpFoundation\Response; + +/** + * @codeCoverageIgnore + */ +#[Package('fundamentals@after-sales')] +class MigrationValidationException extends MigrationException +{ + final public const VALIDATION_UNEXPECTED_NULL_VALUE = 'SWAG_MIGRATION_VALIDATION__UNEXPECTED_NULL_VALUE'; + + final public const VALIDATION_INVALID_ID = 'SWAG_MIGRATION_VALIDATION__INVALID_ID'; + + final public const VALIDATION_INVALID_REQUIRED_FIELD_VALUE = 'SWAG_MIGRATION_VALIDATION__INVALID_REQUIRED_FIELD_VALUE'; + + final public const VALIDATION_INVALID_OPTIONAL_FIELD_VALUE = 'SWAG_MIGRATION_VALIDATION__INVALID_OPTIONAL_FIELD_VALUE'; + + final public const VALIDATION_INVALID_TRANSLATION = 'SWAG_MIGRATION_VALIDATION__INVALID_TRANSLATION'; + + final public const VALIDATION_INVALID_ASSOCIATION = 'SWAG_MIGRATION_VALIDATION__INVALID_ASSOCIATION'; + + public static function unexpectedNullValue(string $fieldName): self + { + return new self( + Response::HTTP_INTERNAL_SERVER_ERROR, + self::VALIDATION_UNEXPECTED_NULL_VALUE, + 'Unexpected null value for field "{{ fieldName }}".', + ['fieldName' => $fieldName] + ); + } + + public static function invalidId(string $entityId, string $entityName): self + { + return new self( + Response::HTTP_INTERNAL_SERVER_ERROR, + self::VALIDATION_INVALID_ID, + 'The id "{{ entityId }}" for entity "{{ entityName }}" is not a valid Uuid', + ['entityId' => $entityId, 'entityName' => $entityName] + ); + } + + public static function invalidRequiredFieldValue(string $entityName, string $fieldName, string $message): self + { + return new self( + Response::HTTP_BAD_REQUEST, + self::VALIDATION_INVALID_REQUIRED_FIELD_VALUE, + 'Invalid value for required field "{{ fieldName }}" in entity "{{ entityName }}": {{ message }}', + ['fieldName' => $fieldName, 'entityName' => $entityName, 'message' => $message] + ); + } + + public static function invalidOptionalFieldValue(string $entityName, string $fieldName, string $message): self + { + return new self( + Response::HTTP_BAD_REQUEST, + self::VALIDATION_INVALID_OPTIONAL_FIELD_VALUE, + 'Invalid value for optional field "{{ fieldName }}" in entity "{{ entityName }}": {{ message }}', + ['fieldName' => $fieldName, 'entityName' => $entityName, 'message' => $message] + ); + } + + public static function invalidTranslation(string $entityName, string $fieldName, string $message): self + { + return new self( + Response::HTTP_BAD_REQUEST, + self::VALIDATION_INVALID_TRANSLATION, + 'Invalid translation for field "{{ fieldName }}" in entity "{{ entityName }}": {{ message }}', + ['fieldName' => $fieldName, 'entityName' => $entityName, 'message' => $message] + ); + } + + public static function invalidAssociation(string $entityName, string $fieldName, string $message): self + { + return new self( + Response::HTTP_BAD_REQUEST, + self::VALIDATION_INVALID_ASSOCIATION, + 'Invalid association "{{ fieldName }}" in entity "{{ entityName }}": {{ message }}', + ['fieldName' => $fieldName, 'entityName' => $entityName, 'message' => $message] + ); + } +} diff --git a/src/Migration/Validation/Log/MigrationValidationInvalidAssociationLog.php b/src/Migration/Validation/Log/MigrationValidationInvalidAssociationLog.php new file mode 100644 index 000000000..ce1ffd980 --- /dev/null +++ b/src/Migration/Validation/Log/MigrationValidationInvalidAssociationLog.php @@ -0,0 +1,30 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SwagMigrationAssistant\Migration\Validation\Log; + +use Shopware\Core\Framework\Log\Package; +use SwagMigrationAssistant\Migration\Logging\Log\Builder\AbstractMigrationLogEntry; + +#[Package('fundamentals@after-sales')] +readonly class MigrationValidationInvalidAssociationLog extends AbstractMigrationLogEntry +{ + public function isUserFixable(): bool + { + return true; + } + + public function getLevel(): string + { + return self::LOG_LEVEL_ERROR; + } + + public function getCode(): string + { + return 'SWAG_MIGRATION_VALIDATION_INVALID_ASSOCIATION'; + } +} diff --git a/src/Migration/Validation/Log/MigrationValidationMissingRequiredFieldLog.php b/src/Migration/Validation/Log/MigrationValidationMissingRequiredFieldLog.php index fb39b8810..ff5909673 100644 --- a/src/Migration/Validation/Log/MigrationValidationMissingRequiredFieldLog.php +++ b/src/Migration/Validation/Log/MigrationValidationMissingRequiredFieldLog.php @@ -15,7 +15,7 @@ { public function isUserFixable(): bool { - return false; + return true; } public function getLevel(): string diff --git a/src/Migration/Validation/MigrationValidationService.php b/src/Migration/Validation/MigrationValidationService.php index 743bec839..ebb0a7c32 100644 --- a/src/Migration/Validation/MigrationValidationService.php +++ b/src/Migration/Validation/MigrationValidationService.php @@ -8,12 +8,23 @@ namespace SwagMigrationAssistant\Migration\Validation; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Exception; use Shopware\Core\Framework\Context; use Shopware\Core\Framework\DataAbstractionLayer\CompiledFieldCollection; use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry; +use Shopware\Core\Framework\DataAbstractionLayer\Field\AssociationField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\CreatedAtField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\Field; use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required; +use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToManyAssociationField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToManyAssociationField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToOneAssociationField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\ReferenceVersionField; use Shopware\Core\Framework\DataAbstractionLayer\Field\StorageAware; use Shopware\Core\Framework\DataAbstractionLayer\Field\TranslationsAssociationField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\UpdatedAtField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\VersionField; use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\WriteCommandQueue; use Shopware\Core\Framework\DataAbstractionLayer\Write\DataStack\KeyValuePair; use Shopware\Core\Framework\DataAbstractionLayer\Write\EntityExistence; @@ -21,20 +32,20 @@ use Shopware\Core\Framework\DataAbstractionLayer\Write\WriteParameterBag; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Uuid\Uuid; -use SwagMigrationAssistant\Exception\MigrationException; use SwagMigrationAssistant\Migration\Logging\Log\Builder\MigrationLogBuilder; use SwagMigrationAssistant\Migration\Logging\LoggingServiceInterface; use SwagMigrationAssistant\Migration\MigrationContextInterface; use SwagMigrationAssistant\Migration\Validation\Event\MigrationPostValidationEvent; use SwagMigrationAssistant\Migration\Validation\Event\MigrationPreValidationEvent; +use SwagMigrationAssistant\Migration\Validation\Exception\MigrationValidationException; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationExceptionLog; +use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidAssociationLog; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidOptionalFieldValueLog; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidRequiredFieldValueLog; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidRequiredTranslation; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationMissingRequiredFieldLog; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\Service\ResetInterface; -use function var_dump; /** * @internal @@ -42,6 +53,17 @@ #[Package('fundamentals@after-sales')] class MigrationValidationService implements ResetInterface { + /** + * @var list> + */ + private const SYSTEM_MANAGED_FIELDS = [ + CreatedAtField::class, + UpdatedAtField::class, + VersionField::class, + ReferenceVersionField::class, + TranslationsAssociationField::class, + ]; + /** * Maps entity name to an associative array of required field property names. * @@ -72,6 +94,8 @@ public function reset(): void /** * @param array|null $convertedEntity * @param array $sourceData + * + * @throws \Exception|Exception */ public function validate( MigrationContextInterface $migrationContext, @@ -84,6 +108,10 @@ public function validate( return null; } + if (!$this->definitionRegistry->has($entityName)) { + return null; + } + $entityDefinition = $this->definitionRegistry->getByEntityName($entityName); $validationContext = new MigrationValidationContext( @@ -98,21 +126,8 @@ public function validate( new MigrationPreValidationEvent($validationContext), ); - try { - $this->validateEntityStructure($validationContext); - $this->validateFields($validationContext); - } catch (\Throwable $exception) { - $validationContext->getValidationResult()->addLog( - MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) - ->withEntityName($entityDefinition->getEntityName()) - ->withSourceData($validationContext->getSourceData()) - ->withConvertedData($validationContext->getConvertedData()) - ->withExceptionMessage($exception->getMessage()) - ->withExceptionTrace($exception->getTrace()) - ->withEntityId($convertedEntity['id'] ?? null) - ->build(MigrationValidationExceptionLog::class) - ); - } + $this->validateEntityStructure($validationContext); + $this->validateFieldValues($validationContext); $this->eventDispatcher->dispatch( new MigrationPostValidationEvent($validationContext), @@ -128,32 +143,26 @@ public function validate( } /** - * Validates that all required fields are present. - * Required fields are determined by checking which database columns are non-nullable without a default value + * @throws Exception|\Exception */ private function validateEntityStructure(MigrationValidationContext $validationContext): void { $entityDefinition = $validationContext->getEntityDefinition(); - - $fields = $entityDefinition->getFields(); $entityName = $entityDefinition->getEntityName(); $convertedData = $validationContext->getConvertedData(); - $validationResult = $validationContext->getValidationResult(); - - $requiredFields = $this->getRequiredFields( - $fields, - $entityName - ); + $fields = $entityDefinition->getFields(); + $requiredFields = $this->getRequiredFields($fields, $entityName); $convertedFieldNames = array_keys($convertedData); + $missingRequiredFields = array_diff( array_keys($requiredFields), $convertedFieldNames ); foreach ($missingRequiredFields as $missingField) { - $validationResult->addLog( + $validationContext->getValidationResult()->addLog( MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) ->withEntityName($entityName) ->withFieldName($missingField) @@ -165,27 +174,88 @@ private function validateEntityStructure(MigrationValidationContext $validationC } /** - * Validates that all field values conform to their field definitions by attempting to serialize them. + * @throws \Exception */ - private function validateFields(MigrationValidationContext $validationContext): void + private function validateFieldValues(MigrationValidationContext $validationContext): void { $entityDefinition = $validationContext->getEntityDefinition(); $fields = $entityDefinition->getFields(); - $entityName = $entityDefinition->getEntityName(); $convertedData = $validationContext->getConvertedData(); - $validationResult = $validationContext->getValidationResult(); - $id = $convertedData['id'] ?? null; if ($id === null) { - throw MigrationException::unexpectedNullValue('id'); + $this->addExceptionLog( + $validationContext, + MigrationValidationException::unexpectedNullValue('id') + ); + + return; } if (!Uuid::isValid($id)) { - throw MigrationException::invalidId($id, $entityDefinition->getEntityName()); + $this->addExceptionLog( + $validationContext, + MigrationValidationException::invalidId($id, $entityDefinition->getEntityName()) + ); + + return; } + foreach ($convertedData as $fieldName => $value) { + if (!$fields->has($fieldName)) { + continue; + } + + $field = clone $fields->get($fieldName); + + try { + if ($field instanceof ManyToManyAssociationField || $field instanceof OneToManyAssociationField) { + $this->validateToManyAssociationStructure( + $validationContext, + $fieldName, + $value, + ); + + continue; + } + + if ($field instanceof ManyToOneAssociationField || $field instanceof OneToOneAssociationField) { + $this->validateToOneAssociationStructure( + $validationContext, + $fieldName, + $value, + ); + + continue; + } + + if ($field instanceof AssociationField) { + continue; + } + + $this->validateFieldByFieldSerializer($validationContext, $field, $value, $id); + } catch (MigrationValidationException $exception) { + $this->addValidationExceptionLog($validationContext, $exception, $fieldName, $value, $id); + } catch (\Throwable $exception) { + $this->addExceptionLog($validationContext, $exception); + } + } + } + + /** + * @throws MigrationValidationException|\Exception|Exception + */ + private function validateFieldByFieldSerializer( + MigrationValidationContext $validationContext, + Field $field, + mixed $value, + string $id, + ): void { + $entityDefinition = $validationContext->getEntityDefinition(); + $fields = $entityDefinition->getFields(); + $entityName = $entityDefinition->getEntityName(); + $entityExistence = EntityExistence::createForEntity( $entityDefinition->getEntityName(), ['id' => $id], @@ -199,60 +269,129 @@ private function validateFields(MigrationValidationContext $validationContext): ); $requiredFields = $this->getRequiredFields($fields, $entityName); + $isRequired = isset($requiredFields[$field->getPropertyName()]); + + /** + * Replace all flags with Required to force the serializer to validate this field. + * AbstractFieldSerializer::requiresValidation() skips validation for fields without Required flag. + * The field is cloned before this method is called to avoid mutating the original definition. + */ + $field->setFlags(new Required()); + + $keyValue = new KeyValuePair( + $field->getPropertyName(), + $value, + true + ); - foreach ($convertedData as $fieldName => $value) { - if (!$fields->has($fieldName)) { - continue; + try { + $serializer = $field->getSerializer(); + + // Consume the generator to trigger validation. Keys are not needed + \iterator_to_array($serializer->encode( + $field, + $entityExistence, + $keyValue, + $parameters + ), false); + } catch (\Throwable $e) { + if ($field instanceof TranslationsAssociationField) { + throw MigrationValidationException::invalidTranslation($entityName, $field->getPropertyName(), $e->getMessage()); } - $field = clone $fields->get($fieldName); - $isRequired = isset($requiredFields[$fieldName]); - - /** - * The required flag controls flow in AbstractFieldSerializer::requiresValidation(). - * Without it, the serializer will skip validation for the field. - */ - $field->setFlags(new Required()); - - $keyValue = new KeyValuePair( - $field->getPropertyName(), - $value, - true + if ($isRequired) { + throw MigrationValidationException::invalidRequiredFieldValue($entityName, $field->getPropertyName(), $e->getMessage()); + } + + throw MigrationValidationException::invalidOptionalFieldValue($entityName, $field->getPropertyName(), $e->getMessage()); + } + } + + /** + * Validates the structure of a to-many association field value (ManyToMany, OneToMany). + * + * Validates: + * - Association value is an array + * - Each entry in the association is an array + * - Each entry's 'id' field (if present) is a valid UUID + * + * @throws MigrationValidationException + */ + private function validateToManyAssociationStructure( + MigrationValidationContext $validationContext, + string $fieldName, + mixed $value, + ): void { + $entityName = $validationContext->getEntityDefinition()->getEntityName(); + + if (!\is_array($value)) { + throw MigrationValidationException::invalidAssociation( + $entityName, + $fieldName, + \sprintf('must be an array, got %s', \get_debug_type($value)) ); + } - try { - $serializer = $field->getSerializer(); - \iterator_to_array($serializer->encode($field, $entityExistence, $keyValue, $parameters), false); - } catch (\Throwable $e) { - $logClass = $isRequired - ? MigrationValidationInvalidRequiredFieldValueLog::class - : MigrationValidationInvalidOptionalFieldValueLog::class; - - if ($field instanceof TranslationsAssociationField) { - $logClass = MigrationValidationInvalidRequiredTranslation::class; - } + foreach ($value as $index => $entry) { + if (!\is_array($entry)) { + throw MigrationValidationException::invalidAssociation( + $entityName, + $fieldName . '/' . $index, + \sprintf('entry at index %s must be an array, got %s', $index, \get_debug_type($entry)) + ); + } - $validationResult->addLog( - MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) - ->withEntityName($entityDefinition->getEntityName()) - ->withFieldName($fieldName) - ->withConvertedData([$fieldName => $value]) - ->withSourceData($validationContext->getSourceData()) - ->withExceptionMessage($e->getMessage()) - ->withExceptionTrace($e->getTrace()) - ->withEntityId($id) - ->build($logClass) + if (isset($entry['id']) && !Uuid::isValid($entry['id'])) { + throw MigrationValidationException::invalidAssociation( + $entityName, + $fieldName . '/' . $index . '/id', + \sprintf('invalid UUID "%s" at index %s', $entry['id'], $index) ); } } } + /** + * Validates the structure of a to-one association field value (ManyToOne, OneToOne). + * + * Validates: + * - Association value is an array (object structure) + * - The 'id' field (if present) is a valid UUID + * + * @throws MigrationValidationException + */ + private function validateToOneAssociationStructure( + MigrationValidationContext $validationContext, + string $fieldName, + mixed $value, + ): void { + $entityName = $validationContext->getEntityDefinition()->getEntityName(); + + if (!\is_array($value)) { + throw MigrationValidationException::invalidAssociation( + $entityName, + $fieldName, + \sprintf('must be an array, got %s', \get_debug_type($value)) + ); + } + + if (isset($value['id']) && !Uuid::isValid($value['id'])) { + throw MigrationValidationException::invalidAssociation( + $entityName, + $fieldName . '/id', + \sprintf('invalid UUID "%s"', $value['id']) + ); + } + } + /** * Gets the map of required field property names for the given entity and caches the result for future calls. * * A field is considered required if: * - It has the Required flag in the entity definition, AND - * - It's either not StorageAware (no direct database column), OR its database column is non-nullable without a default value + * - It's either not StorageAware (no direct database column), OR its database column is non-nullable without a default value. + * + * @throws Exception * * @return array */ @@ -266,6 +405,10 @@ private function getRequiredFields(CompiledFieldCollection $fields, string $enti $requiredFields = []; foreach ($fields->filterByFlag(Required::class) as $field) { + if (\in_array($field::class, self::SYSTEM_MANAGED_FIELDS, true)) { + continue; + } + if (!($field instanceof StorageAware)) { $requiredFields[$field->getPropertyName()] = true; @@ -286,6 +429,8 @@ private function getRequiredFields(CompiledFieldCollection $fields, string $enti * Gets the map of required database columns for the given entity. * A required database column is defined as a column that is non-nullable, has no default value, and is not auto-incrementing. * + * @throws Exception + * * @return array */ private function getRequiredDatabaseColumns(string $entityName): array @@ -304,4 +449,51 @@ private function getRequiredDatabaseColumns(string $entityName): array return $requiredColumns; } + + private function addValidationExceptionLog( + MigrationValidationContext $validationContext, + MigrationValidationException $exception, + string $fieldName, + mixed $value, + string $entityId, + ): void { + $entityDefinition = $validationContext->getEntityDefinition(); + + $logClass = match ($exception->getErrorCode()) { + MigrationValidationException::VALIDATION_INVALID_ASSOCIATION => MigrationValidationInvalidAssociationLog::class, + MigrationValidationException::VALIDATION_INVALID_REQUIRED_FIELD_VALUE => MigrationValidationInvalidRequiredFieldValueLog::class, + MigrationValidationException::VALIDATION_INVALID_OPTIONAL_FIELD_VALUE => MigrationValidationInvalidOptionalFieldValueLog::class, + MigrationValidationException::VALIDATION_INVALID_TRANSLATION => MigrationValidationInvalidRequiredTranslation::class, + default => MigrationValidationExceptionLog::class, + }; + + $validationContext->getValidationResult()->addLog( + MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) + ->withEntityName($entityDefinition->getEntityName()) + ->withFieldName($fieldName) + ->withConvertedData([$fieldName => $value]) + ->withSourceData($validationContext->getSourceData()) + ->withExceptionMessage($exception->getMessage()) + ->withExceptionTrace($exception->getTrace()) + ->withEntityId($entityId) + ->build($logClass) + ); + } + + private function addExceptionLog(MigrationValidationContext $validationContext, \Throwable $exception): void + { + $entityDefinition = $validationContext->getEntityDefinition(); + $convertedData = $validationContext->getConvertedData(); + + $validationContext->getValidationResult()->addLog( + MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) + ->withEntityName($entityDefinition->getEntityName()) + ->withSourceData($validationContext->getSourceData()) + ->withConvertedData($convertedData) + ->withExceptionMessage($exception->getMessage()) + ->withExceptionTrace($exception->getTrace()) + ->withEntityId($convertedData['id'] ?? null) + ->build(MigrationValidationExceptionLog::class) + ); + } } diff --git a/src/Resources/app/administration/src/module/swag-migration/component/card/swag-migration-shop-information/swag-migration-shop-information.html.twig b/src/Resources/app/administration/src/module/swag-migration/component/card/swag-migration-shop-information/swag-migration-shop-information.html.twig index afac4e53f..eaeb1607a 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/card/swag-migration-shop-information/swag-migration-shop-information.html.twig +++ b/src/Resources/app/administration/src/module/swag-migration/component/card/swag-migration-shop-information/swag-migration-shop-information.html.twig @@ -72,15 +72,16 @@ v-if="sslActive" class="swag-migration-shop-information__shop-domain-prefix-icon" name="regular-lock" - size="12px" + size="14px" /> + {{ shopUrlPrefix }}{{ shopUrl }} {% endblock %} diff --git a/src/Resources/app/administration/src/module/swag-migration/component/card/swag-migration-shop-information/swag-migration-shop-information.scss b/src/Resources/app/administration/src/module/swag-migration/component/card/swag-migration-shop-information/swag-migration-shop-information.scss index 514735cc9..0977488f0 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/card/swag-migration-shop-information/swag-migration-shop-information.scss +++ b/src/Resources/app/administration/src/module/swag-migration/component/card/swag-migration-shop-information/swag-migration-shop-information.scss @@ -38,7 +38,7 @@ align-items: center; font-size: var(--font-size-xs); white-space: nowrap; - gap: var(--scale-size-4); + gap: var(--scale-size-2); } .swag-migration-shop-information__profile-avatar { @@ -46,11 +46,14 @@ } .swag-migration-shop-information__shop-domain-prefix { + display: flex; + align-items: center; + gap: var(--scale-size-2); color: var(--color-icon-attention-default); } .swag-migration-shop-information__shop-domain-prefix--is-ssl { - color: var(--color-icon-attention-default); + color: var(--color-icon-positive-default); } .swag-migration-shop-information__connection-info { diff --git a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-step/swag-migration-error-resolution-step.scss b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-step/swag-migration-error-resolution-step.scss index 3b4c3574c..e6e22fa24 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-step/swag-migration-error-resolution-step.scss +++ b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-step/swag-migration-error-resolution-step.scss @@ -58,7 +58,7 @@ } .mt-card__content { - padding: 0; + padding: 0 !important; } .mt-tabs__item:first-of-type { diff --git a/tests/integration/Migration/Validation/MigrationValidationServiceTest.php b/tests/integration/Migration/Validation/MigrationValidationServiceTest.php index 3f8f1ec85..c227c61e5 100644 --- a/tests/integration/Migration/Validation/MigrationValidationServiceTest.php +++ b/tests/integration/Migration/Validation/MigrationValidationServiceTest.php @@ -18,7 +18,6 @@ use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; use Shopware\Core\Framework\Uuid\Uuid; -use SwagMigrationAssistant\Exception\MigrationException; use SwagMigrationAssistant\Migration\Connection\SwagMigrationConnectionEntity; use SwagMigrationAssistant\Migration\Logging\SwagMigrationLoggingCollection; use SwagMigrationAssistant\Migration\Logging\SwagMigrationLoggingDefinition; @@ -28,7 +27,9 @@ use SwagMigrationAssistant\Migration\Run\MigrationStep; use SwagMigrationAssistant\Migration\Run\SwagMigrationRunCollection; use SwagMigrationAssistant\Migration\Run\SwagMigrationRunDefinition; +use SwagMigrationAssistant\Migration\Validation\Exception\MigrationValidationException; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationExceptionLog; +use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidAssociationLog; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidOptionalFieldValueLog; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidRequiredFieldValueLog; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidRequiredTranslation; @@ -37,8 +38,6 @@ use SwagMigrationAssistant\Migration\Validation\MigrationValidationService; use SwagMigrationAssistant\Profile\Shopware54\Shopware54Profile; use SwagMigrationAssistant\Test\Mock\Gateway\Dummy\Local\DummyLocalGateway; -use function array_map; -use function var_dump; /** * @internal @@ -173,7 +172,7 @@ public function testShouldValidateStructureAndFieldsValues(array $convertedData, static::assertCount(\count($expectedLogs), $logs); static::assertCount(\count($expectedLogs), $result->getLogs()); - $logCodes = array_map(fn($log) => $log::class, $result->getLogs()); + $logCodes = \array_map(fn ($log) => $log::class, $result->getLogs()); static::assertSame($expectedLogs, $logCodes); } @@ -191,7 +190,7 @@ public function testShouldFilterNullableFields(): void static::assertInstanceOf(MigrationValidationResult::class, $result); - $missingFields = \array_map(fn($log) => $log->getFieldName(), $result->getLogs()); + $missingFields = \array_map(fn ($log) => $log->getFieldName(), $result->getLogs()); static::assertCount(3, $missingFields); $expectedMissingFields = [ @@ -223,14 +222,14 @@ public function testShouldLogWhenEntityHasNoId(): void static::assertInstanceOf(MigrationValidationResult::class, $result); - $logs = \array_filter($result->getLogs(), fn($log) => $log instanceof MigrationValidationExceptionLog); + $logs = \array_filter($result->getLogs(), fn ($log) => $log instanceof MigrationValidationExceptionLog); static::assertCount(1, $logs); $exceptionLog = array_values($logs)[0]; static::assertInstanceOf(MigrationValidationExceptionLog::class, $exceptionLog); static::assertSame( - MigrationException::unexpectedNullValue('id')->getMessage(), + MigrationValidationException::unexpectedNullValue('id')->getMessage(), $exceptionLog->getExceptionMessage() ); } @@ -255,14 +254,14 @@ public function testShouldLogWhenEntityHasInvalidId(): void static::assertInstanceOf(MigrationValidationResult::class, $result); - $logs = \array_filter($result->getLogs(), fn($log) => $log instanceof MigrationValidationExceptionLog); + $logs = \array_filter($result->getLogs(), fn ($log) => $log instanceof MigrationValidationExceptionLog); static::assertCount(1, $logs); $exceptionLog = array_values($logs)[0]; static::assertInstanceOf(MigrationValidationExceptionLog::class, $exceptionLog); static::assertSame( - MigrationException::invalidId($id, SwagMigrationLoggingDefinition::ENTITY_NAME)->getMessage(), + MigrationValidationException::invalidId($id, SwagMigrationLoggingDefinition::ENTITY_NAME)->getMessage(), $exceptionLog->getExceptionMessage(), ); } @@ -289,7 +288,7 @@ public function testValidateAssociations(array $convertedData, array $mappings, static::assertInstanceOf(MigrationValidationResult::class, $result); - $logClasses = array_map(static fn($log) => $log::class, $result->getLogs()); + $logClasses = \array_map(static fn ($log) => $log::class, $result->getLogs()); static::assertEquals($expectedLogs, $logClasses); } @@ -299,7 +298,7 @@ public function testMissingTranslationAssociation(): void 'id' => Uuid::randomHex(), 'versionId' => Uuid::randomHex(), 'stock' => 10, - 'translations' => ['lel'] + 'translations' => ['lel'], ]; $result = $this->validationService->validate( @@ -312,7 +311,7 @@ public function testMissingTranslationAssociation(): void static::assertInstanceOf(MigrationValidationResult::class, $result); - $logClasses = array_map(static fn($log) => $log::class, $result->getLogs()); + $logClasses = \array_map(static fn ($log) => $log::class, $result->getLogs()); static::assertCount(1, $logClasses); static::assertEquals([MigrationValidationInvalidRequiredTranslation::class], $logClasses); } @@ -327,7 +326,7 @@ public function testValidTranslationAssociation(): void Defaults::LANGUAGE_SYSTEM => [ 'name' => 'Valid name', ], - ] + ], ]; $result = $this->validationService->validate( @@ -340,7 +339,7 @@ public function testValidTranslationAssociation(): void static::assertInstanceOf(MigrationValidationResult::class, $result); - $logClasses = array_map(static fn($log) => $log::class, $result->getLogs()); + $logClasses = \array_map(static fn ($log) => $log::class, $result->getLogs()); static::assertCount(0, $logClasses); } @@ -497,4 +496,185 @@ public static function associationProvider(): \Generator ], ]; } + + /** + * Tests for ManyToMany and OneToMany association validation. + * + * @return \Generator, array}> + */ + public static function toManyAssociationProvider(): \Generator + { + $baseProduct = [ + 'id' => Uuid::randomHex(), + 'versionId' => Uuid::randomHex(), + 'stock' => 10, + 'translations' => [ + Defaults::LANGUAGE_SYSTEM => [ + 'name' => 'Test Product', + ], + ], + ]; + + yield 'valid categories association (empty array)' => [ + [ + ...$baseProduct, + 'categories' => [], + ], + [], + ]; + + yield 'valid categories association (with valid entries)' => [ + [ + ...$baseProduct, + 'categories' => [ + ['id' => Uuid::randomHex()], + ['id' => Uuid::randomHex()], + ], + ], + [], + ]; + + yield 'invalid categories association (non-array value)' => [ + [ + ...$baseProduct, + 'categories' => 'not-an-array', + ], + [ + MigrationValidationInvalidAssociationLog::class, + ], + ]; + + yield 'invalid categories association (entry is not array)' => [ + [ + ...$baseProduct, + 'categories' => [ + 'not-an-array-entry', + ], + ], + [ + MigrationValidationInvalidAssociationLog::class, + ], + ]; + + yield 'invalid categories association (invalid UUID in entry)' => [ + [ + ...$baseProduct, + 'categories' => [ + ['id' => 'invalid-uuid'], + ], + ], + [ + MigrationValidationInvalidAssociationLog::class, + ], + ]; + + yield 'invalid categories association (multiple errors)' => [ + [ + ...$baseProduct, + 'categories' => [ + ['id' => Uuid::randomHex()], // valid + 'invalid-entry', // not array + ['id' => 'invalid-uuid'], // invalid uuid + ], + ], + [ + MigrationValidationInvalidAssociationLog::class, // entry not array + MigrationValidationInvalidAssociationLog::class, // invalid uuid + ], + ]; + } + + /** + * @param array $convertedData + * @param array $expectedLogs + */ + #[DataProvider('toManyAssociationProvider')] + public function testValidateToManyAssociations(array $convertedData, array $expectedLogs): void + { + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + $convertedData, + ProductDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result); + + $logClasses = \array_map(static fn ($log) => $log::class, $result->getLogs()); + static::assertEquals($expectedLogs, $logClasses); + } + + /** + * Tests for ManyToOne and OneToOne association validation. + * + * @return \Generator, array}> + */ + public static function toOneAssociationProvider(): \Generator + { + $baseProduct = [ + 'id' => Uuid::randomHex(), + 'versionId' => Uuid::randomHex(), + 'stock' => 10, + 'translations' => [ + Defaults::LANGUAGE_SYSTEM => [ + 'name' => 'Test Product', + ], + ], + ]; + + yield 'valid manufacturer association (null value)' => [ + $baseProduct, + [], + ]; + + yield 'valid manufacturer association (with valid id)' => [ + [ + ...$baseProduct, + 'manufacturer' => ['id' => Uuid::randomHex(), 'name' => 'Test Manufacturer'], + ], + [], + ]; + + yield 'invalid manufacturer association (non-array value)' => [ + [ + ...$baseProduct, + 'manufacturer' => 'not-an-array', + ], + [ + MigrationValidationInvalidAssociationLog::class, + ], + ]; + + yield 'invalid manufacturer association (invalid UUID)' => [ + [ + ...$baseProduct, + 'manufacturer' => ['id' => 'invalid-uuid'], + ], + [ + MigrationValidationInvalidAssociationLog::class, + ], + ]; + } + + /** + * @param array $convertedData + * @param array $expectedLogs + */ + #[DataProvider('toOneAssociationProvider')] + public function testValidateToOneAssociations(array $convertedData, array $expectedLogs): void + { + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + $convertedData, + ProductDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result); + + $logClasses = \array_map(static fn ($log) => $log::class, $result->getLogs()); + static::assertEquals($expectedLogs, $logClasses); + } } From 54de53b083f4fd541f99fcd0a08a0ae781750772 Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Wed, 7 Jan 2026 09:57:01 +0100 Subject: [PATCH 08/26] feat: cleanup and add tests --- .../Validation/MigrationValidationService.php | 231 +++++++++--------- .../MigrationValidationServiceTest.php | 203 +++++++++++---- 2 files changed, 269 insertions(+), 165 deletions(-) diff --git a/src/Migration/Validation/MigrationValidationService.php b/src/Migration/Validation/MigrationValidationService.php index ebb0a7c32..79b3ba14c 100644 --- a/src/Migration/Validation/MigrationValidationService.php +++ b/src/Migration/Validation/MigrationValidationService.php @@ -143,134 +143,147 @@ public function validate( } /** - * @throws Exception|\Exception + * @throws \Exception|Exception */ private function validateEntityStructure(MigrationValidationContext $validationContext): void { $entityDefinition = $validationContext->getEntityDefinition(); - $entityName = $entityDefinition->getEntityName(); - $convertedData = $validationContext->getConvertedData(); - $fields = $entityDefinition->getFields(); - - $requiredFields = $this->getRequiredFields($fields, $entityName); - $convertedFieldNames = array_keys($convertedData); + $requiredFields = $this->getRequiredFields( + $entityDefinition->getFields(), + $entityDefinition->getEntityName() + ); $missingRequiredFields = array_diff( array_keys($requiredFields), - $convertedFieldNames + array_keys($validationContext->getConvertedData()) ); foreach ($missingRequiredFields as $missingField) { - $validationContext->getValidationResult()->addLog( - MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) - ->withEntityName($entityName) - ->withFieldName($missingField) - ->withConvertedData($convertedData) - ->withEntityId($convertedData['id'] ?? null) - ->build(MigrationValidationMissingRequiredFieldLog::class) - ); + $this->addMissingRequiredFieldLog($validationContext, $missingField); } } /** - * @throws \Exception + * @throws \Exception|Exception */ private function validateFieldValues(MigrationValidationContext $validationContext): void { + $convertedData = $validationContext->getConvertedData(); + $id = $convertedData['id'] ?? null; + + if (!$this->validateId($validationContext, $id)) { + return; + } + $entityDefinition = $validationContext->getEntityDefinition(); + $entityName = $entityDefinition->getEntityName(); $fields = $entityDefinition->getFields(); - $convertedData = $validationContext->getConvertedData(); - $id = $convertedData['id'] ?? null; + $entityExistence = EntityExistence::createForEntity($entityName, ['id' => $id]); + $parameters = new WriteParameterBag( + $entityDefinition, + WriteContext::createFromContext($validationContext->getContext()), + '', + new WriteCommandQueue(), + ); + + $requiredFields = $this->getRequiredFields($fields, $entityName); + foreach ($convertedData as $fieldName => $value) { + $this->validateField( + $validationContext, + $fields, + $fieldName, + $value, + $id, + $entityExistence, + $parameters, + isset($requiredFields[$fieldName]) + ); + } + } + + private function validateId(MigrationValidationContext $validationContext, mixed $id): bool + { if ($id === null) { $this->addExceptionLog( $validationContext, MigrationValidationException::unexpectedNullValue('id') ); - return; + return false; } - if (!Uuid::isValid($id)) { + if (!\is_string($id) || !Uuid::isValid($id)) { $this->addExceptionLog( $validationContext, - MigrationValidationException::invalidId($id, $entityDefinition->getEntityName()) + MigrationValidationException::invalidId((string) $id, $validationContext->getEntityDefinition()->getEntityName()) ); + return false; + } + + return true; + } + + private function validateField( + MigrationValidationContext $validationContext, + CompiledFieldCollection $fields, + string $fieldName, + mixed $value, + string $id, + EntityExistence $existence, + WriteParameterBag $parameters, + bool $isRequired, + ): void { + if (!$fields->has($fieldName)) { return; } - foreach ($convertedData as $fieldName => $value) { - if (!$fields->has($fieldName)) { - continue; + $field = clone $fields->get($fieldName); + + try { + if ($field instanceof TranslationsAssociationField) { + $this->validateFieldByFieldSerializer($field, $value, $existence, $parameters, $isRequired); + + return; + } + + if ($field instanceof ManyToManyAssociationField || $field instanceof OneToManyAssociationField) { + $this->validateToManyAssociationStructure($validationContext, $fieldName, $value); + + return; + } + + if ($field instanceof ManyToOneAssociationField || $field instanceof OneToOneAssociationField) { + $this->validateToOneAssociationStructure($validationContext, $fieldName, $value); + + return; } - $field = clone $fields->get($fieldName); - - try { - if ($field instanceof ManyToManyAssociationField || $field instanceof OneToManyAssociationField) { - $this->validateToManyAssociationStructure( - $validationContext, - $fieldName, - $value, - ); - - continue; - } - - if ($field instanceof ManyToOneAssociationField || $field instanceof OneToOneAssociationField) { - $this->validateToOneAssociationStructure( - $validationContext, - $fieldName, - $value, - ); - - continue; - } - - if ($field instanceof AssociationField) { - continue; - } - - $this->validateFieldByFieldSerializer($validationContext, $field, $value, $id); - } catch (MigrationValidationException $exception) { - $this->addValidationExceptionLog($validationContext, $exception, $fieldName, $value, $id); - } catch (\Throwable $exception) { - $this->addExceptionLog($validationContext, $exception); + if ($field instanceof AssociationField) { + return; } + + $this->validateFieldByFieldSerializer($field, $value, $existence, $parameters, $isRequired); + } catch (MigrationValidationException $exception) { + $this->addValidationExceptionLog($validationContext, $exception, $fieldName, $value, $id); + } catch (\Throwable $exception) { + $this->addExceptionLog($validationContext, $exception); } } /** - * @throws MigrationValidationException|\Exception|Exception + * @throws MigrationValidationException|\Exception */ private function validateFieldByFieldSerializer( - MigrationValidationContext $validationContext, Field $field, mixed $value, - string $id, + EntityExistence $entityExistence, + WriteParameterBag $parameters, + bool $isRequired, ): void { - $entityDefinition = $validationContext->getEntityDefinition(); - $fields = $entityDefinition->getFields(); - $entityName = $entityDefinition->getEntityName(); - - $entityExistence = EntityExistence::createForEntity( - $entityDefinition->getEntityName(), - ['id' => $id], - ); - - $parameters = new WriteParameterBag( - $entityDefinition, - WriteContext::createFromContext($validationContext->getContext()), - '', - new WriteCommandQueue(), - ); - - $requiredFields = $this->getRequiredFields($fields, $entityName); - $isRequired = isset($requiredFields[$field->getPropertyName()]); - /** * Replace all flags with Required to force the serializer to validate this field. * AbstractFieldSerializer::requiresValidation() skips validation for fields without Required flag. @@ -295,26 +308,22 @@ private function validateFieldByFieldSerializer( $parameters ), false); } catch (\Throwable $e) { + $entityName = $parameters->getDefinition()->getEntityName(); + $propertyName = $field->getPropertyName(); + if ($field instanceof TranslationsAssociationField) { - throw MigrationValidationException::invalidTranslation($entityName, $field->getPropertyName(), $e->getMessage()); + throw MigrationValidationException::invalidTranslation($entityName, $propertyName, $e->getMessage()); } if ($isRequired) { - throw MigrationValidationException::invalidRequiredFieldValue($entityName, $field->getPropertyName(), $e->getMessage()); + throw MigrationValidationException::invalidRequiredFieldValue($entityName, $propertyName, $e->getMessage()); } - throw MigrationValidationException::invalidOptionalFieldValue($entityName, $field->getPropertyName(), $e->getMessage()); + throw MigrationValidationException::invalidOptionalFieldValue($entityName, $propertyName, $e->getMessage()); } } /** - * Validates the structure of a to-many association field value (ManyToMany, OneToMany). - * - * Validates: - * - Association value is an array - * - Each entry in the association is an array - * - Each entry's 'id' field (if present) is a valid UUID - * * @throws MigrationValidationException */ private function validateToManyAssociationStructure( @@ -352,12 +361,6 @@ private function validateToManyAssociationStructure( } /** - * Validates the structure of a to-one association field value (ManyToOne, OneToOne). - * - * Validates: - * - Association value is an array (object structure) - * - The 'id' field (if present) is a valid UUID - * * @throws MigrationValidationException */ private function validateToOneAssociationStructure( @@ -385,12 +388,6 @@ private function validateToOneAssociationStructure( } /** - * Gets the map of required field property names for the given entity and caches the result for future calls. - * - * A field is considered required if: - * - It has the Required flag in the entity definition, AND - * - It's either not StorageAware (no direct database column), OR its database column is non-nullable without a default value. - * * @throws Exception * * @return array @@ -420,15 +417,10 @@ private function getRequiredFields(CompiledFieldCollection $fields, string $enti } } - $this->requiredDefinitionFieldsCache[$entityName] = $requiredFields; - - return $requiredFields; + return $this->requiredDefinitionFieldsCache[$entityName] = $requiredFields; } /** - * Gets the map of required database columns for the given entity. - * A required database column is defined as a column that is non-nullable, has no default value, and is not auto-incrementing. - * * @throws Exception * * @return array @@ -450,6 +442,21 @@ private function getRequiredDatabaseColumns(string $entityName): array return $requiredColumns; } + private function addMissingRequiredFieldLog(MigrationValidationContext $validationContext, string $fieldName): void + { + $convertedData = $validationContext->getConvertedData(); + $entityId = isset($convertedData['id']) ? (string) $convertedData['id'] : null; + + $validationContext->getValidationResult()->addLog( + MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) + ->withEntityName($validationContext->getEntityDefinition()->getEntityName()) + ->withFieldName($fieldName) + ->withConvertedData($convertedData) + ->withEntityId($entityId) + ->build(MigrationValidationMissingRequiredFieldLog::class) + ); + } + private function addValidationExceptionLog( MigrationValidationContext $validationContext, MigrationValidationException $exception, @@ -457,8 +464,6 @@ private function addValidationExceptionLog( mixed $value, string $entityId, ): void { - $entityDefinition = $validationContext->getEntityDefinition(); - $logClass = match ($exception->getErrorCode()) { MigrationValidationException::VALIDATION_INVALID_ASSOCIATION => MigrationValidationInvalidAssociationLog::class, MigrationValidationException::VALIDATION_INVALID_REQUIRED_FIELD_VALUE => MigrationValidationInvalidRequiredFieldValueLog::class, @@ -469,7 +474,7 @@ private function addValidationExceptionLog( $validationContext->getValidationResult()->addLog( MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) - ->withEntityName($entityDefinition->getEntityName()) + ->withEntityName($validationContext->getEntityDefinition()->getEntityName()) ->withFieldName($fieldName) ->withConvertedData([$fieldName => $value]) ->withSourceData($validationContext->getSourceData()) @@ -482,17 +487,17 @@ private function addValidationExceptionLog( private function addExceptionLog(MigrationValidationContext $validationContext, \Throwable $exception): void { - $entityDefinition = $validationContext->getEntityDefinition(); $convertedData = $validationContext->getConvertedData(); + $entityId = isset($convertedData['id']) ? (string) $convertedData['id'] : null; $validationContext->getValidationResult()->addLog( MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) - ->withEntityName($entityDefinition->getEntityName()) + ->withEntityName($validationContext->getEntityDefinition()->getEntityName()) ->withSourceData($validationContext->getSourceData()) ->withConvertedData($convertedData) ->withExceptionMessage($exception->getMessage()) ->withExceptionTrace($exception->getTrace()) - ->withEntityId($convertedData['id'] ?? null) + ->withEntityId($entityId) ->build(MigrationValidationExceptionLog::class) ); } diff --git a/tests/integration/Migration/Validation/MigrationValidationServiceTest.php b/tests/integration/Migration/Validation/MigrationValidationServiceTest.php index c227c61e5..3ea51f83b 100644 --- a/tests/integration/Migration/Validation/MigrationValidationServiceTest.php +++ b/tests/integration/Migration/Validation/MigrationValidationServiceTest.php @@ -191,31 +191,22 @@ public function testShouldFilterNullableFields(): void static::assertInstanceOf(MigrationValidationResult::class, $result); $missingFields = \array_map(fn ($log) => $log->getFieldName(), $result->getLogs()); - static::assertCount(3, $missingFields); - $expectedMissingFields = [ - 'active', // has no required flag - 'price', // is nullable, but has required flag - 'cmsPageVersionId', // has default value, but has required flag - ]; - - static::assertCount( - 0, - \array_intersect($expectedMissingFields, $missingFields) - ); + // Only 'stock' should be required as its not nullable in db and has no default + static::assertCount(1, $missingFields); + static::assertContains('stock', $missingFields); } - public function testShouldLogWhenEntityHasNoId(): void + /** + * @param array $convertedData + */ + #[DataProvider('invalidIdProvider')] + public function testShouldLogWhenEntityHasInvalidOrMissingId(array $convertedData, string $expectedExceptionMessage): void { $result = $this->validationService->validate( $this->migrationContext, $this->context, - [ - 'level' => 'error', - 'code' => 'some_code', - 'userFixable' => true, - 'createdAt' => (new \DateTime())->format(\DATE_ATOM), - ], + $convertedData, SwagMigrationLoggingDefinition::ENTITY_NAME, [] ); @@ -228,42 +219,40 @@ public function testShouldLogWhenEntityHasNoId(): void $exceptionLog = array_values($logs)[0]; static::assertInstanceOf(MigrationValidationExceptionLog::class, $exceptionLog); - static::assertSame( - MigrationValidationException::unexpectedNullValue('id')->getMessage(), - $exceptionLog->getExceptionMessage() - ); + static::assertSame($expectedExceptionMessage, $exceptionLog->getExceptionMessage()); } - public function testShouldLogWhenEntityHasInvalidId(): void + /** + * @return \Generator, string}> + */ + public static function invalidIdProvider(): \Generator { - $id = 'invalid-uuid'; - - $result = $this->validationService->validate( - $this->migrationContext, - $this->context, - [ - 'id' => $id, - 'level' => 'error', - 'code' => 'some_code', - 'userFixable' => true, - 'createdAt' => (new \DateTime())->format(\DATE_ATOM), - ], - SwagMigrationLoggingDefinition::ENTITY_NAME, - [] - ); + $baseData = [ + 'level' => 'error', + 'code' => 'some_code', + 'userFixable' => true, + 'createdAt' => (new \DateTime())->format(\DATE_ATOM), + ]; - static::assertInstanceOf(MigrationValidationResult::class, $result); + yield 'missing id (null)' => [ + $baseData, + MigrationValidationException::unexpectedNullValue('id')->getMessage(), + ]; - $logs = \array_filter($result->getLogs(), fn ($log) => $log instanceof MigrationValidationExceptionLog); - static::assertCount(1, $logs); + yield 'invalid uuid string' => [ + [...$baseData, 'id' => 'invalid-uuid'], + MigrationValidationException::invalidId('invalid-uuid', SwagMigrationLoggingDefinition::ENTITY_NAME)->getMessage(), + ]; - $exceptionLog = array_values($logs)[0]; - static::assertInstanceOf(MigrationValidationExceptionLog::class, $exceptionLog); + yield 'integer id instead of uuid string' => [ + [...$baseData, 'id' => 12345], + MigrationValidationException::invalidId('12345', SwagMigrationLoggingDefinition::ENTITY_NAME)->getMessage(), + ]; - static::assertSame( - MigrationValidationException::invalidId($id, SwagMigrationLoggingDefinition::ENTITY_NAME)->getMessage(), - $exceptionLog->getExceptionMessage(), - ); + yield 'empty string id' => [ + [...$baseData, 'id' => ''], + MigrationValidationException::invalidId('', SwagMigrationLoggingDefinition::ENTITY_NAME)->getMessage(), + ]; } /** @@ -572,14 +561,14 @@ public static function toManyAssociationProvider(): \Generator [ ...$baseProduct, 'categories' => [ - ['id' => Uuid::randomHex()], // valid - 'invalid-entry', // not array - ['id' => 'invalid-uuid'], // invalid uuid + ['id' => Uuid::randomHex()], + 'invalid-entry', + ['id' => 'invalid-uuid'], ], ], [ - MigrationValidationInvalidAssociationLog::class, // entry not array - MigrationValidationInvalidAssociationLog::class, // invalid uuid + // Only first error is logged since validation throws on first failure + MigrationValidationInvalidAssociationLog::class, ], ]; } @@ -677,4 +666,114 @@ public function testValidateToOneAssociations(array $convertedData, array $expec $logClasses = \array_map(static fn ($log) => $log::class, $result->getLogs()); static::assertEquals($expectedLogs, $logClasses); } + + public function testShouldReturnNullWhenEntityDefinitionDoesNotExist(): void + { + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + ['id' => Uuid::randomHex()], + 'non_existent_entity_definition', + [] + ); + + static::assertNull($result); + } + + public function testResetShouldClearRequiredFieldsCache(): void + { + $result1 = $this->validationService->validate( + $this->migrationContext, + $this->context, + [ + 'id' => Uuid::randomHex(), + 'profileName' => 'profile', + 'gatewayName' => 'gateway', + 'level' => 'error', + 'code' => 'some_code', + 'userFixable' => true, + ], + SwagMigrationLoggingDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result1); + + $this->validationService->reset(); + + $result2 = $this->validationService->validate( + $this->migrationContext, + $this->context, + [ + 'id' => Uuid::randomHex(), + 'profileName' => 'profile', + 'gatewayName' => 'gateway', + 'level' => 'error', + 'code' => 'some_code', + 'userFixable' => true, + ], + SwagMigrationLoggingDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result2); + + $this->clearCacheData(); + + static::assertCount(\count($result1->getLogs()), $result2->getLogs()); + } + + public function testValidNestedAssociationWithValidUuids(): void + { + $categoryId1 = Uuid::randomHex(); + $categoryId2 = Uuid::randomHex(); + + $convertedData = [ + 'id' => Uuid::randomHex(), + 'versionId' => Uuid::randomHex(), + 'stock' => 10, + 'translations' => [ + Defaults::LANGUAGE_SYSTEM => [ + 'name' => 'Test Product', + ], + ], + 'categories' => [ + ['id' => $categoryId1], + ['id' => $categoryId2], + ], + ]; + + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + $convertedData, + ProductDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result); + static::assertCount(0, $result->getLogs()); + } + + public function testValidationLogsAreSavedToDatabase(): void + { + $this->validationService->validate( + $this->migrationContext, + $this->context, + [ + 'id' => Uuid::randomHex(), + 'level' => 'error', + 'code' => 'some_code', + 'userFixable' => true, + ], + SwagMigrationLoggingDefinition::ENTITY_NAME, + [] + ); + + $this->clearCacheData(); + + $logs = $this->loggingRepo->search(new Criteria(), $this->context)->getEntities(); + static::assertInstanceOf(SwagMigrationLoggingCollection::class, $logs); + static::assertGreaterThan(0, $logs->count()); + } } From 2be56796e41e247d2bf99b6a73a29e907f0b840d Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Wed, 7 Jan 2026 10:04:06 +0100 Subject: [PATCH 09/26] fix: null order custom field --- src/Profile/Shopware/Converter/OrderConverter.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Profile/Shopware/Converter/OrderConverter.php b/src/Profile/Shopware/Converter/OrderConverter.php index bbe5014eb..59c44568d 100644 --- a/src/Profile/Shopware/Converter/OrderConverter.php +++ b/src/Profile/Shopware/Converter/OrderConverter.php @@ -305,7 +305,11 @@ public function convert( } if (isset($data['attributes'])) { - $converted['customFields'] = $this->getAttributes($data['attributes'], DefaultEntities::ORDER, $this->connectionName, ['id', 'orderID'], $this->context); + $customField = $this->getAttributes($data['attributes'], DefaultEntities::ORDER, $this->connectionName, ['id', 'orderID'], $this->context); + + if ($customField !== null) { + $converted['customFields'] = $customField; + } } unset($data['attributes']); From 952688741242dc4e66e59ad9b15d866ab0bf0900 Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Wed, 7 Jan 2026 10:29:31 +0100 Subject: [PATCH 10/26] fix: test --- .../Logging/Log/MigrationLogTest.php | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/unit/Migration/Logging/Log/MigrationLogTest.php b/tests/unit/Migration/Logging/Log/MigrationLogTest.php index c7059e4f8..c2ac1943c 100644 --- a/tests/unit/Migration/Logging/Log/MigrationLogTest.php +++ b/tests/unit/Migration/Logging/Log/MigrationLogTest.php @@ -39,7 +39,10 @@ use SwagMigrationAssistant\Migration\Logging\Log\WriteExceptionRunLog; use SwagMigrationAssistant\Migration\MigrationContext; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationExceptionLog; +use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidAssociationLog; +use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidOptionalFieldValueLog; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidRequiredFieldValueLog; +use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidRequiredTranslation; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationMissingRequiredFieldLog; use SwagMigrationAssistant\Profile\Shopware54\Shopware54Profile; use SwagMigrationAssistant\Test\Mock\Gateway\Dummy\Local\DummyLocalGateway; @@ -105,9 +108,23 @@ public static function logProvider(): \Generator 'userFixable' => false, ]; + yield MigrationValidationInvalidAssociationLog::class => [ + 'logClass' => MigrationValidationInvalidAssociationLog::class, + 'code' => 'SWAG_MIGRATION_VALIDATION_INVALID_ASSOCIATION', + 'level' => AbstractMigrationLogEntry::LOG_LEVEL_ERROR, + 'userFixable' => true, + ]; + + yield MigrationValidationInvalidOptionalFieldValueLog::class => [ + 'logClass' => MigrationValidationInvalidOptionalFieldValueLog::class, + 'code' => 'SWAG_MIGRATION_VALIDATION_INVALID_OPTIONAL_FIELD_VALUE', + 'level' => AbstractMigrationLogEntry::LOG_LEVEL_WARNING, + 'userFixable' => true, + ]; + yield MigrationValidationInvalidRequiredFieldValueLog::class => [ 'logClass' => MigrationValidationInvalidRequiredFieldValueLog::class, - 'code' => 'SWAG_MIGRATION_VALIDATION_INVALID_FIELD_VALUE', + 'code' => 'SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_FIELD_VALUE', 'level' => AbstractMigrationLogEntry::LOG_LEVEL_ERROR, 'userFixable' => true, ]; @@ -116,6 +133,13 @@ public static function logProvider(): \Generator 'logClass' => MigrationValidationMissingRequiredFieldLog::class, 'code' => 'SWAG_MIGRATION_VALIDATION_MISSING_REQUIRED_FIELD', 'level' => AbstractMigrationLogEntry::LOG_LEVEL_ERROR, + 'userFixable' => true, + ]; + + yield MigrationValidationInvalidRequiredTranslation::class => [ + 'logClass' => MigrationValidationInvalidRequiredTranslation::class, + 'code' => 'SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_TRANSLATION', + 'level' => AbstractMigrationLogEntry::LOG_LEVEL_ERROR, 'userFixable' => false, ]; From 9036ca7680adcc4123663efb08137425692ed3fd Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Tue, 23 Dec 2025 14:41:54 +0100 Subject: [PATCH 11/26] feat: add validation of resolution values --- src/Controller/ErrorResolutionController.php | 138 ++++++++++++ src/DependencyInjection/migration.xml | 14 ++ src/Exception/MigrationException.php | 24 +++ .../MigrationFieldExampleGenerator.php | 196 ++++++++++++++++++ .../MigrationFieldValidationService.php | 79 +++++++ .../service/api/swag-migration.api.service.ts | 56 +++++ .../index.ts | 30 ++- ...on-error-resolution-field-scalar.html.twig | 6 + .../index.ts | 49 ++--- ...error-resolution-field-unhandled.html.twig | 3 - .../index.ts | 53 +++++ ...migration-error-resolution-field.html.twig | 5 + .../index.ts | 69 ++++-- ...migration-error-resolution-modal.html.twig | 1 + .../src/module/swag-migration/snippet/de.json | 2 + .../src/module/swag-migration/snippet/en.json | 4 +- 16 files changed, 683 insertions(+), 46 deletions(-) create mode 100644 src/Controller/ErrorResolutionController.php create mode 100644 src/Migration/ErrorResolution/MigrationFieldExampleGenerator.php create mode 100644 src/Migration/Validation/MigrationFieldValidationService.php diff --git a/src/Controller/ErrorResolutionController.php b/src/Controller/ErrorResolutionController.php new file mode 100644 index 000000000..b8884b0be --- /dev/null +++ b/src/Controller/ErrorResolutionController.php @@ -0,0 +1,138 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SwagMigrationAssistant\Controller; + +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Validation\WriteConstraintViolationException; +use SwagMigrationAssistant\Exception\MigrationException; +use SwagMigrationAssistant\Migration\ErrorResolution\MigrationFieldExampleGenerator; +use SwagMigrationAssistant\Migration\Validation\MigrationFieldValidationService; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Attribute\Route; + +#[Route(defaults: ['_routeScope' => ['api']])] +#[Package('fundamentals@after-sales')] +class ErrorResolutionController extends AbstractController +{ + /** + * @internal + */ + public function __construct( + private readonly DefinitionInstanceRegistry $definitionRegistry, + private readonly MigrationFieldValidationService $fieldValidationService, + ) { + } + + #[Route( + path: '/api/_action/migration/error-resolution/validate', + name: 'api.admin.migration.error-resolution.validate', + defaults: ['_acl' => ['swag_migration.viewer']], + methods: [Request::METHOD_POST] + )] + public function validateResolution(Request $request, Context $context): JsonResponse + { + $entityName = (string) $request->request->get('entityName'); + $fieldName = (string) $request->request->get('fieldName'); + + if ($entityName === '') { + throw MigrationException::missingRequestParameter('entityName'); + } + + if ($fieldName === '') { + throw MigrationException::missingRequestParameter('fieldName'); + } + + $fieldValue = $this->decodeFieldValue($request->request->all()['fieldValue'] ?? null); + + if ($fieldValue === null) { + throw MigrationException::missingRequestParameter('fieldValue'); + } + + try { + $this->fieldValidationService->validateFieldValue( + $entityName, + $fieldName, + $fieldValue, + $context, + ); + } catch (WriteConstraintViolationException $e) { + return new JsonResponse([ + 'valid' => false, + 'violations' => $e->toArray(), + ]); + } catch (\Exception $e) { + return new JsonResponse([ + 'valid' => false, + 'violations' => [['message' => $e->getMessage()]], + ]); + } + + return new JsonResponse([ + 'valid' => true, + 'violations' => [], + ]); + } + + #[Route( + path: '/api/_action/migration/error-resolution/example-field-structure', + name: 'api.admin.migration.error-resolution.example-field-structure', + defaults: ['_acl' => ['swag_migration.viewer']], + methods: [Request::METHOD_POST] + )] + public function getExampleFieldStructure(Request $request): JsonResponse + { + $entityName = (string) $request->request->get('entityName'); + $fieldName = (string) $request->request->get('fieldName'); + + if ($entityName === '') { + throw MigrationException::missingRequestParameter('entityName'); + } + + if ($fieldName === '') { + throw MigrationException::missingRequestParameter('fieldName'); + } + + $entityDefinition = $this->definitionRegistry->getByEntityName($entityName); + $fields = $entityDefinition->getFields(); + + if (!$fields->has($fieldName)) { + throw MigrationException::entityFieldNotFound($entityName, $fieldName); + } + + $field = $fields->get($fieldName); + + $response = [ + 'fieldType' => MigrationFieldExampleGenerator::getFieldType($field), + 'example' => MigrationFieldExampleGenerator::generateExample($field), + ]; + + return new JsonResponse($response); + } + + /** + * @return array|bool|float|int|string|null + */ + private function decodeFieldValue(mixed $value): array|bool|float|int|string|null + { + if ($value === null || $value === '' || $value === []) { + return null; + } + + if (!\is_string($value)) { + return $value; + } + + $decoded = \json_decode($value, true); + + return \json_last_error() === \JSON_ERROR_NONE ? $decoded : $value; + } +} diff --git a/src/DependencyInjection/migration.xml b/src/DependencyInjection/migration.xml index c375ecf6a..2b18d7ff1 100644 --- a/src/DependencyInjection/migration.xml +++ b/src/DependencyInjection/migration.xml @@ -295,6 +295,15 @@ + + + + + + + + + @@ -425,9 +434,14 @@ + + + + + diff --git a/src/Exception/MigrationException.php b/src/Exception/MigrationException.php index 4b5cffb2f..3653fcf56 100644 --- a/src/Exception/MigrationException.php +++ b/src/Exception/MigrationException.php @@ -93,6 +93,10 @@ class MigrationException extends HttpException final public const DUPLICATE_SOURCE_CONNECTION = 'SWAG_MIGRATION__DUPLICATE_SOURCE_CONNECTION'; + final public const MISSING_REQUEST_PARAMETER = 'SWAG_MIGRATION__MISSING_REQUEST_PARAMETER'; + + final public const ENTITY_FIELD_NOT_FOUND = 'SWAG_MIGRATION__ENTITY_FIELD_NOT_FOUND'; + public static function associationEntityRequiredMissing(string $entity, string $missingEntity): self { return new self( @@ -476,4 +480,24 @@ public static function duplicateSourceConnection(): self 'A connection to this source system already exists.', ); } + + public static function missingRequestParameter(string $parameterName): self + { + return new self( + Response::HTTP_BAD_REQUEST, + self::MISSING_REQUEST_PARAMETER, + 'Required request parameter "{{ parameterName }}" is missing.', + ['parameterName' => $parameterName] + ); + } + + public static function entityFieldNotFound(string $entityName, string $fieldName): self + { + return new self( + Response::HTTP_NOT_FOUND, + self::ENTITY_FIELD_NOT_FOUND, + 'Field "{{ fieldName }}" not found in entity "{{ entityName }}".', + ['fieldName' => $fieldName, 'entityName' => $entityName] + ); + } } diff --git a/src/Migration/ErrorResolution/MigrationFieldExampleGenerator.php b/src/Migration/ErrorResolution/MigrationFieldExampleGenerator.php new file mode 100644 index 000000000..75a71de8f --- /dev/null +++ b/src/Migration/ErrorResolution/MigrationFieldExampleGenerator.php @@ -0,0 +1,196 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SwagMigrationAssistant\Migration\ErrorResolution; + +use Shopware\Core\Framework\DataAbstractionLayer\Field\BoolField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\CalculatedPriceField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\CartPriceField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\CustomFields; +use Shopware\Core\Framework\DataAbstractionLayer\Field\DateField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\DateTimeField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\Field; +use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\FloatField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\IdField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\IntField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\JsonField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\ListField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\ObjectField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\PriceDefinitionField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\PriceField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\StringField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\VariantListingConfigField; +use Shopware\Core\Framework\Log\Package; + +/** + * @internal + */ +#[Package('fundamentals@after-sales')] +readonly class MigrationFieldExampleGenerator +{ + public static function generateExample(Field $field): ?string + { + $example = self::buildExample($field); + + if ($example === null) { + return null; + } + + $encoded = \json_encode($example, \JSON_PRETTY_PRINT); + + if ($encoded === false) { + return null; + } + + return $encoded; + } + + public static function getFieldType(Field $field): string + { + return (new \ReflectionClass($field))->getShortName(); + } + + private static function buildExample(Field $field): mixed + { + $specialExample = self::getSpecialFieldExample($field); + + if ($specialExample !== null) { + return $specialExample; + } + + if ($field instanceof CustomFields || $field instanceof ObjectField) { + return null; + } + + if ($field instanceof JsonField && !empty($field->getPropertyMapping())) { + return self::buildFromPropertyMapping($field->getPropertyMapping()); + } + + if ($field instanceof ListField) { + $fieldType = $field->getFieldType(); + + if ($fieldType === null) { + return []; + } + + /** @var Field $elementField */ + $elementField = new $fieldType('example', 'example'); + $elementExample = self::buildExample($elementField); + + return $elementExample !== null ? [$elementExample] : []; + } + + if ($field instanceof JsonField) { + return []; + } + + return self::getScalarDefault($field); + } + + /** + * @param list $fields + * + * @return array + */ + private static function buildFromPropertyMapping(array $fields): array + { + $result = []; + + foreach ($fields as $nestedField) { + $result[$nestedField->getPropertyName()] = self::buildExample($nestedField); + } + + return $result; + } + + private static function getScalarDefault(Field $field): mixed + { + return match (true) { + $field instanceof IntField => 0, + $field instanceof FloatField => 0.0, + $field instanceof BoolField => false, + $field instanceof StringField => '', + $field instanceof IdField, $field instanceof FkField => '[uuid]', + $field instanceof DateTimeField, $field instanceof DateField => '[date]', + default => null, + }; + } + + /** + * @return array|list>|null + */ + private static function getSpecialFieldExample(Field $field): ?array + { + return match (true) { + $field instanceof PriceField => [ + [ + 'currencyId' => '[uuid]', + 'gross' => 0.0, + 'net' => 0.0, + 'linked' => false, + ], + ], + $field instanceof VariantListingConfigField => [ + 'displayParent' => false, + 'mainVariantId' => '[uuid]', + 'configuratorGroupConfig' => [], + ], + $field instanceof PriceDefinitionField => [ + 'type' => 'quantity', + 'price' => 0.0, + 'quantity' => 1, + 'isCalculated' => false, + 'taxRules' => [ + [ + 'taxRate' => 0.0, + 'percentage' => 0.0, + ], + ], + ], + $field instanceof CartPriceField => [ + 'netPrice' => 0.0, + 'totalPrice' => 0.0, + 'positionPrice' => 0.0, + 'rawTotal' => 0.0, + 'taxStatus' => 'gross', + 'calculatedTaxes' => [ + [ + 'tax' => 0.0, + 'taxRate' => 0.0, + 'price' => 0.0, + ], + ], + 'taxRules' => [ + [ + 'taxRate' => 0.0, + 'percentage' => 0.0, + ], + ], + ], + $field instanceof CalculatedPriceField => [ + 'unitPrice' => 0.0, + 'totalPrice' => 0.0, + 'quantity' => 1, + 'calculatedTaxes' => [ + [ + 'tax' => 0.0, + 'taxRate' => 0.0, + 'price' => 0.0, + ], + ], + 'taxRules' => [ + [ + 'taxRate' => 0.0, + 'percentage' => 0.0, + ], + ], + ], + default => null, + }; + } +} diff --git a/src/Migration/Validation/MigrationFieldValidationService.php b/src/Migration/Validation/MigrationFieldValidationService.php new file mode 100644 index 000000000..df3079dac --- /dev/null +++ b/src/Migration/Validation/MigrationFieldValidationService.php @@ -0,0 +1,79 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SwagMigrationAssistant\Migration\Validation; + +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry; +use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required; +use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\WriteCommandQueue; +use Shopware\Core\Framework\DataAbstractionLayer\Write\DataStack\KeyValuePair; +use Shopware\Core\Framework\DataAbstractionLayer\Write\EntityExistence; +use Shopware\Core\Framework\DataAbstractionLayer\Write\WriteContext; +use Shopware\Core\Framework\DataAbstractionLayer\Write\WriteParameterBag; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Uuid\Uuid; +use Shopware\Core\Framework\Validation\WriteConstraintViolationException; +use SwagMigrationAssistant\Exception\MigrationException; + +/** + * @internal + */ +#[Package('fundamentals@after-sales')] +readonly class MigrationFieldValidationService +{ + public function __construct( + private DefinitionInstanceRegistry $definitionRegistry, + ) { + } + + /** + * Validates a field value using the DAL field serializer. + * + * @throws WriteConstraintViolationException|MigrationException|\Exception if the value is not valid + */ + public function validateFieldValue( + string $entityName, + string $fieldName, + mixed $value, + Context $context, + ?string $entityId = null, + ): void { + $entityDefinition = $this->definitionRegistry->getByEntityName($entityName); + $fields = $entityDefinition->getFields(); + + if (!$fields->has($fieldName)) { + throw MigrationException::entityFieldNotFound($entityName, $fieldName); + } + + $field = $fields->get($fieldName); + + $entityExistence = EntityExistence::createForEntity( + $entityDefinition->getEntityName(), + ['id' => $entityId ?? Uuid::randomHex()], + ); + + $parameters = new WriteParameterBag( + $entityDefinition, + WriteContext::createFromContext($context), + '', + new WriteCommandQueue(), + ); + + $field = clone $field; + $field->setFlags(new Required()); + + $keyValue = new KeyValuePair( + $field->getPropertyName(), + $value, + true, + ); + + $serializer = $field->getSerializer(); + \iterator_to_array($serializer->encode($field, $entityExistence, $keyValue, $parameters), false); + } +} diff --git a/src/Resources/app/administration/src/core/service/api/swag-migration.api.service.ts b/src/Resources/app/administration/src/core/service/api/swag-migration.api.service.ts index 749c5c24e..b2a8aff00 100644 --- a/src/Resources/app/administration/src/core/service/api/swag-migration.api.service.ts +++ b/src/Resources/app/administration/src/core/service/api/swag-migration.api.service.ts @@ -602,4 +602,60 @@ export default class MigrationApiService extends ApiService { return ApiService.handleResponse(response); }); } + + async validateResolution( + entityName: string, + fieldName: string, + fieldValue: unknown, + additionalHeaders: AdditionalHeaders = {}, + ): Promise<{ isValid: boolean; violations: Array<{ message: string; propertyPath?: string }> }> { + // @ts-ignore + const headers = this.getBasicHeaders(additionalHeaders); + + // @ts-ignore + return this.httpClient + .post( + // @ts-ignore + `_action/${this.getApiBasePath()}/error-resolution/validate`, + { + entityName, + fieldName, + fieldValue, + }, + { + ...this.basicConfig, + headers, + }, + ) + .then((response: AxiosResponse) => { + return ApiService.handleResponse(response); + }); + } + + async getExampleFieldStructure( + entityName: string, + fieldName: string, + additionalHeaders: AdditionalHeaders = {}, + ): Promise<{ fieldType: string; example: string | null }> { + // @ts-ignore + const headers = this.getBasicHeaders(additionalHeaders); + + // @ts-ignore + return this.httpClient + .post( + // @ts-ignore + `_action/${this.getApiBasePath()}/error-resolution/example-field-structure`, + { + entityName, + fieldName, + }, + { + ...this.basicConfig, + headers, + }, + ) + .then((response: AxiosResponse) => { + return ApiService.handleResponse(response); + }); + } } diff --git a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar/index.ts b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar/index.ts index 6552dd74b..7171f9d8a 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar/index.ts +++ b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar/index.ts @@ -16,7 +16,9 @@ export interface SwagMigrationErrorResolutionFieldScalarData { export default Shopware.Component.wrapComponentConfig({ template, - inject: ['updateFieldValue'], + inject: [ + 'updateFieldValue', + ], props: { componentType: { @@ -30,15 +32,29 @@ export default Shopware.Component.wrapComponentConfig({ type: Object as PropType, required: true, }, + entityName: { + type: String, + required: true, + }, fieldName: { type: String, required: true, }, + error: { + type: Object as PropType<{ detail: string }>, + required: false, + default: null, + }, disabled: { type: Boolean, required: false, default: false, }, + exampleValue: { + type: String as PropType, + required: false, + default: null, + }, }, data(): SwagMigrationErrorResolutionFieldScalarData { @@ -50,12 +66,24 @@ export default Shopware.Component.wrapComponentConfig({ watch: { fieldValue: { handler() { + if (this.componentType === 'switch' && this.fieldValue === null) { + this.fieldValue = false; + } + if (this.updateFieldValue) { this.updateFieldValue(this.fieldValue); } }, immediate: true, }, + exampleValue: { + handler(newValue: string | null) { + if (this.componentType === 'editor' && newValue !== null && this.fieldValue === null) { + this.fieldValue = newValue; + } + }, + immediate: true, + }, }, computed: { diff --git a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar/swag-migration-error-resolution-field-scalar.html.twig b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar/swag-migration-error-resolution-field-scalar.html.twig index c0e636bbb..484d8df95 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar/swag-migration-error-resolution-field-scalar.html.twig +++ b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar/swag-migration-error-resolution-field-scalar.html.twig @@ -8,6 +8,7 @@ name="migration-resolution--number" :label="fieldName" :number-type="numberFieldType" + :error="error" :disabled="disabled" /> {% endblock %} @@ -19,6 +20,7 @@ class="sw-migration-error-resolution-field__textarea" name="migration-resolution--textarea" :label="fieldName" + :error="error" :disabled="disabled" /> {% endblock %} @@ -30,6 +32,7 @@ class="sw-migration-error-resolution-field__text" name="migration-resolution--text" :label="fieldName" + :error="error" :disabled="disabled" /> {% endblock %} @@ -41,6 +44,7 @@ class="sw-migration-error-resolution-field__switch" name="migration-resolution--switch" :label="fieldName" + :error="error" :disabled="disabled" /> {% endblock %} @@ -53,6 +57,7 @@ name="migration-resolution--datepicker" date-type="datetime" :label="fieldName" + :error="error" :disabled="disabled" /> {% endblock %} @@ -63,6 +68,7 @@ v-model:value="fieldValue" class="sw-migration-error-resolution-field__editor" name="migration-resolution--code-editor" + :error="error" :label="fieldName" /> {% endblock %} diff --git a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-unhandled/index.ts b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-unhandled/index.ts index ba8ae9cac..de14f2c04 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-unhandled/index.ts +++ b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-unhandled/index.ts @@ -4,8 +4,7 @@ import template from './swag-migration-error-resolution-field-unhandled.html.twi * @private */ export interface SwagMigrationErrorResolutionFieldUnhandledData { - fieldValue: string; - error: { detail: string } | null; + fieldValue: string | null; } /** @@ -27,12 +26,21 @@ export default Shopware.Component.wrapComponentConfig({ required: false, default: false, }, + error: { + type: Object as PropType<{ detail: string }>, + required: false, + default: null, + }, + exampleValue: { + type: String as PropType, + required: false, + default: null, + }, }, data(): SwagMigrationErrorResolutionFieldUnhandledData { return { - fieldValue: '', - error: null, + fieldValue: null, }; }, @@ -40,35 +48,18 @@ export default Shopware.Component.wrapComponentConfig({ fieldValue: { handler() { if (this.updateFieldValue) { - const parsedValue = this.parseJsonFieldValue(); - - this.updateFieldValue(parsedValue); + this.updateFieldValue(this.fieldValue); } }, immediate: true, }, - }, - - methods: { - parseJsonFieldValue(): string | number | boolean | null | object | unknown[] { - if (!this.fieldValue || typeof this.fieldValue !== 'string') { - this.error = null; - - return this.fieldValue; - } - - try { - const value = JSON.parse(this.fieldValue); - this.error = null; - - return value; - } catch { - this.error = { - detail: this.$tc('swag-migration.index.error-resolution.errors.invalidJsonInput'), - }; - - return null; - } + exampleValue: { + handler(newValue: string | null) { + if (newValue !== null && this.fieldValue === null) { + this.fieldValue = newValue; + } + }, + immediate: true, }, }, }); diff --git a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-unhandled/swag-migration-error-resolution-field-unhandled.html.twig b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-unhandled/swag-migration-error-resolution-field-unhandled.html.twig index dd295a502..11415e0f1 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-unhandled/swag-migration-error-resolution-field-unhandled.html.twig +++ b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-unhandled/swag-migration-error-resolution-field-unhandled.html.twig @@ -11,9 +11,6 @@ diff --git a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field/index.ts b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field/index.ts index bd870a33e..1a9e775e8 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field/index.ts +++ b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field/index.ts @@ -3,6 +3,14 @@ import template from './swag-migration-error-resolution-field.html.twig'; import './swag-migration-error-resolution-field.scss'; import type { ErrorResolutionTableData } from '../../swag-migration-error-resolution-step'; import { MIGRATION_ERROR_RESOLUTION_SERVICE } from '../../../../service/swag-migration-error-resolution.service'; +import { MIGRATION_API_SERVICE } from '../../../../../../core/service/api/swag-migration.api.service'; + +/** + * @private + */ +export interface SwagMigrationErrorResolutionFieldData { + exampleValue: string | null; +} /** * @private @@ -13,6 +21,11 @@ export default Shopware.Component.wrapComponentConfig({ inject: [ MIGRATION_ERROR_RESOLUTION_SERVICE, + MIGRATION_API_SERVICE, + ], + + mixins: [ + Shopware.Mixin.getByName('notification'), ], props: { @@ -20,6 +33,11 @@ export default Shopware.Component.wrapComponentConfig({ type: Object as PropType, required: true, }, + error: { + type: Object as PropType<{ detail: string }>, + required: false, + default: null, + }, disabled: { type: Boolean, required: false, @@ -27,6 +45,16 @@ export default Shopware.Component.wrapComponentConfig({ }, }, + data(): SwagMigrationErrorResolutionFieldData { + return { + exampleValue: null, + }; + }, + + async created() { + await this.fetchExampleValue(); + }, + computed: { isUnhandledField(): boolean { return this.swagMigrationErrorResolutionService.isUnhandledField(this.log.entityName, this.log.fieldName); @@ -47,5 +75,30 @@ export default Shopware.Component.wrapComponentConfig({ fieldType(): string | null { return this.swagMigrationErrorResolutionService.getFieldType(this.log.entityName, this.log.fieldName); }, + + shouldFetchExample(): boolean { + return this.isUnhandledField || this.fieldType === 'editor'; + }, + }, + + methods: { + async fetchExampleValue(): Promise { + if (!this.shouldFetchExample) { + return; + } + + try { + const response = await this.migrationApiService.getExampleFieldStructure( + this.log.entityName, + this.log.fieldName, + ); + + this.exampleValue = response?.example ?? null; + } catch { + this.createNotificationError({ + message: this.$tc('swag-migration.index.error-resolution.errors.fetchExampleFailed'), + }); + } + }, }, }); diff --git a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field/swag-migration-error-resolution-field.html.twig b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field/swag-migration-error-resolution-field.html.twig index 3543caccb..f09bd3f7a 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field/swag-migration-error-resolution-field.html.twig +++ b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field/swag-migration-error-resolution-field.html.twig @@ -5,6 +5,8 @@ v-if="isUnhandledField" :field-name="log?.fieldName" :disabled="disabled" + :error="error" + :example-value="exampleValue" /> {% endblock %} @@ -12,9 +14,12 @@ {% endblock %} diff --git a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/index.ts b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/index.ts index 1a703364a..7ff11aff0 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/index.ts +++ b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/index.ts @@ -46,6 +46,7 @@ export interface SwagMigrationErrorResolutionModalData { loading: boolean; submitLoading: boolean; fieldValue: string[] | string | boolean | number | null; + fieldError: { detail: string } | null; migrationStore: MigrationStore; } @@ -89,6 +90,7 @@ export default Shopware.Component.wrapComponentConfig({ loading: false, submitLoading: false, fieldValue: null, + fieldError: null, migrationStore: Shopware.Store.get(MIGRATION_STORE_ID), }; }, @@ -179,22 +181,14 @@ export default Shopware.Component.wrapComponentConfig({ }, async onSubmitResolution() { - const validationError = this.swagMigrationErrorResolutionService.validateFieldValue( - this.selectedLog.entityName, - this.selectedLog.fieldName, - this.fieldValue, - ); - - if (validationError) { - this.createNotificationError({ - message: this.$tc(`swag-migration.index.error-resolution.errors.${validationError}`), - }); + this.submitLoading = true; + this.fieldError = null; + if (!(await this.validateResolution())) { + this.submitLoading = false; return; } - this.submitLoading = true; - try { const entityIds = await this.collectEntityIdsForSubmission(); @@ -223,6 +217,57 @@ export default Shopware.Component.wrapComponentConfig({ } }, + async validateResolution(): Promise { + const validationError = this.swagMigrationErrorResolutionService.validateFieldValue( + this.selectedLog.entityName, + this.selectedLog.fieldName, + this.fieldValue, + ); + + if (validationError) { + this.createNotificationError({ + message: this.$tc(`swag-migration.index.error-resolution.errors.${validationError}`), + }); + + return false; + } + + // skip backend validation for to many associations as they use id arrays + // which are not compatible with the dal serializer format + if ( + this.swagMigrationErrorResolutionService.isToManyAssociationField( + this.selectedLog.entityName, + this.selectedLog.fieldName, + ) + ) { + return true; + } + + const serializationError = await this.migrationApiService + .validateResolution(this.selectedLog.entityName, this.selectedLog.fieldName, this.fieldValue) + .catch(() => { + this.createNotificationError({ + message: this.$tc('swag-migration.index.error-resolution.errors.validationFailed'), + }); + + return false; + }); + + if (serializationError?.valid === true) { + return true; + } + + if (!serializationError?.violations?.length) { + return false; + } + + this.fieldError = { + detail: serializationError.violations.at(0)?.message, + }; + + return false; + }, + async collectEntityIdsForSubmission(): Promise { const entityIdsFromTableData = this.extractEntityIdsFromTableData(); const missingLogIds = this.getMissingLogIds(); diff --git a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/swag-migration-error-resolution-modal.html.twig b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/swag-migration-error-resolution-modal.html.twig index 7174880c9..452f0d99e 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/swag-migration-error-resolution-modal.html.twig +++ b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/swag-migration-error-resolution-modal.html.twig @@ -99,6 +99,7 @@
diff --git a/src/Resources/app/administration/src/module/swag-migration/snippet/de.json b/src/Resources/app/administration/src/module/swag-migration/snippet/de.json index 194bb245d..a8bcbec17 100644 --- a/src/Resources/app/administration/src/module/swag-migration/snippet/de.json +++ b/src/Resources/app/administration/src/module/swag-migration/snippet/de.json @@ -650,6 +650,8 @@ "noEntityIdsFound": "Keine gültigen Entitäts-IDs in den ausgewählten Log-Einträgen gefunden.", "fetchFilterDataFailed": "Das Abrufen der Filterdaten ist fehlgeschlagen.", "fetchExistingFixesFailed": "Das Abrufen vorhandener Korrekturen ist fehlgeschlagen.", + "fetchExampleFailed": "Beispieldaten konnten nicht abgerufen werden.", + "validationFailed": "Validierung fehlgeschlagen.", "invalidJsonInput": "Diese Eingabe ist kein gültiges JSON.", "resetResolutionFailed": "Fehler beim Zurücksetzen der Fehlerbehebung." }, diff --git a/src/Resources/app/administration/src/module/swag-migration/snippet/en.json b/src/Resources/app/administration/src/module/swag-migration/snippet/en.json index d3259cf71..c48cb85fd 100644 --- a/src/Resources/app/administration/src/module/swag-migration/snippet/en.json +++ b/src/Resources/app/administration/src/module/swag-migration/snippet/en.json @@ -502,7 +502,9 @@ "fetchFilterDataFailed": "Failed fetching filter data.", "fetchExistingFixesFailed": "Failed fetching existing fixes.", "invalidJsonInput": "This input is not valid JSON.", - "resetResolutionFailed": "Failed resetting error resolution." + "resetResolutionFailed": "Failed resetting error resolution.", + "fetchExampleFailed": "Failed fetching example data.", + "validationFailed": "Validation failed." }, "step": { "continue-modal": { From 9cb5a7a724a62fd517ef76531a7b9cdbb840290f Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Tue, 23 Dec 2025 16:23:37 +0100 Subject: [PATCH 12/26] test: fix --- .../MigrationFieldExampleGenerator.php | 71 ++++---- .../MigrationFieldValidationServiceTest.php | 136 +++++++++++++++ .../MigrationFieldExampleGeneratorTest.php | 162 ++++++++++++++++++ 3 files changed, 340 insertions(+), 29 deletions(-) create mode 100644 tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php create mode 100644 tests/unit/Migration/ErrorResolution/MigrationFieldExampleGeneratorTest.php diff --git a/src/Migration/ErrorResolution/MigrationFieldExampleGenerator.php b/src/Migration/ErrorResolution/MigrationFieldExampleGenerator.php index 75a71de8f..9ab9f0bc3 100644 --- a/src/Migration/ErrorResolution/MigrationFieldExampleGenerator.php +++ b/src/Migration/ErrorResolution/MigrationFieldExampleGenerator.php @@ -10,6 +10,7 @@ use Shopware\Core\Framework\DataAbstractionLayer\Field\BoolField; use Shopware\Core\Framework\DataAbstractionLayer\Field\CalculatedPriceField; use Shopware\Core\Framework\DataAbstractionLayer\Field\CartPriceField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\CashRoundingConfigField; use Shopware\Core\Framework\DataAbstractionLayer\Field\CustomFields; use Shopware\Core\Framework\DataAbstractionLayer\Field\DateField; use Shopware\Core\Framework\DataAbstractionLayer\Field\DateTimeField; @@ -24,6 +25,7 @@ use Shopware\Core\Framework\DataAbstractionLayer\Field\PriceDefinitionField; use Shopware\Core\Framework\DataAbstractionLayer\Field\PriceField; use Shopware\Core\Framework\DataAbstractionLayer\Field\StringField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\TaxFreeConfigField; use Shopware\Core\Framework\DataAbstractionLayer\Field\VariantListingConfigField; use Shopware\Core\Framework\Log\Package; @@ -67,10 +69,6 @@ private static function buildExample(Field $field): mixed return null; } - if ($field instanceof JsonField && !empty($field->getPropertyMapping())) { - return self::buildFromPropertyMapping($field->getPropertyMapping()); - } - if ($field instanceof ListField) { $fieldType = $field->getFieldType(); @@ -86,7 +84,11 @@ private static function buildExample(Field $field): mixed } if ($field instanceof JsonField) { - return []; + if (empty($field->getPropertyMapping())) { + return []; + } + + return self::buildFromPropertyMapping($field->getPropertyMapping()); } return self::getScalarDefault($field); @@ -112,11 +114,12 @@ private static function getScalarDefault(Field $field): mixed { return match (true) { $field instanceof IntField => 0, - $field instanceof FloatField => 0.0, + $field instanceof FloatField => 0.1, $field instanceof BoolField => false, - $field instanceof StringField => '', + $field instanceof StringField => '[string]', $field instanceof IdField, $field instanceof FkField => '[uuid]', - $field instanceof DateTimeField, $field instanceof DateField => '[date]', + $field instanceof DateField => '[date]', + $field instanceof DateTimeField => '[datetime]', default => null, }; } @@ -130,8 +133,8 @@ private static function getSpecialFieldExample(Field $field): ?array $field instanceof PriceField => [ [ 'currencyId' => '[uuid]', - 'gross' => 0.0, - 'net' => 0.0, + 'gross' => 0.1, + 'net' => 0.1, 'linked' => false, ], ], @@ -142,54 +145,64 @@ private static function getSpecialFieldExample(Field $field): ?array ], $field instanceof PriceDefinitionField => [ 'type' => 'quantity', - 'price' => 0.0, + 'price' => 0.1, 'quantity' => 1, 'isCalculated' => false, 'taxRules' => [ [ - 'taxRate' => 0.0, - 'percentage' => 0.0, + 'taxRate' => 0.1, + 'percentage' => 0.1, ], ], ], $field instanceof CartPriceField => [ - 'netPrice' => 0.0, - 'totalPrice' => 0.0, - 'positionPrice' => 0.0, - 'rawTotal' => 0.0, + 'netPrice' => 0.1, + 'totalPrice' => 0.1, + 'positionPrice' => 0.1, + 'rawTotal' => 0.1, 'taxStatus' => 'gross', 'calculatedTaxes' => [ [ - 'tax' => 0.0, - 'taxRate' => 0.0, - 'price' => 0.0, + 'tax' => 0.1, + 'taxRate' => 0.1, + 'price' => 0.1, ], ], 'taxRules' => [ [ - 'taxRate' => 0.0, - 'percentage' => 0.0, + 'taxRate' => 0.1, + 'percentage' => 0.1, ], ], ], $field instanceof CalculatedPriceField => [ - 'unitPrice' => 0.0, - 'totalPrice' => 0.0, + 'unitPrice' => 0.1, + 'totalPrice' => 0.1, 'quantity' => 1, 'calculatedTaxes' => [ [ - 'tax' => 0.0, - 'taxRate' => 0.0, - 'price' => 0.0, + 'tax' => 0.1, + 'taxRate' => 0.1, + 'price' => 0.1, ], ], 'taxRules' => [ [ - 'taxRate' => 0.0, - 'percentage' => 0.0, + 'taxRate' => 0.1, + 'percentage' => 0.1, ], ], ], + $field instanceof CashRoundingConfigField => [ + 'decimals' => 2, + 'interval' => 0.01, + 'roundForNet' => true, + ], + $field instanceof TaxFreeConfigField => [ + 'enabled' => false, + 'currencyId' => '[uuid]', + 'amount' => 0.1, + ], default => null, }; } diff --git a/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php b/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php new file mode 100644 index 000000000..fd582ce4a --- /dev/null +++ b/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php @@ -0,0 +1,136 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SwagMigrationAssistant\Test\integration\Migration\Validation; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Defaults; +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\DataAbstractionLayer\DataAbstractionLayerException; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; +use Shopware\Core\Framework\Validation\WriteConstraintViolationException; +use SwagMigrationAssistant\Exception\MigrationException; +use SwagMigrationAssistant\Migration\Validation\MigrationFieldValidationService; + +/** + * @internal + */ +#[Package('fundamentals@after-sales')] +#[CoversClass(MigrationFieldValidationService::class)] +class MigrationFieldValidationServiceTest extends TestCase +{ + use IntegrationTestBehaviour; + + private MigrationFieldValidationService $migrationFieldValidationService; + + protected function setUp(): void + { + $this->migrationFieldValidationService = static::getContainer()->get(MigrationFieldValidationService::class); + } + + public function testNotExistingEntityDefinition(): void + { + static::expectExceptionObject(DataAbstractionLayerException::definitionNotFound('test')); + + $this->migrationFieldValidationService->validateFieldValue( + 'test', + 'field', + 'value', + Context::createDefaultContext(), + ); + } + + public function testNotExistingField(): void + { + static::expectExceptionObject(MigrationException::entityFieldNotFound('product', 'nonExistingField')); + + $this->migrationFieldValidationService->validateFieldValue( + 'product', + 'nonExistingField', + 'value', + Context::createDefaultContext(), + ); + } + + public function testValidPriceField(): void + { + static::expectNotToPerformAssertions(); + + $this->migrationFieldValidationService->validateFieldValue( + 'product', + 'price', + [ + [ + 'currencyId' => Defaults::CURRENCY, + 'gross' => 100.0, + 'net' => 84.03, + 'linked' => true, + ], + ], + Context::createDefaultContext(), + ); + } + + public function testInvalidPriceFieldGrossType(): void + { + static::expectException(WriteConstraintViolationException::class); + + $this->migrationFieldValidationService->validateFieldValue( + 'product', + 'price', + [ + [ + 'currencyId' => Defaults::CURRENCY, + 'gross' => 'invalid', // should be numeric + 'net' => 84.03, + 'linked' => true, + ], + ], + Context::createDefaultContext(), + ); + } + + public function testInvalidPriceFieldMissingNet(): void + { + static::expectException(WriteConstraintViolationException::class); + + $this->migrationFieldValidationService->validateFieldValue( + 'product', + 'price', + [ + [ + 'currencyId' => Defaults::CURRENCY, + 'gross' => 100.0, + // 'net' is missing + 'linked' => true, + ], + ], + Context::createDefaultContext(), + ); + } + + public function testInvalidPriceFieldCurrencyId(): void + { + static::expectException(WriteConstraintViolationException::class); + + $this->migrationFieldValidationService->validateFieldValue( + 'product', + 'price', + [ + [ + 'currencyId' => 'not-a-valid-uuid', + 'gross' => 100.0, + 'net' => 84.03, + 'linked' => true, + ], + ], + Context::createDefaultContext(), + ); + } +} diff --git a/tests/unit/Migration/ErrorResolution/MigrationFieldExampleGeneratorTest.php b/tests/unit/Migration/ErrorResolution/MigrationFieldExampleGeneratorTest.php new file mode 100644 index 000000000..92c8b8943 --- /dev/null +++ b/tests/unit/Migration/ErrorResolution/MigrationFieldExampleGeneratorTest.php @@ -0,0 +1,162 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SwagMigrationAssistant\Test\unit\Migration\ErrorResolution; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Framework\DataAbstractionLayer\Field\CalculatedPriceField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\CartPriceField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\CashRoundingConfigField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\CustomFields; +use Shopware\Core\Framework\DataAbstractionLayer\Field\DateField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\DateTimeField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\Field; +use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\FloatField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\IdField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\IntField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\JsonField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\ListField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\ObjectField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\PriceDefinitionField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\PriceField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\StringField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\TaxFreeConfigField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\VariantListingConfigField; +use Shopware\Core\Framework\Log\Package; +use SwagMigrationAssistant\Migration\ErrorResolution\MigrationFieldExampleGenerator; + +/** + * @internal + */ +#[Package('fundamentals@after-sales')] +#[CoversClass(MigrationFieldExampleGenerator::class)] +class MigrationFieldExampleGeneratorTest extends TestCase +{ + public function testGetFieldType(): void + { + static::assertSame(MigrationFieldExampleGenerator::getFieldType(new StringField('test', 'test')), 'StringField'); + static::assertSame(MigrationFieldExampleGenerator::getFieldType(new IntField('test', 'test')), 'IntField'); + } + + #[DataProvider('exampleFieldProvider')] + public function testGenerateExample(Field $field, ?string $expected): void + { + $example = MigrationFieldExampleGenerator::generateExample($field); + static::assertSame($expected, $example); + } + + public static function exampleFieldProvider(): \Generator + { + yield 'IntField' => [ + 'field' => new IntField('test', 'test'), + 'expected' => '0', + ]; + + yield 'FloatField' => [ + 'field' => new FloatField('test', 'test'), + 'expected' => '0.1', + ]; + + yield 'StringField' => [ + 'field' => new StringField('test', 'test'), + 'expected' => '"[string]"', + ]; + + yield 'IdField' => [ + 'field' => new IdField('test', 'test'), + 'expected' => '"[uuid]"', + ]; + + yield 'FkField' => [ + 'field' => new FkField('test', 'test', 'test'), + 'expected' => '"[uuid]"', + ]; + + yield 'DateField' => [ + 'field' => new DateField('test', 'test'), + 'expected' => '"[date]"', + ]; + + yield 'DateTimeField' => [ + 'field' => new DateTimeField('test', 'test'), + 'expected' => '"[datetime]"', + ]; + + yield 'CustomFields' => [ + 'field' => new CustomFields('test', 'test'), + 'expected' => null, + ]; + + yield 'ObjectField' => [ + 'field' => new ObjectField('test', 'test'), + 'expected' => null, + ]; + + yield 'JsonField without property mapping' => [ + 'field' => new JsonField('test', 'test'), + 'expected' => '[]', + ]; + + yield 'JsonField with property mapping' => [ + 'field' => new JsonField('test', 'test', [new StringField('innerString', 'innerString')]), + 'expected' => \json_encode(['innerString' => '[string]'], \JSON_PRETTY_PRINT), + ]; + + yield 'ListField without field type' => [ + 'field' => new ListField('test', 'test'), + 'expected' => '[]', + ]; + + yield 'ListField with field type' => [ + 'field' => new ListField('test', 'test', StringField::class), + 'expected' => \json_encode(['[string]'], \JSON_PRETTY_PRINT), + ]; + } + + #[DataProvider('specialFieldProvider')] + public function testGenerateExampleSpecialFields(Field $field): void + { + $example = MigrationFieldExampleGenerator::generateExample($field); + + // not null means the special field was handled + static::assertNotNull($example); + } + + public static function specialFieldProvider(): \Generator + { + yield 'CalculatedPriceField' => [ + 'field' => new CalculatedPriceField('test', 'test'), + ]; + + yield 'CartPriceField' => [ + 'field' => new CartPriceField('test', 'test'), + ]; + + yield 'PriceDefinitionField' => [ + 'field' => new PriceDefinitionField('test', 'test'), + ]; + + yield 'PriceField' => [ + 'field' => new PriceField('test', 'test'), + ]; + + yield 'VariantListingConfigField' => [ + 'field' => new VariantListingConfigField('test', 'test'), + ]; + + yield 'CashRoundingConfigField' => [ + 'field' => new CashRoundingConfigField('test', 'test'), + ]; + + yield 'TaxFreeConfigField' => [ + 'field' => new TaxFreeConfigField('test', 'test'), + ]; + } +} From 6e7a457619e2196547ff22309f3fe9d47eda4c14 Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Tue, 23 Dec 2025 16:33:06 +0100 Subject: [PATCH 13/26] feat: add defaults to examples --- .../ErrorResolution/MigrationFieldExampleGenerator.php | 5 +++-- .../ErrorResolution/MigrationFieldExampleGeneratorTest.php | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Migration/ErrorResolution/MigrationFieldExampleGenerator.php b/src/Migration/ErrorResolution/MigrationFieldExampleGenerator.php index 9ab9f0bc3..f95373de5 100644 --- a/src/Migration/ErrorResolution/MigrationFieldExampleGenerator.php +++ b/src/Migration/ErrorResolution/MigrationFieldExampleGenerator.php @@ -7,6 +7,7 @@ namespace SwagMigrationAssistant\Migration\ErrorResolution; +use Shopware\Core\Defaults; use Shopware\Core\Framework\DataAbstractionLayer\Field\BoolField; use Shopware\Core\Framework\DataAbstractionLayer\Field\CalculatedPriceField; use Shopware\Core\Framework\DataAbstractionLayer\Field\CartPriceField; @@ -118,8 +119,8 @@ private static function getScalarDefault(Field $field): mixed $field instanceof BoolField => false, $field instanceof StringField => '[string]', $field instanceof IdField, $field instanceof FkField => '[uuid]', - $field instanceof DateField => '[date]', - $field instanceof DateTimeField => '[datetime]', + $field instanceof DateField => \sprintf('[date (%s)]', Defaults::STORAGE_DATE_FORMAT), + $field instanceof DateTimeField => \sprintf('[datetime (%s)]', Defaults::STORAGE_DATE_TIME_FORMAT), default => null, }; } diff --git a/tests/unit/Migration/ErrorResolution/MigrationFieldExampleGeneratorTest.php b/tests/unit/Migration/ErrorResolution/MigrationFieldExampleGeneratorTest.php index 92c8b8943..855a21933 100644 --- a/tests/unit/Migration/ErrorResolution/MigrationFieldExampleGeneratorTest.php +++ b/tests/unit/Migration/ErrorResolution/MigrationFieldExampleGeneratorTest.php @@ -81,12 +81,12 @@ public static function exampleFieldProvider(): \Generator yield 'DateField' => [ 'field' => new DateField('test', 'test'), - 'expected' => '"[date]"', + 'expected' => '"[date (Y-m-d)]"', ]; yield 'DateTimeField' => [ 'field' => new DateTimeField('test', 'test'), - 'expected' => '"[datetime]"', + 'expected' => '"[datetime (Y-m-d H:i:s.v)]"', ]; yield 'CustomFields' => [ From 7dd489b37a6a16a0f3d5acbcdb493884c86f466c Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Wed, 24 Dec 2025 09:08:09 +0100 Subject: [PATCH 14/26] test: error resolution controller --- .../MigrationFieldExampleGenerator.php | 3 +- .../ErrorResolutionControllerTest.php | 326 ++++++++++++++++++ .../MigrationFieldExampleGeneratorTest.php | 6 + 3 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 tests/integration/Migration/Controller/ErrorResolutionControllerTest.php diff --git a/src/Migration/ErrorResolution/MigrationFieldExampleGenerator.php b/src/Migration/ErrorResolution/MigrationFieldExampleGenerator.php index f95373de5..891fe9fcc 100644 --- a/src/Migration/ErrorResolution/MigrationFieldExampleGenerator.php +++ b/src/Migration/ErrorResolution/MigrationFieldExampleGenerator.php @@ -27,6 +27,7 @@ use Shopware\Core\Framework\DataAbstractionLayer\Field\PriceField; use Shopware\Core\Framework\DataAbstractionLayer\Field\StringField; use Shopware\Core\Framework\DataAbstractionLayer\Field\TaxFreeConfigField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\TranslatedField; use Shopware\Core\Framework\DataAbstractionLayer\Field\VariantListingConfigField; use Shopware\Core\Framework\Log\Package; @@ -117,7 +118,7 @@ private static function getScalarDefault(Field $field): mixed $field instanceof IntField => 0, $field instanceof FloatField => 0.1, $field instanceof BoolField => false, - $field instanceof StringField => '[string]', + $field instanceof StringField, $field instanceof TranslatedField => '[string]', $field instanceof IdField, $field instanceof FkField => '[uuid]', $field instanceof DateField => \sprintf('[date (%s)]', Defaults::STORAGE_DATE_FORMAT), $field instanceof DateTimeField => \sprintf('[datetime (%s)]', Defaults::STORAGE_DATE_TIME_FORMAT), diff --git a/tests/integration/Migration/Controller/ErrorResolutionControllerTest.php b/tests/integration/Migration/Controller/ErrorResolutionControllerTest.php new file mode 100644 index 000000000..79e458e1a --- /dev/null +++ b/tests/integration/Migration/Controller/ErrorResolutionControllerTest.php @@ -0,0 +1,326 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace integration\Migration\Controller; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Defaults; +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; +use SwagMigrationAssistant\Controller\ErrorResolutionController; +use SwagMigrationAssistant\Exception\MigrationException; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +/** + * @internal + */ +#[Package('fundamentals@after-sales')] +#[CoversClass(ErrorResolutionController::class)] +class ErrorResolutionControllerTest extends TestCase +{ + use IntegrationTestBehaviour; + + private ErrorResolutionController $errorResolutionController; + + protected function setUp(): void + { + parent::setUp(); + + $this->errorResolutionController = static::getContainer()->get(ErrorResolutionController::class); + } + + public function testGetFieldStructureUnsetEntityName(): void + { + static::expectExceptionObject(MigrationException::missingRequestParameter('entityName')); + + $request = new Request([], [ + 'fieldName' => 'name', + ]); + + $this->errorResolutionController->getExampleFieldStructure($request); + } + + public function testGetFieldStructureUnsetFieldName(): void + { + static::expectExceptionObject(MigrationException::missingRequestParameter('fieldName')); + + $request = new Request([], [ + 'entityName' => 'product', + ]); + + $this->errorResolutionController->getExampleFieldStructure($request); + } + + public function testGetFieldStructureUnknownField(): void + { + static::expectExceptionObject(MigrationException::entityFieldNotFound('product', 'unknownField')); + + $request = new Request([], [ + 'entityName' => 'product', + 'fieldName' => 'unknownField', + ]); + + $this->errorResolutionController->getExampleFieldStructure($request); + } + + /** + * @param array $expected + */ + #[DataProvider('fieldStructureProvider')] + public function testGetFieldStructureProduct(string $entityName, string $fieldName, array $expected): void + { + $request = new Request([], [ + 'entityName' => $entityName, + 'fieldName' => $fieldName, + ]); + + $response = $this->errorResolutionController->getExampleFieldStructure($request); + $responseData = $this->jsonResponseToArray($response); + + static::assertArrayHasKey('fieldType', $responseData); + static::assertArrayHasKey('example', $responseData); + + static::assertSame($expected['fieldType'], $responseData['fieldType']); + static::assertSame($expected['example'], $responseData['example']); + } + + public static function fieldStructureProvider(): \Generator + { + yield 'product name field' => [ + 'entityName' => 'product', + 'fieldName' => 'name', + 'expected' => [ + 'fieldType' => 'TranslatedField', + 'example' => '"[string]"', + ], + ]; + + yield 'product availableStock field' => [ + 'entityName' => 'product', + 'fieldName' => 'availableStock', + 'expected' => [ + 'fieldType' => 'IntField', + 'example' => '0', + ], + ]; + + yield 'product price field' => [ + 'entityName' => 'product', + 'fieldName' => 'price', + 'expected' => [ + 'fieldType' => 'PriceField', + 'example' => \json_encode([ + [ + 'currencyId' => '[uuid]', + 'gross' => 0.1, + 'net' => 0.1, + 'linked' => false, + ], + ], \JSON_PRETTY_PRINT), + ], + ]; + + yield 'product variant listing config' => [ + 'entityName' => 'product', + 'fieldName' => 'variantListingConfig', + 'expected' => [ + 'fieldType' => 'VariantListingConfigField', + 'example' => \json_encode([ + 'displayParent' => false, + 'mainVariantId' => '[uuid]', + 'configuratorGroupConfig' => [], + ], \JSON_PRETTY_PRINT), + ], + ]; + } + + public function testValidateResolutionUnsetEntityName(): void + { + static::expectExceptionObject(MigrationException::missingRequestParameter('entityName')); + + $request = new Request([], [ + 'fieldName' => 'name', + ]); + + $this->errorResolutionController->validateResolution( + $request, + Context::createDefaultContext() + ); + } + + public function testValidateResolutionUnsetFieldName(): void + { + static::expectExceptionObject(MigrationException::missingRequestParameter('fieldName')); + + $request = new Request([], [ + 'entityName' => 'product', + ]); + + $this->errorResolutionController->validateResolution( + $request, + Context::createDefaultContext() + ); + } + + /** + * @param string|list|null $fieldValue + */ + #[DataProvider('invalidResolutionProvider')] + public function testValidateResolutionInvalidRequest(string|array|null $fieldValue): void + { + static::expectExceptionObject(MigrationException::missingRequestParameter('fieldValue')); + + $request = new Request([], [ + 'entityName' => 'product', + 'fieldName' => 'name', + 'fieldValue' => $fieldValue, + ]); + + $this->errorResolutionController->validateResolution( + $request, + Context::createDefaultContext() + ); + } + + public static function invalidResolutionProvider(): \Generator + { + yield 'null' => ['fieldValue' => null]; + yield 'empty string' => ['fieldValue' => '']; + yield 'empty array' => ['fieldValue' => []]; + } + + /** + * @param array $expected + */ + #[DataProvider('validateResolutionProvider')] + public function testValidateResolution(string $entityName, string $fieldName, mixed $fieldValue, array $expected): void + { + $request = new Request([], [ + 'entityName' => $entityName, + 'fieldName' => $fieldName, + 'fieldValue' => $fieldValue, + ]); + + $response = $this->errorResolutionController->validateResolution( + $request, + Context::createDefaultContext() + ); + $data = $this->jsonResponseToArray($response); + + static::assertArrayHasKey('valid', $data); + static::assertArrayHasKey('violations', $data); + + $violationMessages = array_map(static fn (array $violation) => $violation['message'], $data['violations']); + + static::assertSame($expected['valid'], $data['valid']); + static::assertSame($expected['violations'], $violationMessages); + } + + public static function validateResolutionProvider(): \Generator + { + yield 'valid product name' => [ + 'entityName' => 'product', + 'fieldName' => 'name', + 'fieldValue' => 'Valid Product Name', + 'expected' => [ + 'valid' => true, + 'violations' => [], + ], + ]; + + yield 'invalid product stock' => [ + 'entityName' => 'product', + 'fieldName' => 'stock', + 'fieldValue' => 'jhdwhawbdh', + 'expected' => [ + 'valid' => false, + 'violations' => [ + 'This value should be of type int.', + ], + ], + ]; + + yield 'valid product active' => [ + 'entityName' => 'product', + 'fieldName' => 'active', + 'fieldValue' => true, + 'expected' => [ + 'valid' => true, + 'violations' => [], + ], + ]; + + yield 'invalid product taxId' => [ + 'entityName' => 'product', + 'fieldName' => 'taxId', + 'fieldValue' => 'invalid-uuid', + 'expected' => [ + 'valid' => false, + 'violations' => [ + 'The string "invalid-uuid" is not a valid uuid.', + ], + ], + ]; + + yield 'invalid product variant config' => [ + 'entityName' => 'product', + 'fieldName' => 'variantListingConfig', + 'fieldValue' => [ + 'displayParent' => 'not-a-boolean', + 'mainVariantId' => 'also-not-a-uuid', + 'configuratorGroupConfig' => [], + ], + 'expected' => [ + 'valid' => false, + 'violations' => [ + 'This value should be of type boolean.', + 'The string "also-not-a-uuid" is not a valid uuid.', + ], + ], + ]; + + yield 'valid product price' => [ + 'entityName' => 'product', + 'fieldName' => 'price', + 'fieldValue' => [ + [ + 'currencyId' => Defaults::CURRENCY, + 'gross' => 19.99, + 'net' => 16.81, + 'linked' => false, + ], + ], + 'expected' => [ + 'valid' => true, + 'violations' => [], + ], + ]; + } + + /** + * @return array|list> + */ + private function jsonResponseToArray(?Response $response): array + { + static::assertNotNull($response); + static::assertInstanceOf(JsonResponse::class, $response); + + $content = $response->getContent(); + static::assertIsNotBool($content); + static::assertJson($content); + + $array = \json_decode($content, true); + static::assertIsArray($array); + + return $array; + } +} diff --git a/tests/unit/Migration/ErrorResolution/MigrationFieldExampleGeneratorTest.php b/tests/unit/Migration/ErrorResolution/MigrationFieldExampleGeneratorTest.php index 855a21933..35fc8b24c 100644 --- a/tests/unit/Migration/ErrorResolution/MigrationFieldExampleGeneratorTest.php +++ b/tests/unit/Migration/ErrorResolution/MigrationFieldExampleGeneratorTest.php @@ -28,6 +28,7 @@ use Shopware\Core\Framework\DataAbstractionLayer\Field\PriceField; use Shopware\Core\Framework\DataAbstractionLayer\Field\StringField; use Shopware\Core\Framework\DataAbstractionLayer\Field\TaxFreeConfigField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\TranslatedField; use Shopware\Core\Framework\DataAbstractionLayer\Field\VariantListingConfigField; use Shopware\Core\Framework\Log\Package; use SwagMigrationAssistant\Migration\ErrorResolution\MigrationFieldExampleGenerator; @@ -69,6 +70,11 @@ public static function exampleFieldProvider(): \Generator 'expected' => '"[string]"', ]; + yield 'TranslatedField' => [ + 'field' => new TranslatedField('test'), + 'expected' => '"[string]"', + ]; + yield 'IdField' => [ 'field' => new IdField('test', 'test'), 'expected' => '"[uuid]"', From 4b4a9841ccf469101a664cdc3038ffb20a53ffed Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Mon, 29 Dec 2025 11:03:23 +0100 Subject: [PATCH 15/26] test: jest --- .../index.ts | 4 - .../index.ts | 17 +-- .../api/swag-migration.api.service.spec.js | 35 ++++++ ...tion-error-resolution-field-scalar.spec.js | 39 ++++++ ...n-error-resolution-field-unhandled.spec.js | 113 +++-------------- ...g-migration-error-resolution-field.spec.js | 69 +++++++++++ ...g-migration-error-resolution-modal.spec.js | 117 ++++++++++++++++++ 7 files changed, 289 insertions(+), 105 deletions(-) diff --git a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar/index.ts b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar/index.ts index 7171f9d8a..c09c8cea5 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar/index.ts +++ b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar/index.ts @@ -32,10 +32,6 @@ export default Shopware.Component.wrapComponentConfig({ type: Object as PropType, required: true, }, - entityName: { - type: String, - required: true, - }, fieldName: { type: String, required: true, diff --git a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/index.ts b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/index.ts index 7ff11aff0..efe0662f8 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/index.ts +++ b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/index.ts @@ -249,21 +249,24 @@ export default Shopware.Component.wrapComponentConfig({ this.createNotificationError({ message: this.$tc('swag-migration.index.error-resolution.errors.validationFailed'), }); - - return false; + return null; }); - if (serializationError?.valid === true) { + if (!serializationError) { + return false; + } + + if (serializationError.valid === true) { return true; } - if (!serializationError?.violations?.length) { + const message = serializationError.violations?.at(0)?.message; + + if (!message) { return false; } - this.fieldError = { - detail: serializationError.violations.at(0)?.message, - }; + this.fieldError = { detail: message }; return false; }, diff --git a/tests/Jest/src/core/service/api/swag-migration.api.service.spec.js b/tests/Jest/src/core/service/api/swag-migration.api.service.spec.js index 1b7b4d090..86efcfe6b 100644 --- a/tests/Jest/src/core/service/api/swag-migration.api.service.spec.js +++ b/tests/Jest/src/core/service/api/swag-migration.api.service.spec.js @@ -376,4 +376,39 @@ describe('src/core/service/api/swag-migration.api.service', () => { expect(clientMock.history.post[0].data).toBe(JSON.stringify(data)); expect(clientMock.history.post[0].headers['test-header']).toBe('test-value'); }); + + it('should validate resolution', async () => { + const { migrationApiService, clientMock } = createMigrationApiService(); + + const data = { + entityName: 'product', + fieldName: 'name', + fieldValue: 'New Product Name', + }; + + await migrationApiService.validateResolution(data.entityName, data.fieldName, data.fieldValue, { + 'test-header': 'test-value', + }); + + expect(clientMock.history.post[0].url).toBe('_action/migration/error-resolution/validate'); + expect(clientMock.history.post[0].data).toBe(JSON.stringify(data)); + expect(clientMock.history.post[0].headers['test-header']).toBe('test-value'); + }); + + it('should get example field structure', async () => { + const { migrationApiService, clientMock } = createMigrationApiService(); + + const data = { + entityName: 'product', + fieldName: 'name', + }; + + await migrationApiService.getExampleFieldStructure(data.entityName, data.fieldName, { + 'test-header': 'test-value', + }); + + expect(clientMock.history.post[0].url).toBe('_action/migration/error-resolution/example-field-structure'); + expect(clientMock.history.post[0].data).toBe(JSON.stringify(data)); + expect(clientMock.history.post[0].headers['test-header']).toBe('test-value'); + }); }); diff --git a/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar.spec.js b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar.spec.js index 39e8f093d..65c4cb8bc 100644 --- a/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar.spec.js +++ b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar.spec.js @@ -10,6 +10,7 @@ const updateFieldValueMock = jest.fn(); const defaultProps = { componentType: 'text', + entityNAme: 'customer', entityField: { entity: 'customer', type: 'string', @@ -175,4 +176,42 @@ describe('src/module/swag-migration/component/swag-migration-error-resolution/sw await wrapper.setProps({ disabled: true }); expect(wrapper.find('.sw-migration-error-resolution-field__datepicker input').attributes('disabled')).toBeDefined(); }); + + it('should init bool field value for switch component', async () => { + const props = { + ...defaultProps, + componentType: 'switch', + entityField: { + entity: 'customer', + type: 'bool', + }, + fieldName: 'active', + }; + + const wrapper = await createWrapper(props); + + expect(wrapper.vm.fieldValue).toBe(false); + expect(wrapper.find('.sw-migration-error-resolution-field__switch input').element.checked).toBe(false); + }); + + it('should init example value when set', async () => { + const props = { + ...defaultProps, + componentType: 'editor', + entityField: { + entity: 'product', + type: 'string', + }, + fieldName: 'price', + }; + + const wrapper = await createWrapper(props); + expect(wrapper.find('.sw-migration-error-resolution-field__editor').exists()).toBe(true); + + expect(wrapper.vm.fieldValue).toBeNull(); + + const example = 'Sample example value'; + await wrapper.setProps({ exampleValue: example }); + expect(wrapper.vm.fieldValue).toBe(example); + }); }); diff --git a/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-unhandled.spec.js b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-unhandled.spec.js index 6ad3e1182..57ca8c333 100644 --- a/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-unhandled.spec.js +++ b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-unhandled.spec.js @@ -23,9 +23,9 @@ async function createWrapper(props = defaultProps) { stubs: { 'sw-code-editor': await wrapTestComponent('sw-code-editor'), 'sw-base-field': await wrapTestComponent('sw-base-field'), + 'sw-field-error': await wrapTestComponent('sw-field-error'), 'sw-inheritance-switch': true, 'sw-ai-copilot-badge': true, - 'sw-field-error': true, 'sw-circle-icon': true, 'sw-help-text': true, 'mt-banner': true, @@ -71,113 +71,38 @@ describe('src/module/swag-migration/component/swag-migration-error-resolution/sw const wrapper = await createWrapper(); await flushPromises(); - expect(wrapper.find('.sw-code-editor__editor').attributes('content')).toBe(''); - expect(updateFieldValueMock).toHaveBeenCalledWith(''); - }); - - it.each([ - { name: 'string', value: '"test string"', expected: 'test string' }, - { name: 'number', value: '123', expected: 123 }, - { name: 'boolean', value: 'true', expected: true }, - { name: 'null', value: 'null', expected: null }, - { name: 'object', value: '{"key": "value"}', expected: { key: 'value' } }, - { - name: 'array', - value: '[1, 2, 3]', - expected: [ - 1, - 2, - 3, - ], - }, - ])('should parse and publish JSON values: $name', async ({ value, expected }) => { - const wrapper = await createWrapper(); - await flushPromises(); - jest.clearAllMocks(); - - await wrapper.setData({ fieldValue: value }); - await flushPromises(); - - expect(updateFieldValueMock).toHaveBeenCalledWith(expected); - }); - - it.each([ - { - name: 'trailing comma in object', - value: '{"key": "value",}', - }, - { - name: 'trailing comma in array', - value: '[1, 2, 3,]', - }, - { - name: 'nested trailing commas', - value: '{"outer": {"inner": "value",},}', - }, - ])('should return null and set error for trailing commas: $name', async ({ value }) => { - const wrapper = await createWrapper(); - await flushPromises(); - jest.clearAllMocks(); - - await wrapper.setData({ fieldValue: value }); - await flushPromises(); - + expect(wrapper.find('.sw-code-editor__editor').attributes('content')).toBeUndefined(); expect(updateFieldValueMock).toHaveBeenCalledWith(null); - expect(wrapper.vm.error).not.toBeNull(); - expect(wrapper.vm.error.detail).toBeDefined(); }); - it.each([ - { - name: 'invalid JSON', - value: 'not json', - }, - { - name: 'incomplete object', - value: '{"key": ', - }, - { - name: 'single quotes', - value: "{'key': 'value'}", - }, - ])('should return null and set error for invalid JSON: $name', async ({ value }) => { + it('should display error message when passed', async () => { const wrapper = await createWrapper(); await flushPromises(); - jest.clearAllMocks(); - - await wrapper.setData({ fieldValue: value }); - await flushPromises(); - expect(updateFieldValueMock).toHaveBeenCalledWith(null); - expect(wrapper.vm.error).not.toBeNull(); - expect(wrapper.vm.error.detail).toBeDefined(); - }); + expect(wrapper.find('.sw-field__error').exists()).toBe(false); - it('should trim whitespace from JSON string', async () => { - const wrapper = await createWrapper(); - await flushPromises(); - jest.clearAllMocks(); - - await wrapper.setData({ fieldValue: ' {"key": "value"} ' }); - await flushPromises(); + const message = 'This is an error message'; + await wrapper.setProps({ + error: { + detail: message, + }, + }); - expect(updateFieldValueMock).toHaveBeenCalledWith({ key: 'value' }); - expect(wrapper.vm.error).toBeNull(); + expect(wrapper.find('.sw-field__error').exists()).toBe(true); + expect(wrapper.find('.sw-field__error').text()).toBe(message); }); - it('should clear error when valid JSON is entered after invalid JSON', async () => { + it('should init with example value when set', async () => { const wrapper = await createWrapper(); await flushPromises(); - await wrapper.setData({ fieldValue: 'invalid json' }); - await flushPromises(); - expect(wrapper.vm.error).not.toBeNull(); + expect(wrapper.find('.sw-code-editor__editor').attributes('content')).toBeUndefined(); - jest.clearAllMocks(); - await wrapper.setData({ fieldValue: '{"valid": "json"}' }); - await flushPromises(); + const exampleValue = 'example content'; + await wrapper.setProps({ + exampleValue: exampleValue, + }); - expect(wrapper.vm.error).toBeNull(); - expect(updateFieldValueMock).toHaveBeenCalledWith({ valid: 'json' }); + expect(wrapper.find('.sw-code-editor__editor').attributes('content')).toBe(exampleValue); }); }); diff --git a/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field.spec.js b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field.spec.js index 6c18ecc0d..02e0e9c82 100644 --- a/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field.spec.js +++ b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field.spec.js @@ -7,6 +7,14 @@ import SwagMigrationErrorResolutionService from 'SwagMigrationAssistant/module/s Shopware.Component.register('swag-migration-error-resolution-field', () => SwagMigrationErrorResolutionField); +const migrationApiServiceMock = { + getExampleFieldStructure: jest.fn().mockResolvedValue( + Promise.resolve({ + example: 'Example Value', + }), + ), +}; + const defaultProps = { log: { count: 10, @@ -30,12 +38,17 @@ async function createWrapper(props = defaultProps) { }, provide: { swagMigrationErrorResolutionService: new SwagMigrationErrorResolutionService(), + migrationApiService: migrationApiServiceMock, }, }, }); } describe('src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field', () => { + beforeEach(() => { + Shopware.Store.get('notification').$reset(); + }); + it('should display unhandled component & pass data', async () => { const props = { ...defaultProps, @@ -48,12 +61,16 @@ describe('src/module/swag-migration/component/swag-migration-error-resolution/sw }; const wrapper = await createWrapper(props); + await flushPromises(); + + expect(migrationApiServiceMock.getExampleFieldStructure).toHaveBeenCalled(); const field = wrapper.find('swag-migration-error-resolution-field-unhandled-stub'); expect(field.exists()).toBe(true); expect(field.attributes('disabled')).toBe(String(props.disabled)); expect(field.attributes('field-name')).toBe(props.log.fieldName); + expect(field.attributes('example-value')).toBe('Example Value'); }); it('should display scalar component & pass data', async () => { @@ -68,6 +85,9 @@ describe('src/module/swag-migration/component/swag-migration-error-resolution/sw }; const wrapper = await createWrapper(props); + await flushPromises(); + + expect(migrationApiServiceMock.getExampleFieldStructure).not.toHaveBeenCalled(); const field = wrapper.find('swag-migration-error-resolution-field-scalar-stub'); @@ -89,6 +109,9 @@ describe('src/module/swag-migration/component/swag-migration-error-resolution/sw }; const wrapper = await createWrapper(props); + await flushPromises(); + + expect(migrationApiServiceMock.getExampleFieldStructure).not.toHaveBeenCalled(); const field = wrapper.find('swag-migration-error-resolution-field-relation-stub'); @@ -96,4 +119,50 @@ describe('src/module/swag-migration/component/swag-migration-error-resolution/sw expect(field.attributes('disabled')).toBe(String(props.disabled)); expect(field.attributes('field-name')).toBe(props.log.fieldName); }); + + it('should fetch example field value', async () => { + const wrapper = await createWrapper({ + ...defaultProps, + log: { + ...defaultProps.log, + entityName: 'product', + fieldName: 'price', + }, + }); + await flushPromises(); + + expect(migrationApiServiceMock.getExampleFieldStructure).toHaveBeenCalled(); + + expect(wrapper.find('swag-migration-error-resolution-field-scalar-stub').exists()).toBe(true); + expect(wrapper.find('swag-migration-error-resolution-field-scalar-stub').attributes('example-value')).toBe( + 'Example Value', + ); + }); + + it('should display error notification on fetch failure', async () => { + migrationApiServiceMock.getExampleFieldStructure.mockRejectedValueOnce(new Error('Fetch failed')); + + await createWrapper({ + ...defaultProps, + log: { + ...defaultProps.log, + entityName: 'product', + fieldName: 'price', + }, + }); + await flushPromises(); + + expect(migrationApiServiceMock.getExampleFieldStructure).toHaveBeenCalled(); + + const notifications = Object.values(Shopware.Store.get('notification').notifications); + + expect(notifications).toHaveLength(1); + expect(notifications).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: 'swag-migration.index.error-resolution.errors.fetchExampleFailed', + }), + ]), + ); + }); }); diff --git a/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal.spec.js b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal.spec.js index a59bd915e..9a65aa112 100644 --- a/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal.spec.js +++ b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal.spec.js @@ -60,6 +60,7 @@ const defaultProps = { }; const migrationApiServiceMock = { + validateResolution: jest.fn(() => Promise.resolve({ valid: true })), getAllLogIds: jest.fn(() => Promise.resolve({ ids: logMocks.map((log) => log.id), @@ -545,6 +546,122 @@ describe('module/swag-migration/component/swag-migration-error-resolution/swag-m }); describe('create resolution fix', () => { + it('should not call backend validation when resolving to-many association fields', async () => { + const wrapper = await createWrapper({ + ...defaultProps, + selectedLog: { + ...fixtureLogGroups.at(1), + entityName: 'product', + fieldName: 'categories', + }, + }); + await flushPromises(); + + await wrapper.find('.sw-data-grid__row--1 .mt-field--checkbox input').setChecked(true); + await flushPromises(); + + const relationField = wrapper.findComponent({ name: 'swag-migration-error-resolution-field-relation' }); + await relationField.setData({ + fieldValue: [ + 'category-id-1', + 'category-id-2', + ], + }); + await flushPromises(); + + await wrapper.find('.swag-migration-error-resolution-modal__right-content-button').trigger('click'); + await flushPromises(); + + expect(migrationApiServiceMock.validateResolution).not.toHaveBeenCalled(); + }); + + it('should save fix when backend validation passes', async () => { + migrationApiServiceMock.validateResolution.mockResolvedValueOnce({ valid: true, violations: [] }); + + const wrapper = await createWrapper({ + ...defaultProps, + selectedLog: { + ...fixtureLogGroups.at(1), + entityName: 'media', + fieldName: 'title', + }, + }); + await flushPromises(); + + await wrapper.find('.sw-data-grid__row--1 .mt-field--checkbox input').setChecked(true); + await flushPromises(); + + const inputField = wrapper.find('.swag-migration-error-resolution-field-scalar input'); + await inputField.setValue('Valid Title'); + await flushPromises(); + + await wrapper.find('.swag-migration-error-resolution-modal__right-content-button').trigger('click'); + await flushPromises(); + + expect(migrationApiServiceMock.validateResolution).toHaveBeenCalledWith('media', 'title', 'Valid Title'); + expect(wrapper.vm.fieldError).toBeNull(); + expect(migrationFixRepositoryMock.saveAll).toHaveBeenCalled(); + }); + + it('should not save fix when backend validation fails without message', async () => { + migrationApiServiceMock.validateResolution.mockResolvedValueOnce({ valid: false, violations: [] }); + + const wrapper = await createWrapper({ + ...defaultProps, + selectedLog: { + ...fixtureLogGroups.at(1), + entityName: 'media', + fieldName: 'title', + }, + }); + await flushPromises(); + + await wrapper.find('.sw-data-grid__row--1 .mt-field--checkbox input').setChecked(true); + await flushPromises(); + + const inputField = wrapper.find('.swag-migration-error-resolution-field-scalar input'); + await inputField.setValue('Invalid Value'); + await flushPromises(); + + await wrapper.find('.swag-migration-error-resolution-modal__right-content-button').trigger('click'); + await flushPromises(); + + expect(migrationApiServiceMock.validateResolution).toHaveBeenCalledWith('media', 'title', 'Invalid Value'); + expect(wrapper.vm.fieldError).toBeNull(); + expect(migrationFixRepositoryMock.saveAll).not.toHaveBeenCalled(); + }); + + it('should display field error when backend validation fails with message', async () => { + migrationApiServiceMock.validateResolution.mockResolvedValueOnce({ + valid: false, + violations: [{ message: 'This value is invalid.' }], + }); + + const wrapper = await createWrapper({ + ...defaultProps, + selectedLog: { + ...fixtureLogGroups.at(1), + entityName: 'media', + fieldName: 'title', + }, + }); + await flushPromises(); + + await wrapper.find('.sw-data-grid__row--1 .mt-field--checkbox input').setChecked(true); + await flushPromises(); + + const inputField = wrapper.find('.swag-migration-error-resolution-field-scalar input'); + await inputField.setValue('Invalid Value'); + await flushPromises(); + + await wrapper.find('.swag-migration-error-resolution-modal__right-content-button').trigger('click'); + await flushPromises(); + + expect(migrationApiServiceMock.validateResolution).toHaveBeenCalledWith('media', 'title', 'Invalid Value'); + expect(wrapper.vm.fieldError).toStrictEqual({ detail: 'This value is invalid.' }); + expect(migrationFixRepositoryMock.saveAll).not.toHaveBeenCalled(); + }); + it.each(Object.keys(ERROR_CODE_COMPONENT_MAPPING).map((code) => ({ code })))( 'should render default resolve component for defined codes: $code', async ({ code }) => { From 82a5c79fa78dd3ab9400a6959bce123b33743af3 Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Wed, 7 Jan 2026 10:39:17 +0100 Subject: [PATCH 16/26] refactor: unify controller annoations --- src/Controller/DataProviderController.php | 16 ++++---- src/Controller/ErrorResolutionController.php | 8 ++-- src/Controller/HistoryController.php | 40 +++++++++++++++----- src/Controller/PremappingController.php | 18 +++++++-- src/Controller/StatusController.php | 34 +++++++++-------- 5 files changed, 78 insertions(+), 38 deletions(-) diff --git a/src/Controller/DataProviderController.php b/src/Controller/DataProviderController.php index 29f1c56f4..4e2ccef17 100644 --- a/src/Controller/DataProviderController.php +++ b/src/Controller/DataProviderController.php @@ -19,7 +19,9 @@ use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Routing\ApiRouteScope; use Shopware\Core\Framework\Routing\RoutingException; +use Shopware\Core\PlatformRequest; use SwagMigrationAssistant\DataProvider\Provider\ProviderRegistryInterface; use SwagMigrationAssistant\DataProvider\Service\EnvironmentServiceInterface; use SwagMigrationAssistant\Exception\MigrationException; @@ -32,7 +34,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\Routing\Attribute\Route; -#[Route(defaults: ['_routeScope' => ['api']])] +#[Route(defaults: [PlatformRequest::ATTRIBUTE_ROUTE_SCOPE => [ApiRouteScope::ID]])] #[Package('fundamentals@after-sales')] class DataProviderController extends AbstractController { @@ -54,7 +56,7 @@ public function __construct( #[Route( path: '/api/_action/data-provider/get-environment', name: 'api.admin.data-provider.get-environment', - defaults: ['_acl' => ['admin']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['admin']], methods: [Request::METHOD_GET] )] public function getEnvironment(Context $context): JsonResponse @@ -67,7 +69,7 @@ public function getEnvironment(Context $context): JsonResponse #[Route( path: '/api/_action/data-provider/get-data', name: 'api.admin.data-provider.get-data', - defaults: ['_acl' => ['admin']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['admin']], methods: [Request::METHOD_GET] )] public function getData(Request $request, Context $context): JsonResponse @@ -89,7 +91,7 @@ public function getData(Request $request, Context $context): JsonResponse #[Route( path: '/api/_action/data-provider/get-total', name: 'api.admin.data-provider.get-total', - defaults: ['_acl' => ['admin']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['admin']], methods: [Request::METHOD_GET] )] public function getTotal(Request $request, Context $context): JsonResponse @@ -107,7 +109,7 @@ public function getTotal(Request $request, Context $context): JsonResponse #[Route( path: '/api/_action/data-provider/get-table', name: 'api.admin.data-provider.get-table', - defaults: ['_acl' => ['admin']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['admin']], methods: [Request::METHOD_GET] )] public function getTable(Request $request, Context $context): JsonResponse @@ -127,7 +129,7 @@ public function getTable(Request $request, Context $context): JsonResponse #[Route( path: '/api/_action/data-provider/generate-document', name: 'api.admin.data-provider.generate-document', - defaults: ['_acl' => ['admin']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['admin']], methods: [Request::METHOD_GET] )] public function generateDocument(Request $request, Context $context): JsonResponse @@ -154,7 +156,7 @@ public function generateDocument(Request $request, Context $context): JsonRespon #[Route( path: '/api/_action/data-provider/download-private-file/{file}', name: 'api.admin.data-provider.download-private-file', - defaults: ['_acl' => ['admin']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['admin']], methods: [Request::METHOD_GET] )] public function downloadPrivateFile(Request $request, Context $context): StreamedResponse|RedirectResponse diff --git a/src/Controller/ErrorResolutionController.php b/src/Controller/ErrorResolutionController.php index b8884b0be..48c2101e9 100644 --- a/src/Controller/ErrorResolutionController.php +++ b/src/Controller/ErrorResolutionController.php @@ -10,7 +10,9 @@ use Shopware\Core\Framework\Context; use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry; use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Routing\ApiRouteScope; use Shopware\Core\Framework\Validation\WriteConstraintViolationException; +use Shopware\Core\PlatformRequest; use SwagMigrationAssistant\Exception\MigrationException; use SwagMigrationAssistant\Migration\ErrorResolution\MigrationFieldExampleGenerator; use SwagMigrationAssistant\Migration\Validation\MigrationFieldValidationService; @@ -19,7 +21,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; -#[Route(defaults: ['_routeScope' => ['api']])] +#[Route(defaults: [PlatformRequest::ATTRIBUTE_ROUTE_SCOPE => [ApiRouteScope::ID]])] #[Package('fundamentals@after-sales')] class ErrorResolutionController extends AbstractController { @@ -35,7 +37,7 @@ public function __construct( #[Route( path: '/api/_action/migration/error-resolution/validate', name: 'api.admin.migration.error-resolution.validate', - defaults: ['_acl' => ['swag_migration.viewer']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], methods: [Request::METHOD_POST] )] public function validateResolution(Request $request, Context $context): JsonResponse @@ -85,7 +87,7 @@ public function validateResolution(Request $request, Context $context): JsonResp #[Route( path: '/api/_action/migration/error-resolution/example-field-structure', name: 'api.admin.migration.error-resolution.example-field-structure', - defaults: ['_acl' => ['swag_migration.viewer']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], methods: [Request::METHOD_POST] )] public function getExampleFieldStructure(Request $request): JsonResponse diff --git a/src/Controller/HistoryController.php b/src/Controller/HistoryController.php index 16ac27c42..6598dae86 100644 --- a/src/Controller/HistoryController.php +++ b/src/Controller/HistoryController.php @@ -9,7 +9,9 @@ use Shopware\Core\Framework\Context; use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Routing\ApiRouteScope; use Shopware\Core\Framework\Routing\RoutingException; +use Shopware\Core\PlatformRequest; use SwagMigrationAssistant\Exception\MigrationException; use SwagMigrationAssistant\Migration\History\HistoryServiceInterface; use SwagMigrationAssistant\Migration\History\LogGroupingService; @@ -22,7 +24,7 @@ use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; -#[Route(defaults: ['_routeScope' => ['api']])] +#[Route(defaults: [PlatformRequest::ATTRIBUTE_ROUTE_SCOPE => [ApiRouteScope::ID]])] #[Package('fundamentals@after-sales')] class HistoryController extends AbstractController { @@ -35,7 +37,12 @@ public function __construct( ) { } - #[Route(path: '/api/_action/migration/get-grouped-logs-of-run', name: 'api.admin.migration.get-grouped-logs-of-run', methods: ['GET'], defaults: ['_acl' => ['swag_migration.viewer']])] + #[Route( + path: '/api/_action/migration/get-grouped-logs-of-run', + name: 'api.admin.migration.get-grouped-logs-of-run', + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], + methods: [Request::METHOD_GET], + )] public function getGroupedLogsOfRun(Request $request, Context $context): JsonResponse { $runUuid = $request->query->getAlnum('runUuid'); @@ -60,7 +67,12 @@ public function getGroupedLogsOfRun(Request $request, Context $context): JsonRes ]); } - #[Route(path: '/api/_action/migration/download-logs-of-run', name: 'api.admin.migration.download-logs-of-run', methods: ['POST'], defaults: ['auth_required' => false, '_acl' => ['swag_migration.viewer']])] + #[Route( + path: '/api/_action/migration/download-logs-of-run', + name: 'api.admin.migration.download-logs-of-run', + defaults: ['auth_required' => false, PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], + methods: [Request::METHOD_POST], + )] public function downloadLogsOfRun(Request $request, Context $context): StreamedResponse { $runUuid = $request->request->getAlnum('runUuid'); @@ -86,7 +98,12 @@ public function downloadLogsOfRun(Request $request, Context $context): StreamedR return $response; } - #[Route(path: '/api/_action/migration/clear-data-of-run', name: 'api.admin.migration.clear-data-of-run', methods: ['POST'], defaults: ['_acl' => ['swag_migration.deleter']])] + #[Route( + path: '/api/_action/migration/clear-data-of-run', + name: 'api.admin.migration.clear-data-of-run', + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.deleter']], + methods: [Request::METHOD_POST], + )] public function clearDataOfRun(Request $request, Context $context): Response { $runUuid = $request->request->getAlnum('runUuid'); @@ -104,7 +121,12 @@ public function clearDataOfRun(Request $request, Context $context): Response return new Response(); } - #[Route(path: '/api/_action/migration/is-media-processing', name: 'api.admin.migration.is-media-processing', methods: ['GET'], defaults: ['_acl' => ['swag_migration_history:read']])] + #[Route( + path: '/api/_action/migration/is-media-processing', + name: 'api.admin.migration.is-media-processing', + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration_history:read']], + methods: [Request::METHOD_GET], + )] public function isMediaProcessing(): JsonResponse { $result = $this->historyService->isMediaProcessing(); @@ -115,8 +137,8 @@ public function isMediaProcessing(): JsonResponse #[Route( path: '/api/_action/migration/get-log-groups', name: 'api.admin.migration.get-log-groups', - methods: ['GET'], - defaults: ['_acl' => ['swag_migration.viewer']] + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], + methods: [Request::METHOD_GET], )] public function getLogGroups(Request $request, Context $context): JsonResponse { @@ -166,8 +188,8 @@ public function getLogGroups(Request $request, Context $context): JsonResponse #[Route( path: '/api/_action/migration/get-all-log-ids', name: 'api.admin.migration.get-all-log-ids', - methods: ['POST'], - defaults: ['_acl' => ['swag_migration.viewer']] + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], + methods: [Request::METHOD_POST], )] public function getAllLogIds(Request $request): JsonResponse { diff --git a/src/Controller/PremappingController.php b/src/Controller/PremappingController.php index 078990a1d..9e1310535 100644 --- a/src/Controller/PremappingController.php +++ b/src/Controller/PremappingController.php @@ -9,7 +9,9 @@ use Shopware\Core\Framework\Context; use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Routing\ApiRouteScope; use Shopware\Core\Framework\Routing\RoutingException; +use Shopware\Core\PlatformRequest; use SwagMigrationAssistant\Migration\MigrationContextFactoryInterface; use SwagMigrationAssistant\Migration\Service\PremappingServiceInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -18,7 +20,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -#[Route(defaults: ['_routeScope' => ['api']])] +#[Route(defaults: [PlatformRequest::ATTRIBUTE_ROUTE_SCOPE => [ApiRouteScope::ID]])] #[Package('fundamentals@after-sales')] class PremappingController extends AbstractController { @@ -31,7 +33,12 @@ public function __construct( ) { } - #[Route(path: '/api/_action/migration/generate-premapping', name: 'api.admin.migration.generate-premapping', methods: ['POST'], defaults: ['_acl' => ['swag_migration.editor']])] + #[Route( + path: '/api/_action/migration/generate-premapping', + name: 'api.admin.migration.generate-premapping', + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.editor']], + methods: [Request::METHOD_POST], + )] public function generatePremapping(Request $request, Context $context): JsonResponse { $dataSelectionIds = $request->request->all('dataSelectionIds'); @@ -44,7 +51,12 @@ public function generatePremapping(Request $request, Context $context): JsonResp return new JsonResponse($this->premappingService->generatePremapping($context, $migrationContext, $dataSelectionIds)); } - #[Route(path: '/api/_action/migration/write-premapping', name: 'api.admin.migration.write-premapping', methods: ['POST'], defaults: ['_acl' => ['swag_migration.editor']])] + #[Route( + path: '/api/_action/migration/write-premapping', + name: 'api.admin.migration.write-premapping', + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.editor']], + methods: [Request::METHOD_POST], + )] public function writePremapping(Request $request, Context $context): Response { $premapping = $request->request->all('premapping'); diff --git a/src/Controller/StatusController.php b/src/Controller/StatusController.php index 5f6395be0..616f88ebe 100644 --- a/src/Controller/StatusController.php +++ b/src/Controller/StatusController.php @@ -11,7 +11,9 @@ use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Routing\ApiRouteScope; use Shopware\Core\Framework\Routing\RoutingException; +use Shopware\Core\PlatformRequest; use SwagMigrationAssistant\Exception\MigrationException; use SwagMigrationAssistant\Migration\Connection\Fingerprint\MigrationFingerprintServiceInterface; use SwagMigrationAssistant\Migration\Connection\SwagMigrationConnectionCollection; @@ -30,7 +32,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -#[Route(defaults: ['_routeScope' => ['api']])] +#[Route(defaults: [PlatformRequest::ATTRIBUTE_ROUTE_SCOPE => [ApiRouteScope::ID]])] #[Package('fundamentals@after-sales')] class StatusController extends AbstractController { @@ -56,7 +58,7 @@ public function __construct( #[Route( path: '/api/_action/migration/get-profile-information', name: 'api.admin.migration.get-profile-information', - defaults: ['_acl' => ['swag_migration.viewer']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], methods: [Request::METHOD_GET] )] public function getProfileInformation(Request $request): Response @@ -126,7 +128,7 @@ public function getProfileInformation(Request $request): Response #[Route( path: '/api/_action/migration/get-profiles', name: 'api.admin.migration.get-profiles', - defaults: ['_acl' => ['swag_migration.viewer']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], methods: [Request::METHOD_GET] )] public function getProfiles(): JsonResponse @@ -149,7 +151,7 @@ public function getProfiles(): JsonResponse #[Route( path: '/api/_action/migration/get-gateways', name: 'api.admin.migration.get-gateways', - defaults: ['_acl' => ['swag_migration.viewer']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], methods: [Request::METHOD_GET] )] public function getGateways(Request $request): JsonResponse @@ -180,7 +182,7 @@ public function getGateways(Request $request): JsonResponse #[Route( path: '/api/_action/migration/update-connection-credentials', name: 'api.admin.migration.update-connection-credentials', - defaults: ['_acl' => ['swag_migration.editor']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.editor']], methods: [Request::METHOD_POST] )] public function updateConnectionCredentials(Request $request, Context $context): Response @@ -207,7 +209,7 @@ public function updateConnectionCredentials(Request $request, Context $context): #[Route( path: '/api/_action/migration/data-selection', name: 'api.admin.migration.data-selection', - defaults: ['_acl' => ['swag_migration.viewer']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], methods: [Request::METHOD_GET] )] public function getDataSelection(Request $request, Context $context): JsonResponse @@ -234,7 +236,7 @@ public function getDataSelection(Request $request, Context $context): JsonRespon #[Route( path: '/api/_action/migration/check-connection', name: 'api.admin.migration.check-connection', - defaults: ['_acl' => ['swag_migration.viewer']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], methods: [Request::METHOD_POST] )] public function checkConnection(Request $request, Context $context): JsonResponse @@ -287,7 +289,7 @@ public function checkConnection(Request $request, Context $context): JsonRespons #[Route( path: '/api/_action/migration/start-migration', name: 'api.admin.migration.start-migration', - defaults: ['_acl' => ['swag_migration.creator']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.creator']], methods: [Request::METHOD_POST] )] public function startMigration(Request $request, Context $context): Response @@ -320,7 +322,7 @@ public function startMigration(Request $request, Context $context): Response #[Route( path: '/api/_action/migration/get-state', name: 'api.admin.migration.get-state', - defaults: ['_acl' => ['swag_migration.viewer']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], methods: [Request::METHOD_GET] )] public function getState(Context $context): JsonResponse @@ -331,7 +333,7 @@ public function getState(Context $context): JsonResponse #[Route( path: '/api/_action/migration/approve-finished', name: 'api.admin.migration.approveFinished', - defaults: ['_acl' => ['swag_migration.editor']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.editor']], methods: [Request::METHOD_POST] )] public function approveFinishedMigration(Context $context): Response @@ -352,7 +354,7 @@ public function approveFinishedMigration(Context $context): Response #[Route( path: '/api/_action/migration/abort-migration', name: 'api.admin.migration.abort-migration', - defaults: ['_acl' => ['swag_migration.editor']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.editor']], methods: [Request::METHOD_POST] )] public function abortMigration(Context $context): Response @@ -365,7 +367,7 @@ public function abortMigration(Context $context): Response #[Route( path: '/api/_action/migration/reset-checksums', name: 'api.admin.migration.reset-checksums', - defaults: ['_acl' => ['swag_migration.deleter']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.deleter']], methods: [Request::METHOD_POST] )] public function resetChecksums(Request $request, Context $context): Response @@ -384,7 +386,7 @@ public function resetChecksums(Request $request, Context $context): Response #[Route( path: '/api/_action/migration/cleanup-migration-data', name: 'api.admin.migration.cleanup-migration-data', - defaults: ['_acl' => ['swag_migration.deleter']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.deleter']], methods: [Request::METHOD_POST] )] public function cleanupMigrationData(Context $context): Response @@ -397,7 +399,7 @@ public function cleanupMigrationData(Context $context): Response #[Route( path: '/api/_action/migration/is-truncating-migration-data', name: 'api.admin.migration.get-reset-status', - defaults: ['_acl' => ['swag_migration.viewer']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], methods: [Request::METHOD_GET] )] public function isTruncatingMigrationData(Context $context): JsonResponse @@ -414,7 +416,7 @@ public function isTruncatingMigrationData(Context $context): JsonResponse #[Route( path: '/api/_action/migration/is-resetting-checksums', name: 'api.admin.migration.is-resetting-checksums', - defaults: ['_acl' => ['swag_migration.viewer']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], methods: [Request::METHOD_GET] )] public function isResettingChecksums(Context $context): JsonResponse @@ -436,7 +438,7 @@ public function isResettingChecksums(Context $context): JsonResponse #[Route( path: '/api/_action/migration/resume-after-fixes', name: 'api.admin.migration.resume-after-fixes', - defaults: ['_acl' => ['admin']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['admin']], methods: [Request::METHOD_POST] )] public function resumeAfterFixes(Context $context): Response From ec3f5da04a5c3601857d0d9f38a68b7a70b787cc Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Wed, 7 Jan 2026 14:59:42 +0100 Subject: [PATCH 17/26] refactor: rebase & adjust to changes --- UPGRADE.md | 4 +- src/Controller/ErrorResolutionController.php | 22 +- src/DependencyInjection/migration.xml | 4 +- src/Exception/MigrationException.php | 12 - .../Service/MigrationDataConverter.php | 4 +- .../MigrationValidationException.php | 27 ++- ...p => MigrationEntityValidationService.php} | 216 ++---------------- .../MigrationFieldValidationService.php | 190 +++++++++++++-- .../Services/MigrationDataConverterTest.php | 6 +- tests/MigrationServicesTrait.php | 6 +- .../ErrorResolutionControllerTest.php | 3 +- ... MigrationEntityValidationServiceTest.php} | 10 +- .../MigrationFieldValidationServiceTest.php | 25 +- 13 files changed, 269 insertions(+), 260 deletions(-) rename src/Migration/Validation/{MigrationValidationService.php => MigrationEntityValidationService.php} (62%) rename tests/integration/Migration/Validation/{MigrationValidationServiceTest.php => MigrationEntityValidationServiceTest.php} (98%) diff --git a/UPGRADE.md b/UPGRADE.md index b03106cc1..033ae8692 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -139,12 +139,12 @@ - [BREAKING] [#51](https://github.com/shopware/SwagMigrationAssistant/pull/51) - feat!: add migration validation of converted data - [BREAKING] Added validation check of converted data to `convertData(...)` method in `SwagMigrationAssistant\Migration\Service\MigrationDataConverter` - Added `hasValidMappingByEntityId(...)` to `SwagMigrationAssistant\Migration\Mapping\MappingService` and `SwagMigrationAssistant\Migration\Mapping\MappingServiceInterface` to check if a mapping exists and is valid for a given entity and source id - - Added new service `SwagMigrationAssistant\Migration\Validation\MigrationValidationService` to validate converted data against Shopware's data definitions + - Added new service `SwagMigrationAssistant\Migration\Validation\MigrationEntityValidationService` to validate converted data against Shopware's data definitions - Added new events `SwagMigrationAssistant\Migration\Validation\Event\MigrationPreValidationEvent` and `SwagMigrationAssistant\Migration\Validation\Event\MigrationPostValidationEvent` to allow extensions to hook into the validation process - Added new log classes `ValidationInvalidFieldValueLog`, `ValidationInvalidForeignKeyLog`, `ValidationMissingRequiredFieldLog` and `ValidationUnexpectedFieldLog` to log validation errors - Added new context class `SwagMigrationAssistant\Migration\Validation\MigrationValidationContext` to pass validation related data - Added new result class `SwagMigrationAssistant\Migration\Validation\MigrationValidationResult` to collect validation results - - Added new service `SwagMigrationAssistant\Migration\Validation\MigrationValidationService` to validate converted data against Shopware's data definitions in three steps: + - Added new service `SwagMigrationAssistant\Migration\Validation\MigrationEntityValidationService` to validate converted data against Shopware's data definitions in three steps: - Entity structure: Check for unexpected fields - Field Validation: Check for missing required fields and invalid field values - Association Validation: Check for invalid foreign keys diff --git a/src/Controller/ErrorResolutionController.php b/src/Controller/ErrorResolutionController.php index 48c2101e9..1e5f4335f 100644 --- a/src/Controller/ErrorResolutionController.php +++ b/src/Controller/ErrorResolutionController.php @@ -15,6 +15,7 @@ use Shopware\Core\PlatformRequest; use SwagMigrationAssistant\Exception\MigrationException; use SwagMigrationAssistant\Migration\ErrorResolution\MigrationFieldExampleGenerator; +use SwagMigrationAssistant\Migration\Validation\Exception\MigrationValidationException; use SwagMigrationAssistant\Migration\Validation\MigrationFieldValidationService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -60,21 +61,30 @@ public function validateResolution(Request $request, Context $context): JsonResp } try { - $this->fieldValidationService->validateFieldValue( + $this->fieldValidationService->validateField( $entityName, $fieldName, $fieldValue, $context, ); - } catch (WriteConstraintViolationException $e) { + } catch (MigrationValidationException $exception) { + $previous = $exception->getPrevious(); + + if ($previous instanceof WriteConstraintViolationException) { + return new JsonResponse([ + 'valid' => false, + 'violations' => $previous->toArray(), + ]); + } + return new JsonResponse([ 'valid' => false, - 'violations' => $e->toArray(), + 'violations' => [['message' => $exception->getMessage()]], ]); - } catch (\Exception $e) { + } catch (\Exception $exception) { return new JsonResponse([ 'valid' => false, - 'violations' => [['message' => $e->getMessage()]], + 'violations' => [['message' => $exception->getMessage()]], ]); } @@ -107,7 +117,7 @@ public function getExampleFieldStructure(Request $request): JsonResponse $fields = $entityDefinition->getFields(); if (!$fields->has($fieldName)) { - throw MigrationException::entityFieldNotFound($entityName, $fieldName); + throw MigrationValidationException::entityFieldNotFound($entityName, $fieldName); } $field = $fields->get($fieldName); diff --git a/src/DependencyInjection/migration.xml b/src/DependencyInjection/migration.xml index 2b18d7ff1..40fae385e 100644 --- a/src/DependencyInjection/migration.xml +++ b/src/DependencyInjection/migration.xml @@ -225,7 +225,7 @@ - + @@ -430,7 +430,7 @@ id="SwagMigrationAssistant\Core\Content\Product\Stock\StockStorageDecorator.inner"/> - + diff --git a/src/Exception/MigrationException.php b/src/Exception/MigrationException.php index 3653fcf56..38e8b682d 100644 --- a/src/Exception/MigrationException.php +++ b/src/Exception/MigrationException.php @@ -95,8 +95,6 @@ class MigrationException extends HttpException final public const MISSING_REQUEST_PARAMETER = 'SWAG_MIGRATION__MISSING_REQUEST_PARAMETER'; - final public const ENTITY_FIELD_NOT_FOUND = 'SWAG_MIGRATION__ENTITY_FIELD_NOT_FOUND'; - public static function associationEntityRequiredMissing(string $entity, string $missingEntity): self { return new self( @@ -490,14 +488,4 @@ public static function missingRequestParameter(string $parameterName): self ['parameterName' => $parameterName] ); } - - public static function entityFieldNotFound(string $entityName, string $fieldName): self - { - return new self( - Response::HTTP_NOT_FOUND, - self::ENTITY_FIELD_NOT_FOUND, - 'Field "{{ fieldName }}" not found in entity "{{ entityName }}".', - ['fieldName' => $fieldName, 'entityName' => $entityName] - ); - } } diff --git a/src/Migration/Service/MigrationDataConverter.php b/src/Migration/Service/MigrationDataConverter.php index 8f3a39687..2127d03c9 100644 --- a/src/Migration/Service/MigrationDataConverter.php +++ b/src/Migration/Service/MigrationDataConverter.php @@ -25,7 +25,7 @@ use SwagMigrationAssistant\Migration\Mapping\MappingServiceInterface; use SwagMigrationAssistant\Migration\Media\MediaFileServiceInterface; use SwagMigrationAssistant\Migration\MigrationContextInterface; -use SwagMigrationAssistant\Migration\Validation\MigrationValidationService; +use SwagMigrationAssistant\Migration\Validation\MigrationEntityValidationService; #[Package('fundamentals@after-sales')] class MigrationDataConverter implements MigrationDataConverterInterface @@ -37,7 +37,7 @@ public function __construct( private readonly LoggingServiceInterface $loggingService, private readonly EntityDefinition $dataDefinition, private readonly MappingServiceInterface $mappingService, - private readonly MigrationValidationService $validationService, + private readonly MigrationEntityValidationService $validationService, ) { } diff --git a/src/Migration/Validation/Exception/MigrationValidationException.php b/src/Migration/Validation/Exception/MigrationValidationException.php index 6e557120a..8fa6217e7 100644 --- a/src/Migration/Validation/Exception/MigrationValidationException.php +++ b/src/Migration/Validation/Exception/MigrationValidationException.php @@ -29,6 +29,8 @@ class MigrationValidationException extends MigrationException final public const VALIDATION_INVALID_ASSOCIATION = 'SWAG_MIGRATION_VALIDATION__INVALID_ASSOCIATION'; + final public const VALIDATION_ENTITY_FIELD_NOT_FOUND = 'SWAG_MIGRATION_VALIDATION__ENTITY_FIELD_NOT_FOUND'; + public static function unexpectedNullValue(string $fieldName): self { return new self( @@ -49,33 +51,36 @@ public static function invalidId(string $entityId, string $entityName): self ); } - public static function invalidRequiredFieldValue(string $entityName, string $fieldName, string $message): self + public static function invalidRequiredFieldValue(string $entityName, string $fieldName, ?\Throwable $previous = null): self { return new self( Response::HTTP_BAD_REQUEST, self::VALIDATION_INVALID_REQUIRED_FIELD_VALUE, 'Invalid value for required field "{{ fieldName }}" in entity "{{ entityName }}": {{ message }}', - ['fieldName' => $fieldName, 'entityName' => $entityName, 'message' => $message] + ['fieldName' => $fieldName, 'entityName' => $entityName, 'message' => $previous?->getMessage() ?? ''], + $previous ); } - public static function invalidOptionalFieldValue(string $entityName, string $fieldName, string $message): self + public static function invalidOptionalFieldValue(string $entityName, string $fieldName, ?\Throwable $previous = null): self { return new self( Response::HTTP_BAD_REQUEST, self::VALIDATION_INVALID_OPTIONAL_FIELD_VALUE, 'Invalid value for optional field "{{ fieldName }}" in entity "{{ entityName }}": {{ message }}', - ['fieldName' => $fieldName, 'entityName' => $entityName, 'message' => $message] + ['fieldName' => $fieldName, 'entityName' => $entityName, 'message' => $previous?->getMessage() ?? ''], + $previous ); } - public static function invalidTranslation(string $entityName, string $fieldName, string $message): self + public static function invalidTranslation(string $entityName, string $fieldName, ?\Throwable $previous = null): self { return new self( Response::HTTP_BAD_REQUEST, self::VALIDATION_INVALID_TRANSLATION, 'Invalid translation for field "{{ fieldName }}" in entity "{{ entityName }}": {{ message }}', - ['fieldName' => $fieldName, 'entityName' => $entityName, 'message' => $message] + ['fieldName' => $fieldName, 'entityName' => $entityName, 'message' => $previous?->getMessage() ?? ''], + $previous ); } @@ -88,4 +93,14 @@ public static function invalidAssociation(string $entityName, string $fieldName, ['fieldName' => $fieldName, 'entityName' => $entityName, 'message' => $message] ); } + + public static function entityFieldNotFound(string $entityName, string $fieldName): self + { + return new self( + Response::HTTP_NOT_FOUND, + self::VALIDATION_ENTITY_FIELD_NOT_FOUND, + 'Field "{{ fieldName }}" not found in entity "{{ entityName }}".', + ['fieldName' => $fieldName, 'entityName' => $entityName] + ); + } } diff --git a/src/Migration/Validation/MigrationValidationService.php b/src/Migration/Validation/MigrationEntityValidationService.php similarity index 62% rename from src/Migration/Validation/MigrationValidationService.php rename to src/Migration/Validation/MigrationEntityValidationService.php index 79b3ba14c..474157dd4 100644 --- a/src/Migration/Validation/MigrationValidationService.php +++ b/src/Migration/Validation/MigrationEntityValidationService.php @@ -12,24 +12,14 @@ use Shopware\Core\Framework\Context; use Shopware\Core\Framework\DataAbstractionLayer\CompiledFieldCollection; use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry; -use Shopware\Core\Framework\DataAbstractionLayer\Field\AssociationField; use Shopware\Core\Framework\DataAbstractionLayer\Field\CreatedAtField; use Shopware\Core\Framework\DataAbstractionLayer\Field\Field; use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required; -use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToManyAssociationField; -use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField; -use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToManyAssociationField; -use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToOneAssociationField; use Shopware\Core\Framework\DataAbstractionLayer\Field\ReferenceVersionField; use Shopware\Core\Framework\DataAbstractionLayer\Field\StorageAware; use Shopware\Core\Framework\DataAbstractionLayer\Field\TranslationsAssociationField; use Shopware\Core\Framework\DataAbstractionLayer\Field\UpdatedAtField; use Shopware\Core\Framework\DataAbstractionLayer\Field\VersionField; -use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\WriteCommandQueue; -use Shopware\Core\Framework\DataAbstractionLayer\Write\DataStack\KeyValuePair; -use Shopware\Core\Framework\DataAbstractionLayer\Write\EntityExistence; -use Shopware\Core\Framework\DataAbstractionLayer\Write\WriteContext; -use Shopware\Core\Framework\DataAbstractionLayer\Write\WriteParameterBag; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Uuid\Uuid; use SwagMigrationAssistant\Migration\Logging\Log\Builder\MigrationLogBuilder; @@ -51,7 +41,7 @@ * @internal */ #[Package('fundamentals@after-sales')] -class MigrationValidationService implements ResetInterface +class MigrationEntityValidationService implements ResetInterface { /** * @var list> @@ -82,6 +72,7 @@ public function __construct( private readonly DefinitionInstanceRegistry $definitionRegistry, private readonly EventDispatcherInterface $eventDispatcher, private readonly LoggingServiceInterface $loggingService, + private readonly MigrationFieldValidationService $fieldValidationService, private readonly Connection $connection, ) { } @@ -95,7 +86,7 @@ public function reset(): void * @param array|null $convertedEntity * @param array $sourceData * - * @throws \Exception|Exception + * @throws \Exception */ public function validate( MigrationContextInterface $migrationContext, @@ -126,8 +117,12 @@ public function validate( new MigrationPreValidationEvent($validationContext), ); - $this->validateEntityStructure($validationContext); - $this->validateFieldValues($validationContext); + try { + $this->validateEntityStructure($validationContext); + $this->validateFieldValues($validationContext); + } catch (\Throwable $exception) { + $this->addExceptionLog($validationContext, $exception); + } $this->eventDispatcher->dispatch( new MigrationPostValidationEvent($validationContext), @@ -178,29 +173,24 @@ private function validateFieldValues(MigrationValidationContext $validationConte $entityDefinition = $validationContext->getEntityDefinition(); $entityName = $entityDefinition->getEntityName(); - $fields = $entityDefinition->getFields(); - - $entityExistence = EntityExistence::createForEntity($entityName, ['id' => $id]); - $parameters = new WriteParameterBag( - $entityDefinition, - WriteContext::createFromContext($validationContext->getContext()), - '', - new WriteCommandQueue(), - ); + $fields = $entityDefinition->getFields(); $requiredFields = $this->getRequiredFields($fields, $entityName); foreach ($convertedData as $fieldName => $value) { - $this->validateField( - $validationContext, - $fields, - $fieldName, - $value, - $id, - $entityExistence, - $parameters, - isset($requiredFields[$fieldName]) - ); + try { + $this->fieldValidationService->validateField( + $entityName, + $fieldName, + $value, + $validationContext->getContext(), + isset($requiredFields[$fieldName]) + ); + } catch (MigrationValidationException $exception) { + $this->addValidationExceptionLog($validationContext, $exception, $fieldName, $value, (string) $id); + } catch (\Throwable $exception) { + $this->addExceptionLog($validationContext, $exception); + } } } @@ -227,166 +217,6 @@ private function validateId(MigrationValidationContext $validationContext, mixed return true; } - private function validateField( - MigrationValidationContext $validationContext, - CompiledFieldCollection $fields, - string $fieldName, - mixed $value, - string $id, - EntityExistence $existence, - WriteParameterBag $parameters, - bool $isRequired, - ): void { - if (!$fields->has($fieldName)) { - return; - } - - $field = clone $fields->get($fieldName); - - try { - if ($field instanceof TranslationsAssociationField) { - $this->validateFieldByFieldSerializer($field, $value, $existence, $parameters, $isRequired); - - return; - } - - if ($field instanceof ManyToManyAssociationField || $field instanceof OneToManyAssociationField) { - $this->validateToManyAssociationStructure($validationContext, $fieldName, $value); - - return; - } - - if ($field instanceof ManyToOneAssociationField || $field instanceof OneToOneAssociationField) { - $this->validateToOneAssociationStructure($validationContext, $fieldName, $value); - - return; - } - - if ($field instanceof AssociationField) { - return; - } - - $this->validateFieldByFieldSerializer($field, $value, $existence, $parameters, $isRequired); - } catch (MigrationValidationException $exception) { - $this->addValidationExceptionLog($validationContext, $exception, $fieldName, $value, $id); - } catch (\Throwable $exception) { - $this->addExceptionLog($validationContext, $exception); - } - } - - /** - * @throws MigrationValidationException|\Exception - */ - private function validateFieldByFieldSerializer( - Field $field, - mixed $value, - EntityExistence $entityExistence, - WriteParameterBag $parameters, - bool $isRequired, - ): void { - /** - * Replace all flags with Required to force the serializer to validate this field. - * AbstractFieldSerializer::requiresValidation() skips validation for fields without Required flag. - * The field is cloned before this method is called to avoid mutating the original definition. - */ - $field->setFlags(new Required()); - - $keyValue = new KeyValuePair( - $field->getPropertyName(), - $value, - true - ); - - try { - $serializer = $field->getSerializer(); - - // Consume the generator to trigger validation. Keys are not needed - \iterator_to_array($serializer->encode( - $field, - $entityExistence, - $keyValue, - $parameters - ), false); - } catch (\Throwable $e) { - $entityName = $parameters->getDefinition()->getEntityName(); - $propertyName = $field->getPropertyName(); - - if ($field instanceof TranslationsAssociationField) { - throw MigrationValidationException::invalidTranslation($entityName, $propertyName, $e->getMessage()); - } - - if ($isRequired) { - throw MigrationValidationException::invalidRequiredFieldValue($entityName, $propertyName, $e->getMessage()); - } - - throw MigrationValidationException::invalidOptionalFieldValue($entityName, $propertyName, $e->getMessage()); - } - } - - /** - * @throws MigrationValidationException - */ - private function validateToManyAssociationStructure( - MigrationValidationContext $validationContext, - string $fieldName, - mixed $value, - ): void { - $entityName = $validationContext->getEntityDefinition()->getEntityName(); - - if (!\is_array($value)) { - throw MigrationValidationException::invalidAssociation( - $entityName, - $fieldName, - \sprintf('must be an array, got %s', \get_debug_type($value)) - ); - } - - foreach ($value as $index => $entry) { - if (!\is_array($entry)) { - throw MigrationValidationException::invalidAssociation( - $entityName, - $fieldName . '/' . $index, - \sprintf('entry at index %s must be an array, got %s', $index, \get_debug_type($entry)) - ); - } - - if (isset($entry['id']) && !Uuid::isValid($entry['id'])) { - throw MigrationValidationException::invalidAssociation( - $entityName, - $fieldName . '/' . $index . '/id', - \sprintf('invalid UUID "%s" at index %s', $entry['id'], $index) - ); - } - } - } - - /** - * @throws MigrationValidationException - */ - private function validateToOneAssociationStructure( - MigrationValidationContext $validationContext, - string $fieldName, - mixed $value, - ): void { - $entityName = $validationContext->getEntityDefinition()->getEntityName(); - - if (!\is_array($value)) { - throw MigrationValidationException::invalidAssociation( - $entityName, - $fieldName, - \sprintf('must be an array, got %s', \get_debug_type($value)) - ); - } - - if (isset($value['id']) && !Uuid::isValid($value['id'])) { - throw MigrationValidationException::invalidAssociation( - $entityName, - $fieldName . '/id', - \sprintf('invalid UUID "%s"', $value['id']) - ); - } - } - /** * @throws Exception * diff --git a/src/Migration/Validation/MigrationFieldValidationService.php b/src/Migration/Validation/MigrationFieldValidationService.php index df3079dac..9ce96363b 100644 --- a/src/Migration/Validation/MigrationFieldValidationService.php +++ b/src/Migration/Validation/MigrationFieldValidationService.php @@ -9,7 +9,14 @@ use Shopware\Core\Framework\Context; use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry; +use Shopware\Core\Framework\DataAbstractionLayer\Field\AssociationField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\Field; use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required; +use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToManyAssociationField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToManyAssociationField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToOneAssociationField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\TranslationsAssociationField; use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\WriteCommandQueue; use Shopware\Core\Framework\DataAbstractionLayer\Write\DataStack\KeyValuePair; use Shopware\Core\Framework\DataAbstractionLayer\Write\EntityExistence; @@ -17,8 +24,8 @@ use Shopware\Core\Framework\DataAbstractionLayer\Write\WriteParameterBag; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Uuid\Uuid; -use Shopware\Core\Framework\Validation\WriteConstraintViolationException; use SwagMigrationAssistant\Exception\MigrationException; +use SwagMigrationAssistant\Migration\Validation\Exception\MigrationValidationException; /** * @internal @@ -32,29 +39,31 @@ public function __construct( } /** - * Validates a field value using the DAL field serializer. - * - * @throws WriteConstraintViolationException|MigrationException|\Exception if the value is not valid + * @throws \Exception|MigrationException */ - public function validateFieldValue( + public function validateField( string $entityName, string $fieldName, mixed $value, Context $context, - ?string $entityId = null, + bool $isRequired = true, ): void { + if (!$this->definitionRegistry->has($entityName)) { + throw MigrationException::entityNotExists($entityName, $fieldName); + } + $entityDefinition = $this->definitionRegistry->getByEntityName($entityName); $fields = $entityDefinition->getFields(); if (!$fields->has($fieldName)) { - throw MigrationException::entityFieldNotFound($entityName, $fieldName); + throw MigrationValidationException::entityFieldNotFound($entityName, $fieldName); } - $field = $fields->get($fieldName); + $field = clone $fields->get($fieldName); - $entityExistence = EntityExistence::createForEntity( + $existence = EntityExistence::createForEntity( $entityDefinition->getEntityName(), - ['id' => $entityId ?? Uuid::randomHex()], + ['id' => Uuid::randomHex()], ); $parameters = new WriteParameterBag( @@ -64,16 +73,171 @@ public function validateFieldValue( new WriteCommandQueue(), ); - $field = clone $field; + if ($field instanceof TranslationsAssociationField) { + $this->validateTranslationAssociationStructure($field, $value, $entityName); + + return; + } + + if ($field instanceof ManyToManyAssociationField || $field instanceof OneToManyAssociationField) { + $this->validateToManyAssociationStructure($field, $value, $entityName); + + return; + } + + if ($field instanceof ManyToOneAssociationField || $field instanceof OneToOneAssociationField) { + $this->validateToOneAssociationStructure($field, $value, $entityName); + + return; + } + + if ($field instanceof AssociationField) { + return; + } + + $this->validateFieldByFieldSerializer( + $field, + $value, + $isRequired, + $existence, + $parameters, + ); + } + + private function validateFieldByFieldSerializer( + Field $field, + mixed $value, + bool $isRequired, + EntityExistence $existence, + WriteParameterBag $parameters, + ): void { + /** + * Replace all flags with Required to force the serializer to validate this field. + * AbstractFieldSerializer::requiresValidation() skips validation for fields without Required flag. + * The field is cloned before this method is called to avoid mutating the original definition. + */ $field->setFlags(new Required()); $keyValue = new KeyValuePair( $field->getPropertyName(), $value, - true, + true ); $serializer = $field->getSerializer(); - \iterator_to_array($serializer->encode($field, $entityExistence, $keyValue, $parameters), false); + + try { + // Consume the generator to trigger validation. Keys are not needed + \iterator_to_array($serializer->encode( + $field, + $existence, + $keyValue, + $parameters + ), false); + } catch (\Throwable $e) { + $entityName = $parameters->getDefinition()->getEntityName(); + $propertyName = $field->getPropertyName(); + + if ($field instanceof TranslationsAssociationField) { + throw MigrationValidationException::invalidTranslation( + $entityName, + $propertyName, + $e, + ); + } + + if ($isRequired) { + throw MigrationValidationException::invalidRequiredFieldValue( + $entityName, + $propertyName, + $e + ); + } + + throw MigrationValidationException::invalidOptionalFieldValue( + $entityName, + $propertyName, + $e + ); + } + } + + private function validateToManyAssociationStructure(Field $field, mixed $value, string $entityName): void + { + if (!\is_array($value)) { + throw MigrationValidationException::invalidAssociation( + $entityName, + $field->getPropertyName(), + \sprintf('must be an array, got %s', \get_debug_type($value)) + ); + } + + foreach ($value as $index => $entry) { + if (!\is_array($entry)) { + throw MigrationValidationException::invalidAssociation( + $entityName, + $field->getPropertyName() . '/' . $index, + \sprintf('entry at index %s must be an array, got %s', $index, \get_debug_type($entry)) + ); + } + + if (isset($entry['id']) && !Uuid::isValid($entry['id'])) { + throw MigrationValidationException::invalidAssociation( + $entityName, + $field->getPropertyName() . '/' . $index . '/id', + \sprintf('invalid UUID "%s" at index %s', $entry['id'], $index) + ); + } + } + } + + private function validateToOneAssociationStructure(Field $field, mixed $value, string $entityName): void + { + if (!\is_array($value)) { + throw MigrationValidationException::invalidAssociation( + $entityName, + $field->getPropertyName(), + \sprintf('must be an array, got %s', \get_debug_type($value)) + ); + } + + if (isset($value['id']) && !Uuid::isValid($value['id'])) { + throw MigrationValidationException::invalidAssociation( + $entityName, + $field->getPropertyName() . '/id', + \sprintf('invalid UUID "%s"', $value['id']) + ); + } + } + + private function validateTranslationAssociationStructure(TranslationsAssociationField $field, mixed $value, string $entityName): void + { + if (!\is_array($value)) { + throw MigrationValidationException::invalidTranslation( + $entityName, + $field->getPropertyName(), + new \InvalidArgumentException(\sprintf('must be an array, got %s', \get_debug_type($value))) + ); + } + + foreach ($value as $languageId => $translation) { + // Language key must be a string (UUID or locale code like 'en-GB') + if (!\is_string($languageId) || $languageId === '') { + throw MigrationValidationException::invalidTranslation( + $entityName, + $field->getPropertyName(), + new \InvalidArgumentException(\sprintf('language key must be a non-empty string, got %s', \get_debug_type($languageId))) + ); + } + + // Each translation entry must be an array + if (!\is_array($translation)) { + throw MigrationValidationException::invalidTranslation( + $entityName, + $field->getPropertyName() . '/' . $languageId, + new \InvalidArgumentException(\sprintf('translation entry must be an array, got %s', \get_debug_type($translation))) + ); + } + } } } diff --git a/tests/Migration/Services/MigrationDataConverterTest.php b/tests/Migration/Services/MigrationDataConverterTest.php index a5632703a..92c6e3b3d 100644 --- a/tests/Migration/Services/MigrationDataConverterTest.php +++ b/tests/Migration/Services/MigrationDataConverterTest.php @@ -19,7 +19,7 @@ use SwagMigrationAssistant\Migration\Media\MediaFileServiceInterface; use SwagMigrationAssistant\Migration\MigrationContextInterface; use SwagMigrationAssistant\Migration\Service\MigrationDataConverter; -use SwagMigrationAssistant\Migration\Validation\MigrationValidationService; +use SwagMigrationAssistant\Migration\Validation\MigrationEntityValidationService; use SwagMigrationAssistant\Test\Mock\DataSet\DataSetMock; use SwagMigrationAssistant\Test\Mock\Migration\Logging\DummyLoggingService; @@ -60,7 +60,7 @@ private function createMigrationDataConverter( ?LoggingServiceInterface $loggingService = null, ?EntityDefinition $dataDefinition = null, ?MappingServiceInterface $mappingService = null, - ?MigrationValidationService $validationService = null, + ?MigrationEntityValidationService $validationService = null, ): MigrationDataConverter { if ($entityWriter === null) { $entityWriter = $this->createMock(EntityWriter::class); @@ -87,7 +87,7 @@ private function createMigrationDataConverter( } if ($validationService === null) { - $validationService = $this->createMock(MigrationValidationService::class); + $validationService = $this->createMock(MigrationEntityValidationService::class); } return new MigrationDataConverter( diff --git a/tests/MigrationServicesTrait.php b/tests/MigrationServicesTrait.php index 92d228953..25cfc6f03 100644 --- a/tests/MigrationServicesTrait.php +++ b/tests/MigrationServicesTrait.php @@ -54,7 +54,8 @@ use SwagMigrationAssistant\Migration\Service\MigrationDataConverterInterface; use SwagMigrationAssistant\Migration\Service\MigrationDataFetcher; use SwagMigrationAssistant\Migration\Service\MigrationDataFetcherInterface; -use SwagMigrationAssistant\Migration\Validation\MigrationValidationService; +use SwagMigrationAssistant\Migration\Validation\MigrationEntityValidationService; +use SwagMigrationAssistant\Migration\Validation\MigrationFieldValidationService; use SwagMigrationAssistant\Profile\Shopware\Gateway\Api\Reader\EnvironmentReader; use SwagMigrationAssistant\Profile\Shopware\Gateway\Api\Reader\TableCountReader; use SwagMigrationAssistant\Profile\Shopware\Gateway\Api\Reader\TableReader; @@ -208,10 +209,11 @@ protected function getMigrationDataConverter( ) ); - $validationService = new MigrationValidationService( + $validationService = new MigrationEntityValidationService( $this->getContainer()->get(DefinitionInstanceRegistry::class), $this->getContainer()->get('event_dispatcher'), $loggingService, + $this->getContainer()->get(MigrationFieldValidationService::class), $this->getContainer()->get(Connection::class), ); diff --git a/tests/integration/Migration/Controller/ErrorResolutionControllerTest.php b/tests/integration/Migration/Controller/ErrorResolutionControllerTest.php index 79e458e1a..49198265b 100644 --- a/tests/integration/Migration/Controller/ErrorResolutionControllerTest.php +++ b/tests/integration/Migration/Controller/ErrorResolutionControllerTest.php @@ -16,6 +16,7 @@ use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; use SwagMigrationAssistant\Controller\ErrorResolutionController; use SwagMigrationAssistant\Exception\MigrationException; +use SwagMigrationAssistant\Migration\Validation\Exception\MigrationValidationException; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -62,7 +63,7 @@ public function testGetFieldStructureUnsetFieldName(): void public function testGetFieldStructureUnknownField(): void { - static::expectExceptionObject(MigrationException::entityFieldNotFound('product', 'unknownField')); + static::expectExceptionObject(MigrationValidationException::entityFieldNotFound('product', 'unknownField')); $request = new Request([], [ 'entityName' => 'product', diff --git a/tests/integration/Migration/Validation/MigrationValidationServiceTest.php b/tests/integration/Migration/Validation/MigrationEntityValidationServiceTest.php similarity index 98% rename from tests/integration/Migration/Validation/MigrationValidationServiceTest.php rename to tests/integration/Migration/Validation/MigrationEntityValidationServiceTest.php index 3ea51f83b..d590dcf29 100644 --- a/tests/integration/Migration/Validation/MigrationValidationServiceTest.php +++ b/tests/integration/Migration/Validation/MigrationEntityValidationServiceTest.php @@ -34,8 +34,8 @@ use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidRequiredFieldValueLog; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidRequiredTranslation; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationMissingRequiredFieldLog; +use SwagMigrationAssistant\Migration\Validation\MigrationEntityValidationService; use SwagMigrationAssistant\Migration\Validation\MigrationValidationResult; -use SwagMigrationAssistant\Migration\Validation\MigrationValidationService; use SwagMigrationAssistant\Profile\Shopware54\Shopware54Profile; use SwagMigrationAssistant\Test\Mock\Gateway\Dummy\Local\DummyLocalGateway; @@ -43,8 +43,8 @@ * @internal */ #[Package('fundamentals@after-sales')] -#[CoversClass(MigrationValidationService::class)] -class MigrationValidationServiceTest extends TestCase +#[CoversClass(MigrationEntityValidationService::class)] +class MigrationEntityValidationServiceTest extends TestCase { use IntegrationTestBehaviour; @@ -52,7 +52,7 @@ class MigrationValidationServiceTest extends TestCase private MigrationContext $migrationContext; - private MigrationValidationService $validationService; + private MigrationEntityValidationService $validationService; /** * @var EntityRepository @@ -75,7 +75,7 @@ class MigrationValidationServiceTest extends TestCase protected function setUp(): void { - $this->validationService = static::getContainer()->get(MigrationValidationService::class); + $this->validationService = static::getContainer()->get(MigrationEntityValidationService::class); $this->loggingRepo = static::getContainer()->get('swag_migration_logging.repository'); $this->runRepo = static::getContainer()->get('swag_migration_run.repository'); $this->mappingRepo = static::getContainer()->get(SwagMigrationMappingDefinition::ENTITY_NAME . '.repository'); diff --git a/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php b/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php index fd582ce4a..d4a331ca5 100644 --- a/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php +++ b/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php @@ -11,11 +11,10 @@ use PHPUnit\Framework\TestCase; use Shopware\Core\Defaults; use Shopware\Core\Framework\Context; -use Shopware\Core\Framework\DataAbstractionLayer\DataAbstractionLayerException; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; -use Shopware\Core\Framework\Validation\WriteConstraintViolationException; use SwagMigrationAssistant\Exception\MigrationException; +use SwagMigrationAssistant\Migration\Validation\Exception\MigrationValidationException; use SwagMigrationAssistant\Migration\Validation\MigrationFieldValidationService; /** @@ -36,9 +35,9 @@ protected function setUp(): void public function testNotExistingEntityDefinition(): void { - static::expectExceptionObject(DataAbstractionLayerException::definitionNotFound('test')); + static::expectExceptionObject(MigrationException::entityNotExists('test', 'field')); - $this->migrationFieldValidationService->validateFieldValue( + $this->migrationFieldValidationService->validateField( 'test', 'field', 'value', @@ -48,9 +47,9 @@ public function testNotExistingEntityDefinition(): void public function testNotExistingField(): void { - static::expectExceptionObject(MigrationException::entityFieldNotFound('product', 'nonExistingField')); + static::expectExceptionObject(MigrationValidationException::entityFieldNotFound('product', 'nonExistingField')); - $this->migrationFieldValidationService->validateFieldValue( + $this->migrationFieldValidationService->validateField( 'product', 'nonExistingField', 'value', @@ -62,7 +61,7 @@ public function testValidPriceField(): void { static::expectNotToPerformAssertions(); - $this->migrationFieldValidationService->validateFieldValue( + $this->migrationFieldValidationService->validateField( 'product', 'price', [ @@ -79,9 +78,9 @@ public function testValidPriceField(): void public function testInvalidPriceFieldGrossType(): void { - static::expectException(WriteConstraintViolationException::class); + static::expectException(MigrationValidationException::class); - $this->migrationFieldValidationService->validateFieldValue( + $this->migrationFieldValidationService->validateField( 'product', 'price', [ @@ -98,9 +97,9 @@ public function testInvalidPriceFieldGrossType(): void public function testInvalidPriceFieldMissingNet(): void { - static::expectException(WriteConstraintViolationException::class); + static::expectException(MigrationValidationException::class); - $this->migrationFieldValidationService->validateFieldValue( + $this->migrationFieldValidationService->validateField( 'product', 'price', [ @@ -117,9 +116,9 @@ public function testInvalidPriceFieldMissingNet(): void public function testInvalidPriceFieldCurrencyId(): void { - static::expectException(WriteConstraintViolationException::class); + static::expectException(MigrationValidationException::class); - $this->migrationFieldValidationService->validateFieldValue( + $this->migrationFieldValidationService->validateField( 'product', 'price', [ From 0de2bed1e076a80f7436a07235ed72e651e50ded Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Wed, 7 Jan 2026 15:01:09 +0100 Subject: [PATCH 18/26] fix: typo --- .../swag-migration-error-resolution-field-scalar.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar.spec.js b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar.spec.js index 65c4cb8bc..432697e31 100644 --- a/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar.spec.js +++ b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar.spec.js @@ -10,7 +10,7 @@ const updateFieldValueMock = jest.fn(); const defaultProps = { componentType: 'text', - entityNAme: 'customer', + entityName: 'customer', entityField: { entity: 'customer', type: 'string', From f29f32913f876c83337658aace30e61a5245584e Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Thu, 8 Jan 2026 14:53:27 +0100 Subject: [PATCH 19/26] fix: card border --- .../ErrorResolution/MigrationErrorResolutionService.php | 4 ++++ .../swag-migration-shop-information.html.twig | 1 + .../swag-migration-shop-information.scss | 4 ++++ 3 files changed, 9 insertions(+) diff --git a/src/Migration/ErrorResolution/MigrationErrorResolutionService.php b/src/Migration/ErrorResolution/MigrationErrorResolutionService.php index 206ac330a..b864a5f67 100644 --- a/src/Migration/ErrorResolution/MigrationErrorResolutionService.php +++ b/src/Migration/ErrorResolution/MigrationErrorResolutionService.php @@ -44,6 +44,10 @@ public function applyFixes(array &$data, string $connectionId, string $runId, Co $this->loadFixes($errorResolutionContext); + if (empty($errorResolutionContext->getFixes())) { + return; + } + $this->eventDispatcher->dispatch( new MigrationPreErrorResolutionEvent($errorResolutionContext), ); diff --git a/src/Resources/app/administration/src/module/swag-migration/component/card/swag-migration-shop-information/swag-migration-shop-information.html.twig b/src/Resources/app/administration/src/module/swag-migration/component/card/swag-migration-shop-information/swag-migration-shop-information.html.twig index eaeb1607a..6f8917bce 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/card/swag-migration-shop-information/swag-migration-shop-information.html.twig +++ b/src/Resources/app/administration/src/module/swag-migration/component/card/swag-migration-shop-information/swag-migration-shop-information.html.twig @@ -229,6 +229,7 @@ diff --git a/src/Resources/app/administration/src/module/swag-migration/component/card/swag-migration-shop-information/swag-migration-shop-information.scss b/src/Resources/app/administration/src/module/swag-migration/component/card/swag-migration-shop-information/swag-migration-shop-information.scss index 0977488f0..9458620d0 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/card/swag-migration-shop-information/swag-migration-shop-information.scss +++ b/src/Resources/app/administration/src/module/swag-migration/component/card/swag-migration-shop-information/swag-migration-shop-information.scss @@ -11,6 +11,10 @@ align-items: center; } + &__more-information { + border-radius: var(--border-radius-card); + } + .swag-migration-shop-information__header-right { display: flex; align-items: center; From 6b220872422cd408afe93aece2445e93d432dda0 Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Wed, 21 Jan 2026 08:49:29 +0100 Subject: [PATCH 20/26] feat: add nested field validation --- src/Controller/ErrorResolutionController.php | 6 + src/DependencyInjection/migration.xml | 3 + .../MigrationEntityValidationService.php | 123 +++++++++- .../MigrationFieldValidationService.php | 86 +++---- .../Validation/MigrationValidationResult.php | 21 +- ...swag-migration-error-resolution.service.ts | 232 ++++++++++-------- ...migration-error-resolution.service.spec.js | 85 ++++++- 7 files changed, 388 insertions(+), 168 deletions(-) diff --git a/src/Controller/ErrorResolutionController.php b/src/Controller/ErrorResolutionController.php index 1e5f4335f..44affb230 100644 --- a/src/Controller/ErrorResolutionController.php +++ b/src/Controller/ErrorResolutionController.php @@ -9,6 +9,8 @@ use Shopware\Core\Framework\Context; use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry; +use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition; +use Shopware\Core\Framework\DataAbstractionLayer\Write\EntityWriterInterface; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Routing\ApiRouteScope; use Shopware\Core\Framework\Validation\WriteConstraintViolationException; @@ -16,6 +18,7 @@ use SwagMigrationAssistant\Exception\MigrationException; use SwagMigrationAssistant\Migration\ErrorResolution\MigrationFieldExampleGenerator; use SwagMigrationAssistant\Migration\Validation\Exception\MigrationValidationException; +use SwagMigrationAssistant\Migration\Validation\MigrationEntityValidationService; use SwagMigrationAssistant\Migration\Validation\MigrationFieldValidationService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -32,6 +35,9 @@ class ErrorResolutionController extends AbstractController public function __construct( private readonly DefinitionInstanceRegistry $definitionRegistry, private readonly MigrationFieldValidationService $fieldValidationService, + private readonly MigrationEntityValidationService $entityValidationService, + private readonly EntityWriterInterface $entityWriter, + private readonly EntityDefinition $definition, ) { } diff --git a/src/DependencyInjection/migration.xml b/src/DependencyInjection/migration.xml index 40fae385e..7e1200226 100644 --- a/src/DependencyInjection/migration.xml +++ b/src/DependencyInjection/migration.xml @@ -298,6 +298,9 @@ + + + diff --git a/src/Migration/Validation/MigrationEntityValidationService.php b/src/Migration/Validation/MigrationEntityValidationService.php index 474157dd4..3c8a7b971 100644 --- a/src/Migration/Validation/MigrationEntityValidationService.php +++ b/src/Migration/Validation/MigrationEntityValidationService.php @@ -12,9 +12,14 @@ use Shopware\Core\Framework\Context; use Shopware\Core\Framework\DataAbstractionLayer\CompiledFieldCollection; use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry; +use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition; use Shopware\Core\Framework\DataAbstractionLayer\Field\CreatedAtField; use Shopware\Core\Framework\DataAbstractionLayer\Field\Field; use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required; +use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToManyAssociationField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToManyAssociationField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToOneAssociationField; use Shopware\Core\Framework\DataAbstractionLayer\Field\ReferenceVersionField; use Shopware\Core\Framework\DataAbstractionLayer\Field\StorageAware; use Shopware\Core\Framework\DataAbstractionLayer\Field\TranslationsAssociationField; @@ -178,6 +183,13 @@ private function validateFieldValues(MigrationValidationContext $validationConte $requiredFields = $this->getRequiredFields($fields, $entityName); foreach ($convertedData as $fieldName => $value) { + $field = $fields->get($fieldName); + + // Recursively validate nested association entities + if ($field !== null && $value !== null) { + $this->validateNestedAssociations($validationContext, $field, $fieldName, $value); + } + try { $this->fieldValidationService->validateField( $entityName, @@ -187,7 +199,111 @@ private function validateFieldValues(MigrationValidationContext $validationConte isset($requiredFields[$fieldName]) ); } catch (MigrationValidationException $exception) { - $this->addValidationExceptionLog($validationContext, $exception, $fieldName, $value, (string) $id); + $this->addValidationExceptionLog($validationContext, $exception, $entityName, $fieldName, $value, (string) $id); + } catch (\Throwable $exception) { + $this->addExceptionLog($validationContext, $exception); + } + } + } + + /** + * Recursively validates nested entities within association fields. + * + * @param array|mixed $value + */ + private function validateNestedAssociations( + MigrationValidationContext $validationContext, + Field $field, + string $fieldPath, + mixed $value, + ): void { + if (!\is_array($value)) { + return; + } + + // Skip translations, they have special structure and are system-managed + if ($field instanceof TranslationsAssociationField) { + return; + } + + if ($field instanceof OneToManyAssociationField || $field instanceof ManyToManyAssociationField) { + $referenceDefinition = $field instanceof ManyToManyAssociationField + ? $field->getToManyReferenceDefinition() + : $field->getReferenceDefinition(); + + foreach ($value as $nestedEntityData) { + $this->validateNestedEntityData( + $validationContext, + $referenceDefinition, + $nestedEntityData, + $fieldPath + ); + } + + return; + } + + if ($field instanceof ManyToOneAssociationField || $field instanceof OneToOneAssociationField) { + $this->validateNestedEntityData( + $validationContext, + $field->getReferenceDefinition(), + $value, + $fieldPath + ); + } + } + + /** + * Validates a single nested entity's fields and recurses into deeper associations. + * + * @param array|mixed $nestedEntityData + */ + private function validateNestedEntityData( + MigrationValidationContext $validationContext, + EntityDefinition $referenceDefinition, + mixed $nestedEntityData, + string $fieldPath, + ): void { + if (!\is_array($nestedEntityData)) { + return; + } + + // Skip ID-only references (linking existing entities, not creating new ones) + if (\count($nestedEntityData) === 1 && isset($nestedEntityData['id'])) { + return; + } + + $nestedEntityName = $referenceDefinition->getEntityName(); + $fields = $referenceDefinition->getFields(); + $requiredFields = $this->getRequiredFields($fields, $nestedEntityName); + + $rootEntityName = $validationContext->getEntityDefinition()->getEntityName(); + $rootEntityId = $validationContext->getConvertedData()['id'] ?? null; + $rootEntityId = $rootEntityId !== null ? (string) $rootEntityId : null; + + foreach ($nestedEntityData as $fieldName => $value) { + // Skip 'id' field, already validated by association structure check + if ($fieldName === 'id') { + continue; + } + + $field = $fields->get($fieldName); + $nestedFieldPath = $fieldPath . '.' . $fieldName; + + if ($field !== null && $value !== null) { + $this->validateNestedAssociations($validationContext, $field, $nestedFieldPath, $value); + } + + try { + $this->fieldValidationService->validateField( + $nestedEntityName, + $fieldName, + $value, + $validationContext->getContext(), + isset($requiredFields[$fieldName]) + ); + } catch (MigrationValidationException $exception) { + $this->addValidationExceptionLog($validationContext, $exception, $rootEntityName, $nestedFieldPath, $value, $rootEntityId); } catch (\Throwable $exception) { $this->addExceptionLog($validationContext, $exception); } @@ -290,9 +406,10 @@ private function addMissingRequiredFieldLog(MigrationValidationContext $validati private function addValidationExceptionLog( MigrationValidationContext $validationContext, MigrationValidationException $exception, + string $entityName, string $fieldName, mixed $value, - string $entityId, + ?string $entityId, ): void { $logClass = match ($exception->getErrorCode()) { MigrationValidationException::VALIDATION_INVALID_ASSOCIATION => MigrationValidationInvalidAssociationLog::class, @@ -304,7 +421,7 @@ private function addValidationExceptionLog( $validationContext->getValidationResult()->addLog( MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) - ->withEntityName($validationContext->getEntityDefinition()->getEntityName()) + ->withEntityName($entityName) ->withFieldName($fieldName) ->withConvertedData([$fieldName => $value]) ->withSourceData($validationContext->getSourceData()) diff --git a/src/Migration/Validation/MigrationFieldValidationService.php b/src/Migration/Validation/MigrationFieldValidationService.php index 9ce96363b..2822c8ee6 100644 --- a/src/Migration/Validation/MigrationFieldValidationService.php +++ b/src/Migration/Validation/MigrationFieldValidationService.php @@ -9,6 +9,7 @@ use Shopware\Core\Framework\Context; use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry; +use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition; use Shopware\Core\Framework\DataAbstractionLayer\Field\AssociationField; use Shopware\Core\Framework\DataAbstractionLayer\Field\Field; use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required; @@ -24,7 +25,6 @@ use Shopware\Core\Framework\DataAbstractionLayer\Write\WriteParameterBag; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Uuid\Uuid; -use SwagMigrationAssistant\Exception\MigrationException; use SwagMigrationAssistant\Migration\Validation\Exception\MigrationValidationException; /** @@ -39,7 +39,10 @@ public function __construct( } /** - * @throws \Exception|MigrationException + * Validates a single field value against its entity definition. + * Silently skips validation for unknown entities or fields. + * + * @throws MigrationValidationException */ public function validateField( string $entityName, @@ -49,7 +52,7 @@ public function validateField( bool $isRequired = true, ): void { if (!$this->definitionRegistry->has($entityName)) { - throw MigrationException::entityNotExists($entityName, $fieldName); + return; } $entityDefinition = $this->definitionRegistry->getByEntityName($entityName); @@ -61,18 +64,20 @@ public function validateField( $field = clone $fields->get($fieldName); - $existence = EntityExistence::createForEntity( - $entityDefinition->getEntityName(), - ['id' => Uuid::randomHex()], - ); + if ($field instanceof AssociationField) { + $this->validateAssociationStructure($field, $value, $entityName); - $parameters = new WriteParameterBag( - $entityDefinition, - WriteContext::createFromContext($context), - '', - new WriteCommandQueue(), - ); + return; + } + + $this->validateScalarField($field, $value, $isRequired, $entityDefinition, $context); + } + /** + * Validates the structure of an association field value (not its nested content). + */ + private function validateAssociationStructure(AssociationField $field, mixed $value, string $entityName): void + { if ($field instanceof TranslationsAssociationField) { $this->validateTranslationAssociationStructure($field, $value, $entityName); @@ -87,21 +92,32 @@ public function validateField( if ($field instanceof ManyToOneAssociationField || $field instanceof OneToOneAssociationField) { $this->validateToOneAssociationStructure($field, $value, $entityName); - - return; } + } - if ($field instanceof AssociationField) { - return; - } + /** + * Validates a scalar (non-association) field using its serializer. + */ + private function validateScalarField( + Field $field, + mixed $value, + bool $isRequired, + EntityDefinition $entityDefinition, + Context $context, + ): void { + $existence = EntityExistence::createForEntity( + $entityDefinition->getEntityName(), + ['id' => Uuid::randomHex()], + ); - $this->validateFieldByFieldSerializer( - $field, - $value, - $isRequired, - $existence, - $parameters, + $parameters = new WriteParameterBag( + $entityDefinition, + WriteContext::createFromContext($context), + '', + new WriteCommandQueue(), ); + + $this->validateFieldByFieldSerializer($field, $value, $isRequired, $existence, $parameters); } private function validateFieldByFieldSerializer( @@ -176,7 +192,7 @@ private function validateToManyAssociationStructure(Field $field, mixed $value, if (!\is_array($entry)) { throw MigrationValidationException::invalidAssociation( $entityName, - $field->getPropertyName() . '/' . $index, + $field->getPropertyName(), \sprintf('entry at index %s must be an array, got %s', $index, \get_debug_type($entry)) ); } @@ -184,7 +200,7 @@ private function validateToManyAssociationStructure(Field $field, mixed $value, if (isset($entry['id']) && !Uuid::isValid($entry['id'])) { throw MigrationValidationException::invalidAssociation( $entityName, - $field->getPropertyName() . '/' . $index . '/id', + $field->getPropertyName() . '.id', \sprintf('invalid UUID "%s" at index %s', $entry['id'], $index) ); } @@ -204,7 +220,7 @@ private function validateToOneAssociationStructure(Field $field, mixed $value, s if (isset($value['id']) && !Uuid::isValid($value['id'])) { throw MigrationValidationException::invalidAssociation( $entityName, - $field->getPropertyName() . '/id', + $field->getPropertyName() . '.id', \sprintf('invalid UUID "%s"', $value['id']) ); } @@ -216,26 +232,14 @@ private function validateTranslationAssociationStructure(TranslationsAssociation throw MigrationValidationException::invalidTranslation( $entityName, $field->getPropertyName(), - new \InvalidArgumentException(\sprintf('must be an array, got %s', \get_debug_type($value))) ); } - foreach ($value as $languageId => $translation) { - // Language key must be a string (UUID or locale code like 'en-GB') - if (!\is_string($languageId) || $languageId === '') { - throw MigrationValidationException::invalidTranslation( - $entityName, - $field->getPropertyName(), - new \InvalidArgumentException(\sprintf('language key must be a non-empty string, got %s', \get_debug_type($languageId))) - ); - } - - // Each translation entry must be an array + foreach ($value as $key => $translation) { if (!\is_array($translation)) { throw MigrationValidationException::invalidTranslation( $entityName, - $field->getPropertyName() . '/' . $languageId, - new \InvalidArgumentException(\sprintf('translation entry must be an array, got %s', \get_debug_type($translation))) + $field->getPropertyName() . '.' . $key, ); } } diff --git a/src/Migration/Validation/MigrationValidationResult.php b/src/Migration/Validation/MigrationValidationResult.php index 48fc13147..940e65f88 100644 --- a/src/Migration/Validation/MigrationValidationResult.php +++ b/src/Migration/Validation/MigrationValidationResult.php @@ -8,6 +8,7 @@ namespace SwagMigrationAssistant\Migration\Validation; use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Util\Hasher; use SwagMigrationAssistant\Migration\Logging\Log\Builder\MigrationLogEntry; /** @@ -17,17 +18,19 @@ class MigrationValidationResult { /** - * @param MigrationLogEntry[] $logs + * @var array */ + private array $logs = []; + public function __construct( private readonly string $entityName, - private array $logs = [], ) { } public function addLog(MigrationLogEntry $log): void { - $this->logs[] = $log; + $key = $this->createLogKey($log); + $this->logs[$key] = $log; } /** @@ -35,7 +38,17 @@ public function addLog(MigrationLogEntry $log): void */ public function getLogs(): array { - return $this->logs; + return \array_values($this->logs); + } + + private function createLogKey(MigrationLogEntry $log): string + { + return Hasher::hash(\implode('.', [ + $log->getCode(), + $log->getEntityName() ?? '', + $log->getEntityId() ?? '', + $log->getFieldName() ?? '', + ])); } public function hasLogs(): bool diff --git a/src/Resources/app/administration/src/module/swag-migration/service/swag-migration-error-resolution.service.ts b/src/Resources/app/administration/src/module/swag-migration/service/swag-migration-error-resolution.service.ts index 2d1885069..107939be9 100644 --- a/src/Resources/app/administration/src/module/swag-migration/service/swag-migration-error-resolution.service.ts +++ b/src/Resources/app/administration/src/module/swag-migration/service/swag-migration-error-resolution.service.ts @@ -58,7 +58,6 @@ export const UNHANDLED_FIELD_TYPES = [ export const UNHANDLED_FIELD_NAMES = [ 'id', 'autoIncrement', - 'translated', ] as const; /** @@ -172,6 +171,16 @@ export const PRIORITY_FIELD_MAP: Map = new Map( */ export const CONTENT_TEXT_MAX_LENGTH = 100; +/** + * @private + * result of resolving a nested field path. + */ +export interface ResolvedFieldPath { + schema: EntityDefinition; + property: Property; + fieldName: string; +} + /** * @private */ @@ -291,29 +300,68 @@ export default class SwagMigrationErrorResolutionService { /** * gets the entity schema for a given entity name. */ - getEntitySchema(entityName: string | null | undefined): EntityDefinition { - if (entityName && Shopware.EntityDefinition.has(entityName)) { - return Shopware.EntityDefinition.get(entityName); + getEntitySchema(entityName: string | null | undefined): EntityDefinition | null { + return entityName && Shopware.EntityDefinition.has(entityName) + ? Shopware.EntityDefinition.get(entityName) + : null; + } + + /** + * resolves a potentially nested field path (e.g., "prices.shippingMethodId") to its target. + * traverses through association fields to find the final schema and property. + */ + resolveFieldPath(entityName: string | null | undefined, fieldPath: string | null | undefined): ResolvedFieldPath | null { + if (!fieldPath || !entityName) { + return null; } - return null; + const initialSchema = this.getEntitySchema(entityName); + + if (!initialSchema) { + return null; + } + + const paths = fieldPath.split('.'); + + return paths.reduce<{ schema: EntityDefinition; result: ResolvedFieldPath | null } | null>( + (acc, path, index) => { + if (!acc || acc.result) { + return acc; + } + + const property = acc.schema.getField(path); + + if (!property) { + return null; + } + + if (index === paths.length - 1) { + return { ...acc, result: { schema: acc.schema, property, fieldName: path } }; + } + + if (property.type !== DATA_TYPES.ASSOCIATION || !property.entity) { + return null; + } + + const nextSchema = this.getEntitySchema(property.entity); + + return nextSchema ? { schema: nextSchema, result: null } : null; + }, + { schema: initialSchema, result: null }, + )?.result ?? null; } /** * gets the entity field definition for a specific field. */ getEntityField(entityName: string | null | undefined, fieldName: string | null | undefined): Property | null { - if (!fieldName || (UNHANDLED_FIELD_NAMES as readonly string[]).includes(fieldName)) { - return null; - } - - const schema = this.getEntitySchema(entityName); + const lastFieldName = fieldName?.split('.').pop(); - if (!schema) { + if (!lastFieldName || (UNHANDLED_FIELD_NAMES as readonly string[]).includes(lastFieldName)) { return null; } - return schema.getField(fieldName) ?? null; + return this.resolveFieldPath(entityName, fieldName)?.property ?? null; } /** @@ -324,69 +372,54 @@ export default class SwagMigrationErrorResolutionService { entityName: string | null | undefined, fieldName: string | null | undefined, ): Property | null { - const schema = this.getEntitySchema(entityName); - const entityField = this.getEntityField(entityName, fieldName); + const resolved = this.resolveFieldPath(entityName, fieldName); - if (!schema || !entityField || !fieldName) { + if (!resolved) { return null; } - // only id fields can have corresponding association fields - if (entityField.type !== DATA_TYPES.UUID) { - return null; - } + const { schema, property, fieldName: actualFieldName } = resolved; - // primary key fields do not have corresponding association fields - if (entityField.flags?.primary_key === true) { + if (property.type !== DATA_TYPES.UUID || property.flags?.primary_key) { return null; } - // try to find association field by checking all fields for matching localField - let associationField = Object.values(schema.properties).find( - (property) => - property.type === DATA_TYPES.ASSOCIATION && - (property as Property & { localField?: string }).localField === fieldName, + const byLocalField = Object.values(schema.properties).find( + (prop) => prop.type === DATA_TYPES.ASSOCIATION && + (prop as Property & { localField?: string }).localField === actualFieldName, ); + if (byLocalField) { + return byLocalField; + } + const versionIdSuffix = 'VersionId'; - // fallback: try to infer association name from field name - // example: "productVersionId" -> "product" - if (!associationField && fieldName.endsWith(versionIdSuffix) && fieldName !== 'versionId') { - const inferredName = fieldName.slice(0, -versionIdSuffix.length); - const inferredField = schema.getField(inferredName); + if (actualFieldName.endsWith(versionIdSuffix) && actualFieldName !== 'versionId') { + const inferredField = schema.getField(actualFieldName.slice(0, -versionIdSuffix.length)); if (inferredField?.type === DATA_TYPES.ASSOCIATION) { - associationField = inferredField; + return inferredField; } } - return associationField; + return null; } /** * determines if a field is unhandled (not recognized or unsupported). */ isUnhandledField(entityName: string | null | undefined, fieldName: string | null | undefined): boolean { - if (!entityName) { - return true; - } - - if (!Shopware.EntityDefinition.has(entityName)) { + if (!entityName || !Shopware.EntityDefinition.has(entityName)) { return true; } const entityField = this.getEntityField(entityName, fieldName); - if (!entityField) { - return true; - } - - if ((UNHANDLED_FIELD_NAMES as readonly string[]).includes(entityField.type)) { + if (!entityField || (UNHANDLED_FIELD_TYPES as readonly string[]).includes(entityField.type)) { return true; } - // field type is not supported return this.getFieldType(entityName, fieldName) === null; } @@ -396,15 +429,10 @@ export default class SwagMigrationErrorResolutionService { isScalarField(entityName: string | null | undefined, fieldName: string | null | undefined): boolean { const entityField = this.getEntityField(entityName, fieldName); - if (!entityField) { - return false; - } - - if (entityField.type === DATA_TYPES.ASSOCIATION) { + if (!entityField || entityField.type === DATA_TYPES.ASSOCIATION) { return false; } - // id fields with corresponding association fields are treated as relation fields const correspondingAssociation = this.findCorrespondingAssociationField(entityName, fieldName); return !(entityField.type === DATA_TYPES.UUID && correspondingAssociation); @@ -412,16 +440,12 @@ export default class SwagMigrationErrorResolutionService { /** * checks if a field is a "to many" association (one_to_many or many_to_many). + * supports nested field paths like "prices.shippingMethodId". */ isToManyAssociationField(entityName: string | null | undefined, fieldName: string | null | undefined): boolean { - const schema = this.getEntitySchema(entityName); - const entityField = this.getEntityField(entityName, fieldName); + const resolved = this.resolveFieldPath(entityName, fieldName); - if (!schema || !entityField) { - return false; - } - - return schema.isToManyAssociation(entityField); + return resolved ? resolved.schema.isToManyAssociation(resolved.property) : false; } /** @@ -429,13 +453,8 @@ export default class SwagMigrationErrorResolutionService { * for id fields with associations, returns the association field instead. */ getEffectiveEntityField(entityName: string | null | undefined, fieldName: string | null | undefined): Property | null { - const correspondingAssociation = this.findCorrespondingAssociationField(entityName, fieldName); - - if (correspondingAssociation) { - return correspondingAssociation; - } - - return this.getEntityField(entityName, fieldName); + return this.findCorrespondingAssociationField(entityName, fieldName) + ?? this.getEntityField(entityName, fieldName); } /** @@ -444,7 +463,7 @@ export default class SwagMigrationErrorResolutionService { getFieldType(entityName: string | null | undefined, fieldName: string | null | undefined): string | null { const entityField = this.getEntityField(entityName, fieldName); - if (!entityField || (UNHANDLED_FIELD_NAMES as readonly string[]).includes(entityField.type)) { + if (!entityField || (UNHANDLED_FIELD_TYPES as readonly string[]).includes(entityField.type)) { return null; } @@ -490,15 +509,7 @@ export default class SwagMigrationErrorResolutionService { } // fallback to alphabetical order - if (a < b) { - return -1; - } - - if (a > b) { - return 1; - } - - return 0; + return a.localeCompare(b); }); } @@ -575,6 +586,33 @@ export default class SwagMigrationErrorResolutionService { return allFields[0] || null; } + /** + * gets a value from an object using a dot-notation path. + * for nested paths like "prices.shippingMethodId", traverses into nested objects/arrays. + */ + getNestedValue(data: Record, path: string): unknown { + const paths = path.split('.'); + + return paths.reduce((current, key) => { + if (current === null || current === undefined) { + return undefined; + } + + if (Array.isArray(current)) { + // for arrays, get the value from the first item + const firstItem = current[0]; + + return firstItem && typeof firstItem === 'object' ? (firstItem as Record)[key] : undefined; + } + + if (typeof current === 'object') { + return (current as Record)[key]; + } + + return undefined; + }, data); + } + /** * formats association field values to display only ids in a comma-separated list. */ @@ -609,58 +647,38 @@ export default class SwagMigrationErrorResolutionService { /** * maps entity field properties from converted data and formats association fields. * extracts only the specified properties and formats "to many" association fields to display ids. + * supports nested field paths like "prices.shippingMethodId". */ mapEntityFieldProperties( entityName: string | null | undefined, fieldProperties: string[], convertedData: Record, - fieldName?: string | null | undefined, ): Record { - const isToManyRelation = fieldName && this.isToManyAssociationField(entityName, fieldName); - const dataToMap = isToManyRelation ? this.getFirstNestedItem(convertedData[fieldName]) : convertedData; - return fieldProperties.reduce>((acc, property) => { - if (isToManyRelation && property === fieldName) { - acc[property] = this.formatAssociationFieldValue(entityName, property, convertedData[property]); + const value = this.getNestedValue(convertedData, property); + if (value === undefined) { return acc; } - if (property in dataToMap) { - const value = dataToMap[property]; + const shouldFormat = + this.isToManyAssociationField(entityName, property) || + Array.isArray(value) || + (typeof value === 'object' && value !== null && 'id' in value); - const shouldFormat = - this.isToManyAssociationField(entityName, property) || - Array.isArray(value) || - (typeof value === 'object' && value !== null && 'id' in value); + let finalValue = shouldFormat ? this.formatAssociationFieldValue(entityName, property, value) : value; - let finalValue = shouldFormat ? this.formatAssociationFieldValue(entityName, property, value) : value; - - // truncate long text values - if (typeof finalValue === 'string' && finalValue.length > CONTENT_TEXT_MAX_LENGTH) { - finalValue = `${finalValue.substring(0, CONTENT_TEXT_MAX_LENGTH)}...`; - } - - acc[property] = finalValue; + // truncate long text values + if (typeof finalValue === 'string' && finalValue.length > CONTENT_TEXT_MAX_LENGTH) { + finalValue = `${finalValue.substring(0, CONTENT_TEXT_MAX_LENGTH)}...`; } + acc[property] = finalValue; + return acc; }, {}); } - private getFirstNestedItem(fieldValue: unknown): Record { - if (fieldValue && typeof fieldValue === 'object' && !Array.isArray(fieldValue)) { - const firstKey = Object.keys(fieldValue)[0]; - const firstItem = firstKey ? (fieldValue as Record)[firstKey] : null; - - if (firstItem && typeof firstItem === 'object') { - return firstItem as Record; - } - } - - return {}; - } - /** * validates if a field value is valid for submission based on field type. * returns error message snippet suffix if invalid, null if valid. diff --git a/tests/Jest/src/module/swag-migration/service/swag-migration-error-resolution.service.spec.js b/tests/Jest/src/module/swag-migration/service/swag-migration-error-resolution.service.spec.js index a8c048dfe..09ebe0f8a 100644 --- a/tests/Jest/src/module/swag-migration/service/swag-migration-error-resolution.service.spec.js +++ b/tests/Jest/src/module/swag-migration/service/swag-migration-error-resolution.service.spec.js @@ -4,6 +4,7 @@ import SwagMigrationErrorResolutionService, { DATA_TYPES, UNHANDLED_FIELD_TYPES, + UNHANDLED_FIELD_NAMES, HANDLED_RELATION_TYPES, FIELD_COMPONENT_TYPES, FIELD_TYPE_COMPONENT_MAPPING, @@ -110,7 +111,7 @@ const ENTITY_FIELD_TESTS = [ { name: 'forbidden field name', entityName: 'product', - fieldName: UNHANDLED_FIELD_TYPES.at(0), + fieldName: UNHANDLED_FIELD_NAMES.at(0), expected: null, }, { @@ -452,7 +453,7 @@ const CORRESPONDING_ASSOCIATION_FIELD_TESTS = [ name: 'field without corresponding association or translation match', entityName: 'category', fieldName: 'afterCategoryVersionId', - expected: undefined, + expected: null, }, { name: 'infer translation association from field name', @@ -812,7 +813,6 @@ const MAP_ENTITY_FIELD_PROPERTIES_TESTS = [ entityName: 'product', fieldProperties: [], convertedData: { id: 'prod-1', name: 'Product 1' }, - fieldName: null, expected: {}, }, { @@ -824,7 +824,6 @@ const MAP_ENTITY_FIELD_PROPERTIES_TESTS = [ 'productNumber', ], convertedData: { id: 'prod-1', name: 'Product 1', productNumber: 'P-001' }, - fieldName: null, expected: { id: 'prod-1', name: 'Product 1', productNumber: 'P-001' }, }, { @@ -836,7 +835,6 @@ const MAP_ENTITY_FIELD_PROPERTIES_TESTS = [ 'description', ], convertedData: { id: 'prod-1', name: 'Product 1' }, - fieldName: null, expected: { id: 'prod-1', name: 'Product 1' }, }, { @@ -850,7 +848,6 @@ const MAP_ENTITY_FIELD_PROPERTIES_TESTS = [ id: 'prod-1', manufacturer: { id: 'manu-1', name: 'Manufacturer 1' }, }, - fieldName: null, expected: { id: 'prod-1', manufacturer: 'manu-1', @@ -870,7 +867,6 @@ const MAP_ENTITY_FIELD_PROPERTIES_TESTS = [ { id: 'cat-2' }, ], }, - fieldName: null, expected: { id: 'prod-1', categories: 'cat-1, cat-2', @@ -890,9 +886,8 @@ const MAP_ENTITY_FIELD_PROPERTIES_TESTS = [ 'cat-2': { id: 'cat-2', name: 'Category 2' }, }, }, - fieldName: 'categories', expected: { - id: 'cat-1', + id: 'prod-1', categories: 'cat-1, cat-2', }, }, @@ -907,8 +902,8 @@ const MAP_ENTITY_FIELD_PROPERTIES_TESTS = [ id: 'prod-1', categories: null, }, - fieldName: 'categories', expected: { + id: 'prod-1', categories: '', }, }, @@ -921,7 +916,6 @@ const MAP_ENTITY_FIELD_PROPERTIES_TESTS = [ convertedData: { customerComment: 'A'.repeat(CONTENT_TEXT_MAX_LENGTH + 1), }, - fieldName: 'customerComment', expected: { customerComment: `${'A'.repeat(CONTENT_TEXT_MAX_LENGTH)}...`, }, @@ -1150,6 +1144,63 @@ const NORMALIZE_FIELD_VALUE_FOR_SAVE_TESTS = [ }, ]; +const GET_NESTED_VALUE_TESTS = [ + { + name: 'simple top-level property', + data: { id: 'prod-1', name: 'Product 1' }, + path: 'name', + expected: 'Product 1', + }, + { + name: 'nested property via dot notation', + data: { id: 'prod-1', prices: { shippingMethodId: 'ship-1' } }, + path: 'prices.shippingMethodId', + expected: 'ship-1', + }, + { + name: 'nested property in array (first item)', + data: { id: 'prod-1', prices: [{ shippingMethodId: 'ship-1' }, { shippingMethodId: 'ship-2' }] }, + path: 'prices.shippingMethodId', + expected: 'ship-1', + }, + { + name: 'deeply nested property', + data: { level1: { level2: { level3: { value: 'deep' } } } }, + path: 'level1.level2.level3.value', + expected: 'deep', + }, + { + name: 'non-existent property', + data: { id: 'prod-1' }, + path: 'nonExistent', + expected: undefined, + }, + { + name: 'non-existent nested property', + data: { id: 'prod-1', prices: {} }, + path: 'prices.shippingMethodId', + expected: undefined, + }, + { + name: 'null intermediate value', + data: { id: 'prod-1', prices: null }, + path: 'prices.shippingMethodId', + expected: undefined, + }, + { + name: 'empty array', + data: { id: 'prod-1', prices: [] }, + path: 'prices.shippingMethodId', + expected: undefined, + }, + { + name: 'nested object within array', + data: { items: [{ details: { sku: 'SKU-001' } }] }, + path: 'items.details.sku', + expected: 'SKU-001', + }, +]; + const testCases = { getEntityLink: ENTITY_LINK_TESTS, getEntitySchema: ENTITY_SCHEMA_TESTS, @@ -1167,6 +1218,7 @@ const testCases = { generateTableColumns: GENERATE_TABLE_COLUMNS_TESTS, formatAssociationFieldValue: FORMAT_ASSOCIATION_FIELD_VALUE_TESTS, mapEntityFieldProperties: MAP_ENTITY_FIELD_PROPERTIES_TESTS, + getNestedValue: GET_NESTED_VALUE_TESTS, validateFieldValue: VALIDATE_FIELD_VALUE_TESTS, isEntityCollection: IS_ENTITY_COLLECTION_TESTS, normalizeFieldValueForSave: NORMALIZE_FIELD_VALUE_FOR_SAVE_TESTS, @@ -1420,12 +1472,19 @@ describe('module/swag-migration/service/swag-migration-error-resolution.service' it.each(testCases.mapEntityFieldProperties)( 'should map entity field properties: $name', - ({ entityName, fieldProperties, convertedData, fieldName, expected }) => { + ({ entityName, fieldProperties, convertedData, expected }) => { expect( - service.mapEntityFieldProperties(entityName, fieldProperties, convertedData, fieldName), + service.mapEntityFieldProperties(entityName, fieldProperties, convertedData), ).toStrictEqual(expected); }, ); + + it.each(testCases.getNestedValue)( + 'should get nested value from object: $name', + ({ data, path, expected }) => { + expect(service.getNestedValue(data, path)).toStrictEqual(expected); + }, + ); }); describe('value validation', () => { From 4a4ddef3b8f6082213fb4d40cde5cba15da86b71 Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Wed, 21 Jan 2026 11:31:03 +0100 Subject: [PATCH 21/26] refactor: validation --- src/Controller/ErrorResolutionController.php | 15 +- src/DependencyInjection/migration.xml | 4 - .../MigrationErrorResolutionService.php | 5 + .../ErrorResolution/MigrationFix.php | 70 ++++--- .../MigrationEntityValidationService.php | 4 + .../MigrationFieldValidationService.php | 65 ++++++- .../Validation/MigrationValidationResult.php | 6 +- .../MigrationFieldValidationServiceTest.php | 83 +++++++- .../ErrorResolution/MigrationFixTest.php | 184 ++++++++++++++++++ 9 files changed, 374 insertions(+), 62 deletions(-) diff --git a/src/Controller/ErrorResolutionController.php b/src/Controller/ErrorResolutionController.php index 44affb230..ec21dc402 100644 --- a/src/Controller/ErrorResolutionController.php +++ b/src/Controller/ErrorResolutionController.php @@ -8,9 +8,6 @@ namespace SwagMigrationAssistant\Controller; use Shopware\Core\Framework\Context; -use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry; -use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition; -use Shopware\Core\Framework\DataAbstractionLayer\Write\EntityWriterInterface; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Routing\ApiRouteScope; use Shopware\Core\Framework\Validation\WriteConstraintViolationException; @@ -18,7 +15,6 @@ use SwagMigrationAssistant\Exception\MigrationException; use SwagMigrationAssistant\Migration\ErrorResolution\MigrationFieldExampleGenerator; use SwagMigrationAssistant\Migration\Validation\Exception\MigrationValidationException; -use SwagMigrationAssistant\Migration\Validation\MigrationEntityValidationService; use SwagMigrationAssistant\Migration\Validation\MigrationFieldValidationService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -33,11 +29,7 @@ class ErrorResolutionController extends AbstractController * @internal */ public function __construct( - private readonly DefinitionInstanceRegistry $definitionRegistry, private readonly MigrationFieldValidationService $fieldValidationService, - private readonly MigrationEntityValidationService $entityValidationService, - private readonly EntityWriterInterface $entityWriter, - private readonly EntityDefinition $definition, ) { } @@ -119,14 +111,13 @@ public function getExampleFieldStructure(Request $request): JsonResponse throw MigrationException::missingRequestParameter('fieldName'); } - $entityDefinition = $this->definitionRegistry->getByEntityName($entityName); - $fields = $entityDefinition->getFields(); + $resolved = $this->fieldValidationService->resolveFieldPath($entityName, $fieldName); - if (!$fields->has($fieldName)) { + if ($resolved === null) { throw MigrationValidationException::entityFieldNotFound($entityName, $fieldName); } - $field = $fields->get($fieldName); + [, $field] = $resolved; $response = [ 'fieldType' => MigrationFieldExampleGenerator::getFieldType($field), diff --git a/src/DependencyInjection/migration.xml b/src/DependencyInjection/migration.xml index 7e1200226..54c282785 100644 --- a/src/DependencyInjection/migration.xml +++ b/src/DependencyInjection/migration.xml @@ -296,11 +296,7 @@ - - - - diff --git a/src/Migration/ErrorResolution/MigrationErrorResolutionService.php b/src/Migration/ErrorResolution/MigrationErrorResolutionService.php index b864a5f67..63cdf75ff 100644 --- a/src/Migration/ErrorResolution/MigrationErrorResolutionService.php +++ b/src/Migration/ErrorResolution/MigrationErrorResolutionService.php @@ -9,6 +9,7 @@ use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Exception; use Shopware\Core\Framework\Context; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Uuid\Uuid; @@ -30,6 +31,8 @@ public function __construct( /** * @param array> $data + * + * @throws Exception */ public function applyFixes(array &$data, string $connectionId, string $runId, Context $context): void { @@ -80,6 +83,8 @@ public function applyFixes(array &$data, string $connectionId, string $runId, Co /** * Loads fixes from the database and populates them in the context. + * + * @throws Exception */ private function loadFixes(MigrationErrorResolutionContext $errorResolutionContext): void { diff --git a/src/Migration/ErrorResolution/MigrationFix.php b/src/Migration/ErrorResolution/MigrationFix.php index 80530c08f..be1ab7f00 100644 --- a/src/Migration/ErrorResolution/MigrationFix.php +++ b/src/Migration/ErrorResolution/MigrationFix.php @@ -50,38 +50,50 @@ public static function fromDatabaseQuery(array $data): self */ public function apply(array &$item): void { - /* - * Explode the path to an array - * Path example: 'category.language.name' - * Results in an array like: ['category', 'language', 'name'] - */ $pathArray = explode(self::PATH_SEPARATOR, $this->path); + $decodedValue = \json_decode($this->value, true, 512, \JSON_THROW_ON_ERROR); - /* - * Set current item as pointer - * Item structure for example has no valid value for name and looks like: - * [ - * 'someOtherKeys', - * ... - * category => [ - * ... - * 'language' => [ - * ... - * 'name' => null, - * ] - * ] - * ] - */ - $nestedPointer = &$item; - - // Iterating over the path to follow them and set the nested pointer to the last key in pathArray - // In this example the result pointer is: $item['category']['language']['name'] - foreach ($pathArray as $key) { - $nestedPointer = &$nestedPointer[$key]; + $this->applyToPath($item, $pathArray, $decodedValue); + } + + /** + * Recursively applies the fix value to the specified path. + * When encountering a list (numerically-indexed array), applies the fix to all items. + * + * @param array $data + * @param array $remainingPath + */ + private function applyToPath(array &$data, array $remainingPath, mixed $value): void + { + if (empty($remainingPath)) { + return; + } + + $key = \array_shift($remainingPath); + + // last segment of the path, normal set operation + if (empty($remainingPath)) { + $data[$key] = $value; + + return; + } + + // key points to a list, apply to all items in the list + if (isset($data[$key]) && \is_array($data[$key]) && \array_is_list($data[$key])) { + foreach ($data[$key] as &$arrayItem) { + if (\is_array($arrayItem)) { + $this->applyToPath($arrayItem, $remainingPath, $value); + } + } + + return; + } + + // stop traversal if the next key is not an array + if (!isset($data[$key]) || !\is_array($data[$key])) { + return; } - // Now set the value to the pointer like: $item['category']['language']['name'] = 'new Value' - $nestedPointer = \json_decode($this->value, true, 512, \JSON_THROW_ON_ERROR); - unset($nestedPointer); + $this->applyToPath($data[$key], $remainingPath, $value); } } diff --git a/src/Migration/Validation/MigrationEntityValidationService.php b/src/Migration/Validation/MigrationEntityValidationService.php index 3c8a7b971..62522352a 100644 --- a/src/Migration/Validation/MigrationEntityValidationService.php +++ b/src/Migration/Validation/MigrationEntityValidationService.php @@ -210,6 +210,8 @@ private function validateFieldValues(MigrationValidationContext $validationConte * Recursively validates nested entities within association fields. * * @param array|mixed $value + * + * @throws Exception */ private function validateNestedAssociations( MigrationValidationContext $validationContext, @@ -257,6 +259,8 @@ private function validateNestedAssociations( * Validates a single nested entity's fields and recurses into deeper associations. * * @param array|mixed $nestedEntityData + * + * @throws Exception|\Exception */ private function validateNestedEntityData( MigrationValidationContext $validationContext, diff --git a/src/Migration/Validation/MigrationFieldValidationService.php b/src/Migration/Validation/MigrationFieldValidationService.php index 2822c8ee6..5e3c6f234 100644 --- a/src/Migration/Validation/MigrationFieldValidationService.php +++ b/src/Migration/Validation/MigrationFieldValidationService.php @@ -40,9 +40,10 @@ public function __construct( /** * Validates a single field value against its entity definition. - * Silently skips validation for unknown entities or fields. + * Supports nested field paths like "prices.shippingMethodId". + * Silently skips validation for unknown entities. * - * @throws MigrationValidationException + * @throws MigrationValidationException|\Exception */ public function validateField( string $entityName, @@ -55,17 +56,17 @@ public function validateField( return; } - $entityDefinition = $this->definitionRegistry->getByEntityName($entityName); - $fields = $entityDefinition->getFields(); + $resolved = $this->resolveFieldPath($entityName, $fieldName); - if (!$fields->has($fieldName)) { - throw MigrationValidationException::entityFieldNotFound($entityName, $fieldName); + if ($resolved === null) { + return; } - $field = clone $fields->get($fieldName); + [$entityDefinition, $field] = $resolved; + $field = clone $field; if ($field instanceof AssociationField) { - $this->validateAssociationStructure($field, $value, $entityName); + $this->validateAssociationStructure($field, $value, $entityDefinition->getEntityName()); return; } @@ -73,6 +74,54 @@ public function validateField( $this->validateScalarField($field, $value, $isRequired, $entityDefinition, $context); } + /** + * Resolves a potentially nested field path (e.g., "prices.shippingMethodId") to its target. + * Traverses through association fields to find the final entity definition and field. + * + * @throws \Exception + * + * @return array{EntityDefinition, Field}|null Returns [EntityDefinition, Field] or null if not found + */ + public function resolveFieldPath(string $entityName, string $fieldPath): ?array + { + if (!$this->definitionRegistry->has($entityName)) { + return null; + } + + $currentDefinition = $this->definitionRegistry->getByEntityName($entityName); + $paths = \explode('.', $fieldPath); + + foreach ($paths as $index => $path) { + $fields = $currentDefinition->getFields(); + + if (!$fields->has($path)) { + return null; + } + + $field = $fields->get($path); + + if ($index === \count($paths) - 1) { + return [$currentDefinition, $field]; + } + + if (!$field instanceof AssociationField) { + return null; + } + + $referenceEntity = $field instanceof ManyToManyAssociationField + ? $field->getToManyReferenceDefinition()->getEntityName() + : $field->getReferenceDefinition()->getEntityName(); + + if (!$this->definitionRegistry->has($referenceEntity)) { + return null; + } + + $currentDefinition = $this->definitionRegistry->getByEntityName($referenceEntity); + } + + return null; + } + /** * Validates the structure of an association field value (not its nested content). */ diff --git a/src/Migration/Validation/MigrationValidationResult.php b/src/Migration/Validation/MigrationValidationResult.php index 940e65f88..e3a0556e4 100644 --- a/src/Migration/Validation/MigrationValidationResult.php +++ b/src/Migration/Validation/MigrationValidationResult.php @@ -13,6 +13,7 @@ /** * @final + * @codeCoverageIgnore */ #[Package('fundamentals@after-sales')] class MigrationValidationResult @@ -51,11 +52,6 @@ private function createLogKey(MigrationLogEntry $log): string ])); } - public function hasLogs(): bool - { - return \count($this->logs) !== 0; - } - public function getEntityName(): string { return $this->entityName; diff --git a/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php b/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php index d4a331ca5..e4152502e 100644 --- a/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php +++ b/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php @@ -13,7 +13,6 @@ use Shopware\Core\Framework\Context; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; -use SwagMigrationAssistant\Exception\MigrationException; use SwagMigrationAssistant\Migration\Validation\Exception\MigrationValidationException; use SwagMigrationAssistant\Migration\Validation\MigrationFieldValidationService; @@ -33,12 +32,13 @@ protected function setUp(): void $this->migrationFieldValidationService = static::getContainer()->get(MigrationFieldValidationService::class); } - public function testNotExistingEntityDefinition(): void + public function testNotExistingEntityDefinitionSkipsValidation(): void { - static::expectExceptionObject(MigrationException::entityNotExists('test', 'field')); + // Unknown entities are silently skipped - no exception should be thrown + static::expectNotToPerformAssertions(); $this->migrationFieldValidationService->validateField( - 'test', + 'unknown_entity', 'field', 'value', Context::createDefaultContext(), @@ -132,4 +132,79 @@ public function testInvalidPriceFieldCurrencyId(): void Context::createDefaultContext(), ); } + + public function testResolveFieldPathSimpleField(): void + { + $result = $this->migrationFieldValidationService->resolveFieldPath('product', 'name'); + + static::assertNotNull($result); + static::assertCount(2, $result); + static::assertSame('product', $result[0]->getEntityName()); + static::assertSame('name', $result[1]->getPropertyName()); + } + + public function testResolveFieldPathNestedField(): void + { + $result = $this->migrationFieldValidationService->resolveFieldPath('shipping_method', 'prices.shippingMethodId'); + + static::assertNotNull($result); + static::assertCount(2, $result); + static::assertSame('shipping_method_price', $result[0]->getEntityName()); + static::assertSame('shippingMethodId', $result[1]->getPropertyName()); + } + + public function testResolveFieldPathDeeplyNested(): void + { + $result = $this->migrationFieldValidationService->resolveFieldPath('product', 'categories.media.alt'); + + static::assertNotNull($result); + static::assertCount(2, $result); + static::assertSame('media', $result[0]->getEntityName()); + static::assertSame('alt', $result[1]->getPropertyName()); + } + + public function testResolveFieldPathUnknownEntity(): void + { + $result = $this->migrationFieldValidationService->resolveFieldPath('unknown_entity', 'field'); + + static::assertNull($result); + } + + public function testResolveFieldPathUnknownField(): void + { + $result = $this->migrationFieldValidationService->resolveFieldPath('product', 'unknownField'); + + static::assertNull($result); + } + + public function testResolveFieldPathUnknownNestedField(): void + { + $result = $this->migrationFieldValidationService->resolveFieldPath('shipping_method', 'prices.unknownField'); + + static::assertNull($result); + } + + public function testValidateNestedField(): void + { + static::expectNotToPerformAssertions(); + + $this->migrationFieldValidationService->validateField( + 'shipping_method', + 'prices.shippingMethodId', + 'a5d7a3b4c5d6e7f8a9b0c1d2e3f4a5b6', + Context::createDefaultContext(), + ); + } + + public function testValidateNestedFieldInvalid(): void + { + static::expectException(MigrationValidationException::class); + + $this->migrationFieldValidationService->validateField( + 'shipping_method', + 'prices.shippingMethodId', + 'not-a-valid-uuid', + Context::createDefaultContext(), + ); + } } diff --git a/tests/unit/Migration/ErrorResolution/MigrationFixTest.php b/tests/unit/Migration/ErrorResolution/MigrationFixTest.php index 19a968834..cf5599734 100644 --- a/tests/unit/Migration/ErrorResolution/MigrationFixTest.php +++ b/tests/unit/Migration/ErrorResolution/MigrationFixTest.php @@ -78,6 +78,190 @@ public function testApplyFix(): void static::assertSame('untouchedValue', $item['path']['to']['the']['value']['which']['needs']['to']['be']['doNotTouch']); } + public function testApplyFixToArrayAssociation(): void + { + $expectedValue = 'fixedTypeId'; + + $fix = new MigrationFix( + 'anyId', + \json_encode($expectedValue, \JSON_THROW_ON_ERROR), + 'numberRangeSalesChannels.numberRangeTypeId', + ); + + $item = [ + 'id' => 'anyId', + 'name' => 'Order Number', + 'numberRangeSalesChannels' => [ + [ + 'id' => 'channel1', + 'numberRangeTypeId' => null, + 'salesChannelId' => 'sc1', + ], + [ + 'id' => 'channel2', + 'numberRangeTypeId' => null, + 'salesChannelId' => 'sc2', + ], + [ + 'id' => 'channel3', + 'numberRangeTypeId' => 'existingValue', + 'salesChannelId' => 'sc3', + ], + ], + ]; + + $fix->apply($item); + + // All array items should have the fix applied + static::assertSame($expectedValue, $item['numberRangeSalesChannels'][0]['numberRangeTypeId']); + static::assertSame($expectedValue, $item['numberRangeSalesChannels'][1]['numberRangeTypeId']); + static::assertSame($expectedValue, $item['numberRangeSalesChannels'][2]['numberRangeTypeId']); + + // Other fields should remain untouched + static::assertSame('Order Number', $item['name']); + static::assertSame('channel1', $item['numberRangeSalesChannels'][0]['id']); + static::assertSame('sc1', $item['numberRangeSalesChannels'][0]['salesChannelId']); + static::assertSame('channel2', $item['numberRangeSalesChannels'][1]['id']); + static::assertSame('sc2', $item['numberRangeSalesChannels'][1]['salesChannelId']); + } + + public function testApplyFixToEmptyArray(): void + { + $fix = new MigrationFix( + 'anyId', + \json_encode('fixedValue', \JSON_THROW_ON_ERROR), + 'items.fieldName', + ); + + $item = [ + 'id' => 'anyId', + 'items' => [], + ]; + + // Should not crash when array is empty + $fix->apply($item); + + static::assertSame([], $item['items']); + } + + public function testApplyFixToDeeplyNestedArrays(): void + { + $expectedValue = 'deepFixedValue'; + + $fix = new MigrationFix( + 'anyId', + \json_encode($expectedValue, \JSON_THROW_ON_ERROR), + 'categories.children.name', + ); + + $item = [ + 'id' => 'anyId', + 'categories' => [ + [ + 'id' => 'cat1', + 'children' => [ + ['id' => 'child1', 'name' => null], + ['id' => 'child2', 'name' => null], + ], + ], + [ + 'id' => 'cat2', + 'children' => [ + ['id' => 'child3', 'name' => 'existingName'], + ], + ], + ], + ]; + + $fix->apply($item); + + // All nested array items should have the fix applied + static::assertSame($expectedValue, $item['categories'][0]['children'][0]['name']); + static::assertSame($expectedValue, $item['categories'][0]['children'][1]['name']); + static::assertSame($expectedValue, $item['categories'][1]['children'][0]['name']); + + // Other fields should remain untouched + static::assertSame('cat1', $item['categories'][0]['id']); + static::assertSame('cat2', $item['categories'][1]['id']); + static::assertSame('child1', $item['categories'][0]['children'][0]['id']); + } + + public function testApplyFixToMixedAssociativeAndArrayPaths(): void + { + $expectedValue = 'mixedPathValue'; + + $fix = new MigrationFix( + 'anyId', + \json_encode($expectedValue, \JSON_THROW_ON_ERROR), + 'product.prices.currencyId', + ); + + $item = [ + 'id' => 'anyId', + 'product' => [ + 'id' => 'prod1', + 'name' => 'Test Product', + 'prices' => [ + ['id' => 'price1', 'currencyId' => null, 'gross' => 100], + ['id' => 'price2', 'currencyId' => null, 'gross' => 200], + ], + ], + ]; + + $fix->apply($item); + + // Fix should be applied to all price items + static::assertSame($expectedValue, $item['product']['prices'][0]['currencyId']); + static::assertSame($expectedValue, $item['product']['prices'][1]['currencyId']); + + // Other fields should remain untouched + static::assertSame('prod1', $item['product']['id']); + static::assertSame('Test Product', $item['product']['name']); + static::assertSame(100, $item['product']['prices'][0]['gross']); + static::assertSame(200, $item['product']['prices'][1]['gross']); + } + + public function testApplyFixCreatesPathIfNotExists(): void + { + $expectedValue = 'newValue'; + + $fix = new MigrationFix( + 'anyId', + \json_encode($expectedValue, \JSON_THROW_ON_ERROR), + 'new.path.field', + ); + + $item = [ + 'id' => 'anyId', + 'existingField' => 'existingValue', + ]; + + $fix->apply($item); + + static::assertSame($expectedValue, $item['new']['path']['field']); + static::assertSame('existingValue', $item['existingField']); + } + + public function testApplyFixWithArrayValue(): void + { + $expectedValue = ['id1', 'id2', 'id3']; + + $fix = new MigrationFix( + 'anyId', + \json_encode($expectedValue, \JSON_THROW_ON_ERROR), + 'tags', + ); + + $item = [ + 'id' => 'anyId', + 'tags' => [], + ]; + + $fix->apply($item); + + static::assertSame($expectedValue, $item['tags']); + } + public function testCreateFromDatabaseQuery(): void { $data = [ From 6b242c53743c33a39789c116a3de4a9636763cca Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Wed, 21 Jan 2026 15:53:19 +0100 Subject: [PATCH 22/26] test: refine php tests --- UPGRADE.md | 1 - .../ErrorResolution/MigrationFix.php | 5 +- .../MigrationEntityValidationService.php | 5 +- .../Validation/MigrationValidationResult.php | 1 + .../ErrorResolutionControllerTest.php | 12 ++ .../MigrationErrorResolutionServiceTest.php | 109 +++++++++++++++- .../MigrationEntityValidationServiceTest.php | 121 +++++++++++++++++- .../MigrationFieldValidationServiceTest.php | 2 +- 8 files changed, 242 insertions(+), 14 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 033ae8692..3482d416c 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -138,7 +138,6 @@ - [BREAKING] [#51](https://github.com/shopware/SwagMigrationAssistant/pull/51) - feat!: add migration validation of converted data - [BREAKING] Added validation check of converted data to `convertData(...)` method in `SwagMigrationAssistant\Migration\Service\MigrationDataConverter` - - Added `hasValidMappingByEntityId(...)` to `SwagMigrationAssistant\Migration\Mapping\MappingService` and `SwagMigrationAssistant\Migration\Mapping\MappingServiceInterface` to check if a mapping exists and is valid for a given entity and source id - Added new service `SwagMigrationAssistant\Migration\Validation\MigrationEntityValidationService` to validate converted data against Shopware's data definitions - Added new events `SwagMigrationAssistant\Migration\Validation\Event\MigrationPreValidationEvent` and `SwagMigrationAssistant\Migration\Validation\Event\MigrationPostValidationEvent` to allow extensions to hook into the validation process - Added new log classes `ValidationInvalidFieldValueLog`, `ValidationInvalidForeignKeyLog`, `ValidationMissingRequiredFieldLog` and `ValidationUnexpectedFieldLog` to log validation errors diff --git a/src/Migration/ErrorResolution/MigrationFix.php b/src/Migration/ErrorResolution/MigrationFix.php index be1ab7f00..0875c4507 100644 --- a/src/Migration/ErrorResolution/MigrationFix.php +++ b/src/Migration/ErrorResolution/MigrationFix.php @@ -71,7 +71,7 @@ private function applyToPath(array &$data, array $remainingPath, mixed $value): $key = \array_shift($remainingPath); - // last segment of the path, normal set operation + // last segment of the path, "normal" set operation if (empty($remainingPath)) { $data[$key] = $value; @@ -89,9 +89,8 @@ private function applyToPath(array &$data, array $remainingPath, mixed $value): return; } - // stop traversal if the next key is not an array if (!isset($data[$key]) || !\is_array($data[$key])) { - return; + $data[$key] = []; } $this->applyToPath($data[$key], $remainingPath, $value); diff --git a/src/Migration/Validation/MigrationEntityValidationService.php b/src/Migration/Validation/MigrationEntityValidationService.php index 62522352a..42bed8f0e 100644 --- a/src/Migration/Validation/MigrationEntityValidationService.php +++ b/src/Migration/Validation/MigrationEntityValidationService.php @@ -223,7 +223,6 @@ private function validateNestedAssociations( return; } - // Skip translations, they have special structure and are system-managed if ($field instanceof TranslationsAssociationField) { return; } @@ -272,7 +271,7 @@ private function validateNestedEntityData( return; } - // Skip ID-only references (linking existing entities, not creating new ones) + // Skip id only references if (\count($nestedEntityData) === 1 && isset($nestedEntityData['id'])) { return; } @@ -286,7 +285,7 @@ private function validateNestedEntityData( $rootEntityId = $rootEntityId !== null ? (string) $rootEntityId : null; foreach ($nestedEntityData as $fieldName => $value) { - // Skip 'id' field, already validated by association structure check + // skip id field validation if ($fieldName === 'id') { continue; } diff --git a/src/Migration/Validation/MigrationValidationResult.php b/src/Migration/Validation/MigrationValidationResult.php index e3a0556e4..c316303e3 100644 --- a/src/Migration/Validation/MigrationValidationResult.php +++ b/src/Migration/Validation/MigrationValidationResult.php @@ -13,6 +13,7 @@ /** * @final + * * @codeCoverageIgnore */ #[Package('fundamentals@after-sales')] diff --git a/tests/integration/Migration/Controller/ErrorResolutionControllerTest.php b/tests/integration/Migration/Controller/ErrorResolutionControllerTest.php index 49198265b..60f430681 100644 --- a/tests/integration/Migration/Controller/ErrorResolutionControllerTest.php +++ b/tests/integration/Migration/Controller/ErrorResolutionControllerTest.php @@ -305,6 +305,18 @@ public static function validateResolutionProvider(): \Generator 'violations' => [], ], ]; + + yield 'nested invalid number range field' => [ + 'entityName' => 'number_range', + 'fieldName' => 'numberRangeSalesChannels.numberRangeId', + 'fieldValue' => 'not-a-uuid', + 'expected' => [ + 'valid' => false, + 'violations' => [ + 'The string "not-a-uuid" is not a valid uuid.', + ], + ], + ]; } /** diff --git a/tests/integration/Migration/ErrorResolution/MigrationErrorResolutionServiceTest.php b/tests/integration/Migration/ErrorResolution/MigrationErrorResolutionServiceTest.php index 5c95ee207..06f088fb8 100644 --- a/tests/integration/Migration/ErrorResolution/MigrationErrorResolutionServiceTest.php +++ b/tests/integration/Migration/ErrorResolution/MigrationErrorResolutionServiceTest.php @@ -12,15 +12,18 @@ use Shopware\Core\Framework\Context; use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Test\TestCaseBase\EventDispatcherBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; use Shopware\Core\Framework\Uuid\Uuid; use SwagMigrationAssistant\Migration\Connection\SwagMigrationConnectionEntity; use SwagMigrationAssistant\Migration\ErrorResolution\Entity\SwagMigrationFixEntity; +use SwagMigrationAssistant\Migration\ErrorResolution\Event\MigrationPreErrorResolutionEvent; use SwagMigrationAssistant\Migration\ErrorResolution\MigrationErrorResolutionService; use SwagMigrationAssistant\Migration\Logging\SwagMigrationLoggingEntity; use SwagMigrationAssistant\Migration\MigrationContext; use SwagMigrationAssistant\Migration\Run\SwagMigrationRunEntity; use SwagMigrationAssistant\Profile\Shopware\Gateway\Local\ShopwareLocalGateway; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; /** * @internal @@ -28,8 +31,106 @@ #[Package('fundamentals@after-sales')] class MigrationErrorResolutionServiceTest extends TestCase { + use EventDispatcherBehaviour; use IntegrationTestBehaviour; + private EventDispatcherInterface $eventDispatcher; + + protected function setUp(): void + { + parent::setUp(); + + $this->eventDispatcher = $this->getContainer()->get('event_dispatcher'); + } + + public function testEarlyReturnIfNoFixesExist(): void + { + $connectionId = Uuid::randomHex(); + $connection = $this->createConnection($connectionId); + $run = $this->createRun($connection); + + $data = [ + ['id' => Uuid::randomHex(), 'some' => ['data' => 'value']], + ['id' => Uuid::randomHex(), 'other' => ['data' => 'value']], + ]; + + $originalData = $data; + + $service = new MigrationErrorResolutionService( + $this->getContainer()->get(Connection::class), + $this->eventDispatcher, + ); + + $events = []; + $this->addEventListener($this->eventDispatcher, MigrationPreErrorResolutionEvent::class, function (MigrationPreErrorResolutionEvent $event) use (&$events): void { + $events[] = $event; + }); + + $service->applyFixes($data, $connection->getId(), $run->getId(), Context::createDefaultContext()); + + static::assertCount(0, $events); + static::assertSame($originalData, $data); + } + + public function testApplyToManyArray(): void + { + $connectionId = Uuid::randomHex(); + $connection = $this->createConnection($connectionId); + $run = $this->createRun($connection); + + $id = Uuid::randomHex(); + + $service = new MigrationErrorResolutionService( + $this->getContainer()->get(Connection::class), + $this->eventDispatcher, + ); + + // if path is prop in array, apply to all items in array ('items.[index].missingId' -> 'items.missingId') + $this->createFixAndLogging($connection->getId(), $id, 'someId', 'items.missingId', $run); + + $data = [ + [ + 'id' => $id, + 'items' => [ + [ + 'value' => 'oldValue1', + 'missingId' => null, + ], + [ + 'value' => 'oldValue2', + 'missingId' => null, + ], + [ + 'value' => 'oldValue3', + 'missingId' => null, + ], + ], + ], + ]; + + $service->applyFixes($data, $connection->getId(), $run->getId(), Context::createDefaultContext()); + + $expected = [[ + 'id' => $id, + 'items' => [ + [ + 'value' => 'oldValue1', + 'missingId' => 'someId', + ], + [ + 'value' => 'oldValue2', + 'missingId' => 'someId', + ], + [ + 'value' => 'oldValue3', + 'missingId' => 'someId', + ], + ], + ]]; + + static::assertSame($expected, $data); + } + public function testApply(): void { $connectionId = Uuid::randomHex(); @@ -42,7 +143,7 @@ public function testApply(): void $service = new MigrationErrorResolutionService( $this->getContainer()->get(Connection::class), - $this->getContainer()->get('event_dispatcher') + $this->eventDispatcher, ); $this->createFixAndLogging($connection->getId(), $idOne, 'val1', 'first.path', $run); @@ -64,6 +165,11 @@ public function testApply(): void ['id' => $idThree], ]; + $events = []; + $this->addEventListener($this->eventDispatcher, MigrationPreErrorResolutionEvent::class, function (MigrationPreErrorResolutionEvent $event) use (&$events): void { + $events[] = $event; + }); + $service->applyFixes($data, $connection->getId(), $run->getId(), Context::createDefaultContext()); $expected = [[ @@ -90,6 +196,7 @@ public function testApply(): void 'id' => $idThree, ]]; + static::assertCount(1, $events); static::assertSame($expected, $data); } diff --git a/tests/integration/Migration/Validation/MigrationEntityValidationServiceTest.php b/tests/integration/Migration/Validation/MigrationEntityValidationServiceTest.php index d590dcf29..5265b1ac6 100644 --- a/tests/integration/Migration/Validation/MigrationEntityValidationServiceTest.php +++ b/tests/integration/Migration/Validation/MigrationEntityValidationServiceTest.php @@ -486,9 +486,7 @@ public static function associationProvider(): \Generator ]; } - /** - * Tests for ManyToMany and OneToMany association validation. - * + /*** * @return \Generator, array}> */ public static function toManyAssociationProvider(): \Generator @@ -595,8 +593,6 @@ public function testValidateToManyAssociations(array $convertedData, array $expe } /** - * Tests for ManyToOne and OneToOne association validation. - * * @return \Generator, array}> */ public static function toOneAssociationProvider(): \Generator @@ -776,4 +772,119 @@ public function testValidationLogsAreSavedToDatabase(): void static::assertInstanceOf(SwagMigrationLoggingCollection::class, $logs); static::assertGreaterThan(0, $logs->count()); } + + public function testDeepNestedValidationTwoLevels(): void + { + $convertedData = [ + 'id' => Uuid::randomHex(), + 'versionId' => Uuid::randomHex(), + 'stock' => 10, + 'translations' => [ + Defaults::LANGUAGE_SYSTEM => [ + 'name' => 'Test Product', + ], + ], + 'manufacturer' => [ + 'id' => Uuid::randomHex(), + 'translations' => [ + Defaults::LANGUAGE_SYSTEM => [ + 'name' => 'Test Manufacturer', + ], + ], + 'media' => [ + 'id' => Uuid::randomHex(), + 'mimeType' => 12345, // invalid: should be string + ], + ], + ]; + + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + $convertedData, + ProductDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result); + + $logClasses = \array_map(static fn ($log) => $log::class, $result->getLogs()); + static::assertContains(MigrationValidationInvalidOptionalFieldValueLog::class, $logClasses); + } + + public function testInvalidFieldValueInNestedEntity(): void + { + $convertedData = [ + 'id' => Uuid::randomHex(), + 'versionId' => Uuid::randomHex(), + 'stock' => 10, + 'translations' => [ + Defaults::LANGUAGE_SYSTEM => [ + 'name' => 'Test Product', + ], + ], + 'manufacturer' => [ + 'id' => Uuid::randomHex(), + 'translations' => [ + Defaults::LANGUAGE_SYSTEM => [ + 'name' => 'Test Manufacturer', + ], + ], + 'mediaId' => 'not-a-valid-uuid', // invalid FK value + ], + ]; + + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + $convertedData, + ProductDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result); + + $logClasses = \array_map(static fn ($log) => $log::class, $result->getLogs()); + static::assertContains(MigrationValidationInvalidOptionalFieldValueLog::class, $logClasses); + } + + public function testNestedFieldPathCorrectness(): void + { + $convertedData = [ + 'id' => Uuid::randomHex(), + 'versionId' => Uuid::randomHex(), + 'stock' => 10, + 'translations' => [ + Defaults::LANGUAGE_SYSTEM => [ + 'name' => 'Test Product', + ], + ], + 'manufacturer' => [ + 'id' => Uuid::randomHex(), + 'translations' => [ + Defaults::LANGUAGE_SYSTEM => [ + 'name' => 'Test Manufacturer', + ], + ], + 'media' => [ + 'id' => Uuid::randomHex(), + 'mimeType' => ['invalid' => 'array'], // invalid: should be string + ], + ], + ]; + + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + $convertedData, + ProductDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result); + static::assertNotEmpty($result->getLogs()); + + $fieldPaths = \array_map(static fn ($log) => $log->getFieldName(), $result->getLogs()); + static::assertContains('manufacturer.media.mimeType', $fieldPaths); + } } diff --git a/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php b/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php index e4152502e..004178056 100644 --- a/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php +++ b/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php @@ -34,7 +34,7 @@ protected function setUp(): void public function testNotExistingEntityDefinitionSkipsValidation(): void { - // Unknown entities are silently skipped - no exception should be thrown + // Unknown entities are silently skipped static::expectNotToPerformAssertions(); $this->migrationFieldValidationService->validateField( From 534d4d177365637ee454901962995479c6cdcfe0 Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Wed, 21 Jan 2026 16:31:01 +0100 Subject: [PATCH 23/26] refactor: simplify --- .../MigrationEntityValidationService.php | 1 - ...swag-migration-error-resolution.service.ts | 115 ++++++------------ ...migration-error-resolution.service.spec.js | 69 +---------- 3 files changed, 35 insertions(+), 150 deletions(-) diff --git a/src/Migration/Validation/MigrationEntityValidationService.php b/src/Migration/Validation/MigrationEntityValidationService.php index 42bed8f0e..0b97be413 100644 --- a/src/Migration/Validation/MigrationEntityValidationService.php +++ b/src/Migration/Validation/MigrationEntityValidationService.php @@ -282,7 +282,6 @@ private function validateNestedEntityData( $rootEntityName = $validationContext->getEntityDefinition()->getEntityName(); $rootEntityId = $validationContext->getConvertedData()['id'] ?? null; - $rootEntityId = $rootEntityId !== null ? (string) $rootEntityId : null; foreach ($nestedEntityData as $fieldName => $value) { // skip id field validation diff --git a/src/Resources/app/administration/src/module/swag-migration/service/swag-migration-error-resolution.service.ts b/src/Resources/app/administration/src/module/swag-migration/service/swag-migration-error-resolution.service.ts index 107939be9..7bc86ea7f 100644 --- a/src/Resources/app/administration/src/module/swag-migration/service/swag-migration-error-resolution.service.ts +++ b/src/Resources/app/administration/src/module/swag-migration/service/swag-migration-error-resolution.service.ts @@ -264,46 +264,24 @@ export default class SwagMigrationErrorResolutionService { * grouped by scalar fields, associations, and required fields. */ extractEntityFields(entityName: string | null | undefined): EntityFields { - const fields = { - scalar: {}, - associations: {}, - required: {}, - } as EntityFields; - - if (!entityName) { - return fields; - } - - if (!Shopware.EntityDefinition.has(entityName)) { - return fields; + if (!entityName || !Shopware.EntityDefinition.has(entityName)) { + return { scalar: {}, associations: {}, required: {} }; } const definition = Shopware.EntityDefinition.get(entityName); - definition.forEachField((property: Property, propertyName: string) => { - if (definition.isScalarField(property)) { - fields.scalar[propertyName] = property; - } - - if (property.type === 'association' && definition.isToOneAssociation(property)) { - fields.associations[propertyName] = property; - } - - if (property.flags?.required) { - fields.required[propertyName] = property; - } - }); - - return fields; + return { + scalar: definition.filterProperties((property) => definition.isScalarField(property)), + associations: definition.getToOneAssociations(), + required: definition.getRequiredFields(), + }; } /** * gets the entity schema for a given entity name. */ getEntitySchema(entityName: string | null | undefined): EntityDefinition | null { - return entityName && Shopware.EntityDefinition.has(entityName) - ? Shopware.EntityDefinition.get(entityName) - : null; + return entityName && Shopware.EntityDefinition.has(entityName) ? Shopware.EntityDefinition.get(entityName) : null; } /** @@ -323,32 +301,34 @@ export default class SwagMigrationErrorResolutionService { const paths = fieldPath.split('.'); - return paths.reduce<{ schema: EntityDefinition; result: ResolvedFieldPath | null } | null>( - (acc, path, index) => { - if (!acc || acc.result) { - return acc; - } + return ( + paths.reduce<{ schema: EntityDefinition; result: ResolvedFieldPath | null } | null>( + (acc, path, index) => { + if (!acc || acc.result) { + return acc; + } - const property = acc.schema.getField(path); + const property = acc.schema.getField(path); - if (!property) { - return null; - } + if (!property) { + return null; + } - if (index === paths.length - 1) { - return { ...acc, result: { schema: acc.schema, property, fieldName: path } }; - } + if (index === paths.length - 1) { + return { ...acc, result: { schema: acc.schema, property, fieldName: path } }; + } - if (property.type !== DATA_TYPES.ASSOCIATION || !property.entity) { - return null; - } + if (property.type !== DATA_TYPES.ASSOCIATION || !property.entity) { + return null; + } - const nextSchema = this.getEntitySchema(property.entity); + const nextSchema = this.getEntitySchema(property.entity); - return nextSchema ? { schema: nextSchema, result: null } : null; - }, - { schema: initialSchema, result: null }, - )?.result ?? null; + return nextSchema ? { schema: nextSchema, result: null } : null; + }, + { schema: initialSchema, result: null }, + )?.result ?? null + ); } /** @@ -385,7 +365,8 @@ export default class SwagMigrationErrorResolutionService { } const byLocalField = Object.values(schema.properties).find( - (prop) => prop.type === DATA_TYPES.ASSOCIATION && + (prop) => + prop.type === DATA_TYPES.ASSOCIATION && (prop as Property & { localField?: string }).localField === actualFieldName, ); @@ -453,8 +434,7 @@ export default class SwagMigrationErrorResolutionService { * for id fields with associations, returns the association field instead. */ getEffectiveEntityField(entityName: string | null | undefined, fieldName: string | null | undefined): Property | null { - return this.findCorrespondingAssociationField(entityName, fieldName) - ?? this.getEntityField(entityName, fieldName); + return this.findCorrespondingAssociationField(entityName, fieldName) ?? this.getEntityField(entityName, fieldName); } /** @@ -586,33 +566,6 @@ export default class SwagMigrationErrorResolutionService { return allFields[0] || null; } - /** - * gets a value from an object using a dot-notation path. - * for nested paths like "prices.shippingMethodId", traverses into nested objects/arrays. - */ - getNestedValue(data: Record, path: string): unknown { - const paths = path.split('.'); - - return paths.reduce((current, key) => { - if (current === null || current === undefined) { - return undefined; - } - - if (Array.isArray(current)) { - // for arrays, get the value from the first item - const firstItem = current[0]; - - return firstItem && typeof firstItem === 'object' ? (firstItem as Record)[key] : undefined; - } - - if (typeof current === 'object') { - return (current as Record)[key]; - } - - return undefined; - }, data); - } - /** * formats association field values to display only ids in a comma-separated list. */ @@ -655,7 +608,7 @@ export default class SwagMigrationErrorResolutionService { convertedData: Record, ): Record { return fieldProperties.reduce>((acc, property) => { - const value = this.getNestedValue(convertedData, property); + const value = Shopware.Utils.object.get(convertedData, property) as unknown; if (value === undefined) { return acc; diff --git a/tests/Jest/src/module/swag-migration/service/swag-migration-error-resolution.service.spec.js b/tests/Jest/src/module/swag-migration/service/swag-migration-error-resolution.service.spec.js index 09ebe0f8a..97ce2f4cf 100644 --- a/tests/Jest/src/module/swag-migration/service/swag-migration-error-resolution.service.spec.js +++ b/tests/Jest/src/module/swag-migration/service/swag-migration-error-resolution.service.spec.js @@ -1144,63 +1144,6 @@ const NORMALIZE_FIELD_VALUE_FOR_SAVE_TESTS = [ }, ]; -const GET_NESTED_VALUE_TESTS = [ - { - name: 'simple top-level property', - data: { id: 'prod-1', name: 'Product 1' }, - path: 'name', - expected: 'Product 1', - }, - { - name: 'nested property via dot notation', - data: { id: 'prod-1', prices: { shippingMethodId: 'ship-1' } }, - path: 'prices.shippingMethodId', - expected: 'ship-1', - }, - { - name: 'nested property in array (first item)', - data: { id: 'prod-1', prices: [{ shippingMethodId: 'ship-1' }, { shippingMethodId: 'ship-2' }] }, - path: 'prices.shippingMethodId', - expected: 'ship-1', - }, - { - name: 'deeply nested property', - data: { level1: { level2: { level3: { value: 'deep' } } } }, - path: 'level1.level2.level3.value', - expected: 'deep', - }, - { - name: 'non-existent property', - data: { id: 'prod-1' }, - path: 'nonExistent', - expected: undefined, - }, - { - name: 'non-existent nested property', - data: { id: 'prod-1', prices: {} }, - path: 'prices.shippingMethodId', - expected: undefined, - }, - { - name: 'null intermediate value', - data: { id: 'prod-1', prices: null }, - path: 'prices.shippingMethodId', - expected: undefined, - }, - { - name: 'empty array', - data: { id: 'prod-1', prices: [] }, - path: 'prices.shippingMethodId', - expected: undefined, - }, - { - name: 'nested object within array', - data: { items: [{ details: { sku: 'SKU-001' } }] }, - path: 'items.details.sku', - expected: 'SKU-001', - }, -]; - const testCases = { getEntityLink: ENTITY_LINK_TESTS, getEntitySchema: ENTITY_SCHEMA_TESTS, @@ -1218,7 +1161,6 @@ const testCases = { generateTableColumns: GENERATE_TABLE_COLUMNS_TESTS, formatAssociationFieldValue: FORMAT_ASSOCIATION_FIELD_VALUE_TESTS, mapEntityFieldProperties: MAP_ENTITY_FIELD_PROPERTIES_TESTS, - getNestedValue: GET_NESTED_VALUE_TESTS, validateFieldValue: VALIDATE_FIELD_VALUE_TESTS, isEntityCollection: IS_ENTITY_COLLECTION_TESTS, normalizeFieldValueForSave: NORMALIZE_FIELD_VALUE_FOR_SAVE_TESTS, @@ -1473,16 +1415,7 @@ describe('module/swag-migration/service/swag-migration-error-resolution.service' it.each(testCases.mapEntityFieldProperties)( 'should map entity field properties: $name', ({ entityName, fieldProperties, convertedData, expected }) => { - expect( - service.mapEntityFieldProperties(entityName, fieldProperties, convertedData), - ).toStrictEqual(expected); - }, - ); - - it.each(testCases.getNestedValue)( - 'should get nested value from object: $name', - ({ data, path, expected }) => { - expect(service.getNestedValue(data, path)).toStrictEqual(expected); + expect(service.mapEntityFieldProperties(entityName, fieldProperties, convertedData)).toStrictEqual(expected); }, ); }); From 2f81945fe76434eeaff1bba918a36c73199a342e Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Thu, 22 Jan 2026 13:15:25 +0100 Subject: [PATCH 24/26] test: add indexed generator keys --- .../ErrorResolution/MigrationFix.php | 1 + .../MigrationEntityValidationService.php | 41 +- .../MigrationFieldValidationService.php | 8 +- .../MigrationEntityValidationServiceTest.php | 407 +++++++++--------- .../MigrationFieldValidationServiceTest.php | 5 +- 5 files changed, 242 insertions(+), 220 deletions(-) diff --git a/src/Migration/ErrorResolution/MigrationFix.php b/src/Migration/ErrorResolution/MigrationFix.php index 0875c4507..aeacf19d4 100644 --- a/src/Migration/ErrorResolution/MigrationFix.php +++ b/src/Migration/ErrorResolution/MigrationFix.php @@ -32,6 +32,7 @@ public function __construct( public static function fromDatabaseQuery(array $data): self { $expectedArrayKeys = ['id', 'value', 'path']; + foreach ($expectedArrayKeys as $expectedKey) { if (!\array_key_exists($expectedKey, $data)) { throw MigrationException::couldNotConvertFix($expectedKey); diff --git a/src/Migration/Validation/MigrationEntityValidationService.php b/src/Migration/Validation/MigrationEntityValidationService.php index 0b97be413..65f1f5d74 100644 --- a/src/Migration/Validation/MigrationEntityValidationService.php +++ b/src/Migration/Validation/MigrationEntityValidationService.php @@ -49,6 +49,8 @@ class MigrationEntityValidationService implements ResetInterface { /** + * System managed that managed by Shopware and should not be validated as required fields + * * @var list> */ private const SYSTEM_MANAGED_FIELDS = [ @@ -62,7 +64,7 @@ class MigrationEntityValidationService implements ResetInterface /** * Maps entity name to an associative array of required field property names. * - * Example: + * example: * [ * 'entity_name' => [ * 'required_field_name' => true, @@ -143,6 +145,8 @@ public function validate( } /** + * Validates that all required fields are present in the converted data. + * * @throws \Exception|Exception */ private function validateEntityStructure(MigrationValidationContext $validationContext): void @@ -165,6 +169,8 @@ private function validateEntityStructure(MigrationValidationContext $validationC } /** + * Validates the values of each field in the converted data and its nested associations. + * * @throws \Exception|Exception */ private function validateFieldValues(MigrationValidationContext $validationContext): void @@ -172,20 +178,20 @@ private function validateFieldValues(MigrationValidationContext $validationConte $convertedData = $validationContext->getConvertedData(); $id = $convertedData['id'] ?? null; - if (!$this->validateId($validationContext, $id)) { - return; - } - $entityDefinition = $validationContext->getEntityDefinition(); $entityName = $entityDefinition->getEntityName(); + if (!$this->validateId($validationContext, $entityName, $id)) { + return; + } + $fields = $entityDefinition->getFields(); $requiredFields = $this->getRequiredFields($fields, $entityName); foreach ($convertedData as $fieldName => $value) { $field = $fields->get($fieldName); - // Recursively validate nested association entities + // recursively validate nested association entities if ($field !== null && $value !== null) { $this->validateNestedAssociations($validationContext, $field, $fieldName, $value); } @@ -223,6 +229,7 @@ private function validateNestedAssociations( return; } + // skip translations associations, they are validated by field validation if ($field instanceof TranslationsAssociationField) { return; } @@ -271,11 +278,6 @@ private function validateNestedEntityData( return; } - // Skip id only references - if (\count($nestedEntityData) === 1 && isset($nestedEntityData['id'])) { - return; - } - $nestedEntityName = $referenceDefinition->getEntityName(); $fields = $referenceDefinition->getFields(); $requiredFields = $this->getRequiredFields($fields, $nestedEntityName); @@ -283,8 +285,11 @@ private function validateNestedEntityData( $rootEntityName = $validationContext->getEntityDefinition()->getEntityName(); $rootEntityId = $validationContext->getConvertedData()['id'] ?? null; + if (\count($nestedEntityData) === 1 && isset($nestedEntityData['id'])) { + return; + } + foreach ($nestedEntityData as $fieldName => $value) { - // skip id field validation if ($fieldName === 'id') { continue; } @@ -312,7 +317,7 @@ private function validateNestedEntityData( } } - private function validateId(MigrationValidationContext $validationContext, mixed $id): bool + private function validateId(MigrationValidationContext $validationContext, string $entityName, mixed $id): bool { if ($id === null) { $this->addExceptionLog( @@ -326,7 +331,7 @@ private function validateId(MigrationValidationContext $validationContext, mixed if (!\is_string($id) || !Uuid::isValid($id)) { $this->addExceptionLog( $validationContext, - MigrationValidationException::invalidId((string) $id, $validationContext->getEntityDefinition()->getEntityName()) + MigrationValidationException::invalidId((string) $id, $entityName) ); return false; @@ -336,6 +341,14 @@ private function validateId(MigrationValidationContext $validationContext, mixed } /** + * Loads and caches the required fields for the given entity definition. + * It considers both the definition flags and the database schema. + * + * A field is considered required if: + * - It has the Required flag + * - It is not a system managed field + * - Its corresponding database column is non-nullable without a default value + * * @throws Exception * * @return array diff --git a/src/Migration/Validation/MigrationFieldValidationService.php b/src/Migration/Validation/MigrationFieldValidationService.php index 5e3c6f234..cde842595 100644 --- a/src/Migration/Validation/MigrationFieldValidationService.php +++ b/src/Migration/Validation/MigrationFieldValidationService.php @@ -41,7 +41,7 @@ public function __construct( /** * Validates a single field value against its entity definition. * Supports nested field paths like "prices.shippingMethodId". - * Silently skips validation for unknown entities. + * Silently skips validation for unknown entities & fields. * * @throws MigrationValidationException|\Exception */ @@ -63,6 +63,8 @@ public function validateField( } [$entityDefinition, $field] = $resolved; + + // needed to avoid side effects when modifying flags later $field = clone $field; if ($field instanceof AssociationField) { @@ -80,7 +82,7 @@ public function validateField( * * @throws \Exception * - * @return array{EntityDefinition, Field}|null Returns [EntityDefinition, Field] or null if not found + * @return array{EntityDefinition, Field}|null */ public function resolveFieldPath(string $entityName, string $fieldPath): ?array { @@ -192,7 +194,7 @@ private function validateFieldByFieldSerializer( $serializer = $field->getSerializer(); try { - // Consume the generator to trigger validation. Keys are not needed + // consume the generator to trigger validation. Keys are not needed \iterator_to_array($serializer->encode( $field, $existence, diff --git a/tests/integration/Migration/Validation/MigrationEntityValidationServiceTest.php b/tests/integration/Migration/Validation/MigrationEntityValidationServiceTest.php index 5265b1ac6..05f01dcdc 100644 --- a/tests/integration/Migration/Validation/MigrationEntityValidationServiceTest.php +++ b/tests/integration/Migration/Validation/MigrationEntityValidationServiceTest.php @@ -176,6 +176,98 @@ public function testShouldValidateStructureAndFieldsValues(array $convertedData, static::assertSame($expectedLogs, $logCodes); } + public static function entityStructureAndFieldProvider(): \Generator + { + $log = [ + 'id' => Uuid::randomHex(), + 'profileName' => 'profile', + 'gatewayName' => 'gateway', + 'level' => 'error', + 'code' => 'some_code', + 'userFixable' => true, + 'sourceData' => [ + 'some' => 'data', + ], + 'createdAt' => (new \DateTime())->format(\DATE_ATOM), + ]; + + yield 'valid' => [ + 'convertedData' => $log, + 'expectedLogs' => [], + ]; + + yield 'structure - missing required fields' => [ + 'convertedData' => [ + 'id' => Uuid::randomHex(), + 'level' => 'error', + 'code' => 'some_code', + 'userFixable' => true, + 'createdAt' => (new \DateTime())->format(\DATE_ATOM), + ], + 'expectedLogs' => [ + MigrationValidationMissingRequiredFieldLog::class, + MigrationValidationMissingRequiredFieldLog::class, + ], + ]; + + yield 'fields - invalid type' => [ + 'convertedData' => [ + ...$log, + 'userFixable' => 'not_a_boolean', + ], + 'expectedLogs' => [ + MigrationValidationInvalidOptionalFieldValueLog::class, + ], + ]; + + yield 'fields - too long' => [ + 'convertedData' => [ + ...$log, + 'code' => str_repeat('sw', 128), + ], + 'expectedLogs' => [ + MigrationValidationInvalidRequiredFieldValueLog::class, + ], + ]; + + yield 'fields - invalid uuid' => [ + 'convertedData' => [ + ...$log, + 'id' => 'not-a-uuid', + ], + 'expectedLogs' => [ + MigrationValidationExceptionLog::class, + ], + ]; + + yield 'fields - invalid json' => [ + 'convertedData' => [ + ...$log, + 'sourceData' => "\xB1\x31", + ], + 'expectedLogs' => [ + MigrationValidationInvalidOptionalFieldValueLog::class, + ], + ]; + + yield 'structure/field - multiple errors' => [ + 'convertedData' => [ + 'id' => Uuid::randomHex(), + 'gatewayName' => true, + 'level' => 1, + 'code' => ['sw'], + 'userFixable' => true, + 'createdAt' => (new \DateTime())->format(\DATE_ATOM), + ], + 'expectedLogs' => [ + MigrationValidationMissingRequiredFieldLog::class, + MigrationValidationInvalidRequiredFieldValueLog::class, + MigrationValidationInvalidRequiredFieldValueLog::class, + MigrationValidationInvalidRequiredFieldValueLog::class, + ], + ]; + } + public function testShouldFilterNullableFields(): void { $result = $this->validationService->validate( @@ -223,7 +315,7 @@ public function testShouldLogWhenEntityHasInvalidOrMissingId(array $convertedDat } /** - * @return \Generator, string}> + * @return \Generator, expectedExceptionMessage: string}> */ public static function invalidIdProvider(): \Generator { @@ -235,23 +327,23 @@ public static function invalidIdProvider(): \Generator ]; yield 'missing id (null)' => [ - $baseData, - MigrationValidationException::unexpectedNullValue('id')->getMessage(), + 'convertedData' => $baseData, + 'expectedExceptionMessage' => MigrationValidationException::unexpectedNullValue('id')->getMessage(), ]; yield 'invalid uuid string' => [ - [...$baseData, 'id' => 'invalid-uuid'], - MigrationValidationException::invalidId('invalid-uuid', SwagMigrationLoggingDefinition::ENTITY_NAME)->getMessage(), + 'convertedData' => [...$baseData, 'id' => 'invalid-uuid'], + 'expectedExceptionMessage' => MigrationValidationException::invalidId('invalid-uuid', SwagMigrationLoggingDefinition::ENTITY_NAME)->getMessage(), ]; yield 'integer id instead of uuid string' => [ - [...$baseData, 'id' => 12345], - MigrationValidationException::invalidId('12345', SwagMigrationLoggingDefinition::ENTITY_NAME)->getMessage(), + 'convertedData' => [...$baseData, 'id' => 12345], + 'expectedExceptionMessage' => MigrationValidationException::invalidId('12345', SwagMigrationLoggingDefinition::ENTITY_NAME)->getMessage(), ]; yield 'empty string id' => [ - [...$baseData, 'id' => ''], - MigrationValidationException::invalidId('', SwagMigrationLoggingDefinition::ENTITY_NAME)->getMessage(), + 'convertedData' => [...$baseData, 'id' => ''], + 'expectedExceptionMessage' => MigrationValidationException::invalidId('', SwagMigrationLoggingDefinition::ENTITY_NAME)->getMessage(), ]; } @@ -281,6 +373,71 @@ public function testValidateAssociations(array $convertedData, array $mappings, static::assertEquals($expectedLogs, $logClasses); } + /** + * @return \Generator, mappings: array>, expectedLogs: array}> + */ + public static function associationProvider(): \Generator + { + $log = [ + 'id' => Uuid::randomHex(), + 'profileName' => 'profile', + 'gatewayName' => 'gateway', + 'level' => 'error', + 'code' => 'some_code', + 'userFixable' => true, + 'sourceData' => [ + 'some' => 'data', + ], + 'createdAt' => (new \DateTime())->format(\DATE_ATOM), + ]; + + $runId = Uuid::randomHex(); + $mapping = [ + 'id' => Uuid::randomHex(), + 'connectionId' => self::CONNECTION_ID, + 'entity' => SwagMigrationRunDefinition::ENTITY_NAME, + 'oldIdentifier' => $runId, + 'entityId' => $runId, + ]; + + yield 'valid fk' => [ + 'convertedData' => [ + ...$log, + 'runId' => $runId, + ], + 'mappings' => [$mapping], + 'expectedLogs' => [], + ]; + + yield 'fk field not in converted data' => [ + 'convertedData' => $log, + 'mappings' => [], + 'expectedLogs' => [], + ]; + + yield 'fk value is null' => [ + 'convertedData' => [ + ...$log, + 'runId' => null, + ], + 'mappings' => [], + 'expectedLogs' => [ + MigrationValidationInvalidOptionalFieldValueLog::class, + ], + ]; + + yield 'fk value is empty string' => [ + 'convertedData' => [ + ...$log, + 'runId' => '', + ], + 'mappings' => [], + 'expectedLogs' => [ + MigrationValidationInvalidOptionalFieldValueLog::class, + ], + ]; + } + public function testMissingTranslationAssociation(): void { $convertedData = [ @@ -332,158 +489,27 @@ public function testValidTranslationAssociation(): void static::assertCount(0, $logClasses); } - public static function entityStructureAndFieldProvider(): \Generator - { - $log = [ - 'id' => Uuid::randomHex(), - 'profileName' => 'profile', - 'gatewayName' => 'gateway', - 'level' => 'error', - 'code' => 'some_code', - 'userFixable' => true, - 'sourceData' => [ - 'some' => 'data', - ], - 'createdAt' => (new \DateTime())->format(\DATE_ATOM), - ]; - - yield 'valid' => [ - $log, - [], - ]; - - yield 'structure - missing required fields' => [ - [ - 'id' => Uuid::randomHex(), - 'level' => 'error', - 'code' => 'some_code', - 'userFixable' => true, - 'createdAt' => (new \DateTime())->format(\DATE_ATOM), - ], - [ - MigrationValidationMissingRequiredFieldLog::class, - MigrationValidationMissingRequiredFieldLog::class, - ], - ]; - - yield 'fields - invalid type' => [ - [ - ...$log, - 'userFixable' => 'not_a_boolean', - ], - [ - MigrationValidationInvalidOptionalFieldValueLog::class, - ], - ]; - - yield 'fields - too long' => [ - [ - ...$log, - 'code' => str_repeat('sw', 128), - ], - [ - MigrationValidationInvalidRequiredFieldValueLog::class, - ], - ]; - - yield 'fields - invalid uuid' => [ - [ - ...$log, - 'id' => 'not-a-uuid', - ], - [ - MigrationValidationExceptionLog::class, - ], - ]; - - yield 'fields - invalid json' => [ - [ - ...$log, - 'sourceData' => "\xB1\x31", - ], - [ - MigrationValidationInvalidOptionalFieldValueLog::class, - ], - ]; - - yield 'structure/field - multiple errors' => [ - [ - 'id' => Uuid::randomHex(), - 'gatewayName' => true, - 'level' => 1, - 'code' => ['sw'], - 'userFixable' => true, - 'createdAt' => (new \DateTime())->format(\DATE_ATOM), - ], - [ - MigrationValidationMissingRequiredFieldLog::class, - MigrationValidationInvalidRequiredFieldValueLog::class, - MigrationValidationInvalidRequiredFieldValueLog::class, - MigrationValidationInvalidRequiredFieldValueLog::class, - ], - ]; - } - - public static function associationProvider(): \Generator + /** + * @param array $convertedData + * @param array $expectedLogs + */ + #[DataProvider('toManyAssociationProvider')] + public function testValidateToManyAssociations(array $convertedData, array $expectedLogs): void { - $log = [ - 'id' => Uuid::randomHex(), - 'profileName' => 'profile', - 'gatewayName' => 'gateway', - 'level' => 'error', - 'code' => 'some_code', - 'userFixable' => true, - 'sourceData' => [ - 'some' => 'data', - ], - 'createdAt' => (new \DateTime())->format(\DATE_ATOM), - ]; - - $runId = Uuid::randomHex(); - $mapping = [ - 'id' => Uuid::randomHex(), - 'connectionId' => self::CONNECTION_ID, - 'entity' => SwagMigrationRunDefinition::ENTITY_NAME, - 'oldIdentifier' => $runId, - 'entityId' => $runId, - ]; - - yield 'valid fk' => [ - [ - ...$log, - 'runId' => $runId, - ], - [$mapping], - [], - ]; + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + $convertedData, + ProductDefinition::ENTITY_NAME, + [] + ); - yield 'fk field not in converted data' => [ - $log, - [], - [], - ]; + static::assertInstanceOf(MigrationValidationResult::class, $result); - yield 'fk value is null' => [ - [ - ...$log, - 'runId' => null, - ], - [], - [ - MigrationValidationInvalidOptionalFieldValueLog::class, - ], - ]; + $logClasses = \array_map(static fn ($log) => $log::class, $result->getLogs()); + \var_dump($logClasses); - yield 'fk value is empty string' => [ - [ - ...$log, - 'runId' => '', - ], - [], - [ - MigrationValidationInvalidOptionalFieldValueLog::class, - ], - ]; + static::assertEquals($expectedLogs, $logClasses); } /*** @@ -503,60 +529,60 @@ public static function toManyAssociationProvider(): \Generator ]; yield 'valid categories association (empty array)' => [ - [ + 'convertedData' => [ ...$baseProduct, 'categories' => [], ], - [], + 'expectedLogs' => [], ]; yield 'valid categories association (with valid entries)' => [ - [ + 'convertedData' => [ ...$baseProduct, 'categories' => [ ['id' => Uuid::randomHex()], ['id' => Uuid::randomHex()], ], ], - [], + 'expectedLogs' => [], ]; yield 'invalid categories association (non-array value)' => [ - [ + 'convertedData' => [ ...$baseProduct, 'categories' => 'not-an-array', ], - [ + 'expectedLogs' => [ MigrationValidationInvalidAssociationLog::class, ], ]; yield 'invalid categories association (entry is not array)' => [ - [ + 'convertedData' => [ ...$baseProduct, 'categories' => [ 'not-an-array-entry', ], ], - [ + 'expectedLogs' => [ MigrationValidationInvalidAssociationLog::class, ], ]; yield 'invalid categories association (invalid UUID in entry)' => [ - [ + 'convertedData' => [ ...$baseProduct, 'categories' => [ ['id' => 'invalid-uuid'], ], ], - [ + 'expectedLogs' => [ MigrationValidationInvalidAssociationLog::class, ], ]; yield 'invalid categories association (multiple errors)' => [ - [ + 'convertedData' => [ ...$baseProduct, 'categories' => [ ['id' => Uuid::randomHex()], @@ -564,7 +590,7 @@ public static function toManyAssociationProvider(): \Generator ['id' => 'invalid-uuid'], ], ], - [ + 'expectedLogs' => [ // Only first error is logged since validation throws on first failure MigrationValidationInvalidAssociationLog::class, ], @@ -575,8 +601,8 @@ public static function toManyAssociationProvider(): \Generator * @param array $convertedData * @param array $expectedLogs */ - #[DataProvider('toManyAssociationProvider')] - public function testValidateToManyAssociations(array $convertedData, array $expectedLogs): void + #[DataProvider('toOneAssociationProvider')] + public function testValidateToOneAssociations(array $convertedData, array $expectedLogs): void { $result = $this->validationService->validate( $this->migrationContext, @@ -593,7 +619,7 @@ public function testValidateToManyAssociations(array $convertedData, array $expe } /** - * @return \Generator, array}> + * @return \Generator, expectedLogs: array}> */ public static function toOneAssociationProvider(): \Generator { @@ -609,60 +635,39 @@ public static function toOneAssociationProvider(): \Generator ]; yield 'valid manufacturer association (null value)' => [ - $baseProduct, - [], + 'convertedData' => $baseProduct, + 'expectedLogs' => [], ]; yield 'valid manufacturer association (with valid id)' => [ - [ + 'convertedData' => [ ...$baseProduct, 'manufacturer' => ['id' => Uuid::randomHex(), 'name' => 'Test Manufacturer'], ], - [], + 'expectedLogs' => [], ]; yield 'invalid manufacturer association (non-array value)' => [ - [ + 'convertedData' => [ ...$baseProduct, 'manufacturer' => 'not-an-array', ], - [ + 'expectedLogs' => [ MigrationValidationInvalidAssociationLog::class, ], ]; yield 'invalid manufacturer association (invalid UUID)' => [ - [ + 'convertedData' => [ ...$baseProduct, 'manufacturer' => ['id' => 'invalid-uuid'], ], - [ + 'expectedLogs' => [ MigrationValidationInvalidAssociationLog::class, ], ]; } - /** - * @param array $convertedData - * @param array $expectedLogs - */ - #[DataProvider('toOneAssociationProvider')] - public function testValidateToOneAssociations(array $convertedData, array $expectedLogs): void - { - $result = $this->validationService->validate( - $this->migrationContext, - $this->context, - $convertedData, - ProductDefinition::ENTITY_NAME, - [] - ); - - static::assertInstanceOf(MigrationValidationResult::class, $result); - - $logClasses = \array_map(static fn ($log) => $log::class, $result->getLogs()); - static::assertEquals($expectedLogs, $logClasses); - } - public function testShouldReturnNullWhenEntityDefinitionDoesNotExist(): void { $result = $this->validationService->validate( diff --git a/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php b/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php index 004178056..e2885fd6e 100644 --- a/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php +++ b/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php @@ -45,9 +45,10 @@ public function testNotExistingEntityDefinitionSkipsValidation(): void ); } - public function testNotExistingField(): void + public function testNotExistingFieldSkipsValidation(): void { - static::expectExceptionObject(MigrationValidationException::entityFieldNotFound('product', 'nonExistingField')); + // Unknown entities are silently skipped + static::expectNotToPerformAssertions(); $this->migrationFieldValidationService->validateField( 'product', From 9583ee23459c9b56e0d463a52f9f423ab989da3a Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Thu, 22 Jan 2026 13:20:12 +0100 Subject: [PATCH 25/26] fix: remove old invalid code --- .../index.ts | 11 ------- ...g-migration-error-resolution-modal.spec.js | 29 ------------------- 2 files changed, 40 deletions(-) diff --git a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/index.ts b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/index.ts index efe0662f8..0c19274f9 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/index.ts +++ b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/index.ts @@ -232,17 +232,6 @@ export default Shopware.Component.wrapComponentConfig({ return false; } - // skip backend validation for to many associations as they use id arrays - // which are not compatible with the dal serializer format - if ( - this.swagMigrationErrorResolutionService.isToManyAssociationField( - this.selectedLog.entityName, - this.selectedLog.fieldName, - ) - ) { - return true; - } - const serializationError = await this.migrationApiService .validateResolution(this.selectedLog.entityName, this.selectedLog.fieldName, this.fieldValue) .catch(() => { diff --git a/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal.spec.js b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal.spec.js index 9a65aa112..a078e6c37 100644 --- a/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal.spec.js +++ b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal.spec.js @@ -546,35 +546,6 @@ describe('module/swag-migration/component/swag-migration-error-resolution/swag-m }); describe('create resolution fix', () => { - it('should not call backend validation when resolving to-many association fields', async () => { - const wrapper = await createWrapper({ - ...defaultProps, - selectedLog: { - ...fixtureLogGroups.at(1), - entityName: 'product', - fieldName: 'categories', - }, - }); - await flushPromises(); - - await wrapper.find('.sw-data-grid__row--1 .mt-field--checkbox input').setChecked(true); - await flushPromises(); - - const relationField = wrapper.findComponent({ name: 'swag-migration-error-resolution-field-relation' }); - await relationField.setData({ - fieldValue: [ - 'category-id-1', - 'category-id-2', - ], - }); - await flushPromises(); - - await wrapper.find('.swag-migration-error-resolution-modal__right-content-button').trigger('click'); - await flushPromises(); - - expect(migrationApiServiceMock.validateResolution).not.toHaveBeenCalled(); - }); - it('should save fix when backend validation passes', async () => { migrationApiServiceMock.validateResolution.mockResolvedValueOnce({ valid: true, violations: [] }); From 3e0ea790d138ee00fa25168bf03146f38c0143c4 Mon Sep 17 00:00:00 2001 From: Lars Kemper Date: Mon, 26 Jan 2026 08:45:40 +0100 Subject: [PATCH 26/26] fix: resolve threads --- src/Controller/ErrorResolutionController.php | 2 +- .../MigrationErrorResolutionService.php | 5 - .../ErrorResolution/MigrationFix.php | 36 +- .../MigrationValidationException.php | 2 +- .../MigrationEntityValidationService.php | 29 +- .../MigrationFieldValidationService.php | 16 +- .../src/module/swag-migration/snippet/de.json | 2 - .../src/module/swag-migration/snippet/en.json | 2 - .../MigrationFieldValidationServiceTest.php | 373 +++++++++++------- 9 files changed, 286 insertions(+), 181 deletions(-) diff --git a/src/Controller/ErrorResolutionController.php b/src/Controller/ErrorResolutionController.php index ec21dc402..ec77b7364 100644 --- a/src/Controller/ErrorResolutionController.php +++ b/src/Controller/ErrorResolutionController.php @@ -79,7 +79,7 @@ public function validateResolution(Request $request, Context $context): JsonResp 'valid' => false, 'violations' => [['message' => $exception->getMessage()]], ]); - } catch (\Exception $exception) { + } catch (\Throwable $exception) { return new JsonResponse([ 'valid' => false, 'violations' => [['message' => $exception->getMessage()]], diff --git a/src/Migration/ErrorResolution/MigrationErrorResolutionService.php b/src/Migration/ErrorResolution/MigrationErrorResolutionService.php index 63cdf75ff..b864a5f67 100644 --- a/src/Migration/ErrorResolution/MigrationErrorResolutionService.php +++ b/src/Migration/ErrorResolution/MigrationErrorResolutionService.php @@ -9,7 +9,6 @@ use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Exception; use Shopware\Core\Framework\Context; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Uuid\Uuid; @@ -31,8 +30,6 @@ public function __construct( /** * @param array> $data - * - * @throws Exception */ public function applyFixes(array &$data, string $connectionId, string $runId, Context $context): void { @@ -83,8 +80,6 @@ public function applyFixes(array &$data, string $connectionId, string $runId, Co /** * Loads fixes from the database and populates them in the context. - * - * @throws Exception */ private function loadFixes(MigrationErrorResolutionContext $errorResolutionContext): void { diff --git a/src/Migration/ErrorResolution/MigrationFix.php b/src/Migration/ErrorResolution/MigrationFix.php index aeacf19d4..341c2f6e9 100644 --- a/src/Migration/ErrorResolution/MigrationFix.php +++ b/src/Migration/ErrorResolution/MigrationFix.php @@ -28,6 +28,8 @@ public function __construct( /** * @param array $data + * + * @throws MigrationException */ public static function fromDatabaseQuery(array $data): self { @@ -51,6 +53,11 @@ public static function fromDatabaseQuery(array $data): self */ public function apply(array &$item): void { + /** + * Explode the path to an array + * Path example: 'category.language.name' + * Results in an array like: ['category', 'language', 'name'] + */ $pathArray = explode(self::PATH_SEPARATOR, $this->path); $decodedValue = \json_decode($this->value, true, 512, \JSON_THROW_ON_ERROR); @@ -62,38 +69,41 @@ public function apply(array &$item): void * When encountering a list (numerically-indexed array), applies the fix to all items. * * @param array $data - * @param array $remainingPath + * @param array $path */ - private function applyToPath(array &$data, array $remainingPath, mixed $value): void + private function applyToPath(array &$data, array $path, mixed $value): void { - if (empty($remainingPath)) { + if (empty($path)) { return; } - $key = \array_shift($remainingPath); + $nextSegment = \array_shift($path); // last segment of the path, "normal" set operation - if (empty($remainingPath)) { - $data[$key] = $value; + if (empty($path)) { + $data[$nextSegment] = $value; return; } - // key points to a list, apply to all items in the list - if (isset($data[$key]) && \is_array($data[$key]) && \array_is_list($data[$key])) { - foreach ($data[$key] as &$arrayItem) { + $nextSegmentIsList = isset($data[$nextSegment]) + && \is_array($data[$nextSegment]) + && \array_is_list($data[$nextSegment]); + + if ($nextSegmentIsList) { + foreach ($data[$nextSegment] as &$arrayItem) { if (\is_array($arrayItem)) { - $this->applyToPath($arrayItem, $remainingPath, $value); + $this->applyToPath($arrayItem, $path, $value); } } return; } - if (!isset($data[$key]) || !\is_array($data[$key])) { - $data[$key] = []; + if (!isset($data[$nextSegment]) || !\is_array($data[$nextSegment])) { + $data[$nextSegment] = []; } - $this->applyToPath($data[$key], $remainingPath, $value); + $this->applyToPath($data[$nextSegment], $path, $value); } } diff --git a/src/Migration/Validation/Exception/MigrationValidationException.php b/src/Migration/Validation/Exception/MigrationValidationException.php index 8fa6217e7..201611f34 100644 --- a/src/Migration/Validation/Exception/MigrationValidationException.php +++ b/src/Migration/Validation/Exception/MigrationValidationException.php @@ -46,7 +46,7 @@ public static function invalidId(string $entityId, string $entityName): self return new self( Response::HTTP_INTERNAL_SERVER_ERROR, self::VALIDATION_INVALID_ID, - 'The id "{{ entityId }}" for entity "{{ entityName }}" is not a valid Uuid', + 'The id "{{ entityId }}" for entity "{{ entityName }}" is not a valid UUID', ['entityId' => $entityId, 'entityName' => $entityName] ); } diff --git a/src/Migration/Validation/MigrationEntityValidationService.php b/src/Migration/Validation/MigrationEntityValidationService.php index 65f1f5d74..4a514f32b 100644 --- a/src/Migration/Validation/MigrationEntityValidationService.php +++ b/src/Migration/Validation/MigrationEntityValidationService.php @@ -8,7 +8,6 @@ namespace SwagMigrationAssistant\Migration\Validation; use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Exception; use Shopware\Core\Framework\Context; use Shopware\Core\Framework\DataAbstractionLayer\CompiledFieldCollection; use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry; @@ -49,7 +48,7 @@ class MigrationEntityValidationService implements ResetInterface { /** - * System managed that managed by Shopware and should not be validated as required fields + * System managed fields that are managed by Shopware and should not be validated as required fields * * @var list> */ @@ -92,8 +91,6 @@ public function reset(): void /** * @param array|null $convertedEntity * @param array $sourceData - * - * @throws \Exception */ public function validate( MigrationContextInterface $migrationContext, @@ -126,7 +123,7 @@ public function validate( try { $this->validateEntityStructure($validationContext); - $this->validateFieldValues($validationContext); + $this->validateRootEntityFields($validationContext); } catch (\Throwable $exception) { $this->addExceptionLog($validationContext, $exception); } @@ -146,8 +143,6 @@ public function validate( /** * Validates that all required fields are present in the converted data. - * - * @throws \Exception|Exception */ private function validateEntityStructure(MigrationValidationContext $validationContext): void { @@ -169,11 +164,9 @@ private function validateEntityStructure(MigrationValidationContext $validationC } /** - * Validates the values of each field in the converted data and its nested associations. - * - * @throws \Exception|Exception + * Validates all fields of the root entity, including nested associations. */ - private function validateFieldValues(MigrationValidationContext $validationContext): void + private function validateRootEntityFields(MigrationValidationContext $validationContext): void { $convertedData = $validationContext->getConvertedData(); $id = $convertedData['id'] ?? null; @@ -216,8 +209,6 @@ private function validateFieldValues(MigrationValidationContext $validationConte * Recursively validates nested entities within association fields. * * @param array|mixed $value - * - * @throws Exception */ private function validateNestedAssociations( MigrationValidationContext $validationContext, @@ -240,7 +231,7 @@ private function validateNestedAssociations( : $field->getReferenceDefinition(); foreach ($value as $nestedEntityData) { - $this->validateNestedEntityData( + $this->validateNestedEntityFields( $validationContext, $referenceDefinition, $nestedEntityData, @@ -252,7 +243,7 @@ private function validateNestedAssociations( } if ($field instanceof ManyToOneAssociationField || $field instanceof OneToOneAssociationField) { - $this->validateNestedEntityData( + $this->validateNestedEntityFields( $validationContext, $field->getReferenceDefinition(), $value, @@ -265,10 +256,8 @@ private function validateNestedAssociations( * Validates a single nested entity's fields and recurses into deeper associations. * * @param array|mixed $nestedEntityData - * - * @throws Exception|\Exception */ - private function validateNestedEntityData( + private function validateNestedEntityFields( MigrationValidationContext $validationContext, EntityDefinition $referenceDefinition, mixed $nestedEntityData, @@ -349,8 +338,6 @@ private function validateId(MigrationValidationContext $validationContext, strin * - It is not a system managed field * - Its corresponding database column is non-nullable without a default value * - * @throws Exception - * * @return array */ private function getRequiredFields(CompiledFieldCollection $fields, string $entityName): array @@ -382,8 +369,6 @@ private function getRequiredFields(CompiledFieldCollection $fields, string $enti } /** - * @throws Exception - * * @return array */ private function getRequiredDatabaseColumns(string $entityName): array diff --git a/src/Migration/Validation/MigrationFieldValidationService.php b/src/Migration/Validation/MigrationFieldValidationService.php index cde842595..9043e5d5e 100644 --- a/src/Migration/Validation/MigrationFieldValidationService.php +++ b/src/Migration/Validation/MigrationFieldValidationService.php @@ -43,7 +43,7 @@ public function __construct( * Supports nested field paths like "prices.shippingMethodId". * Silently skips validation for unknown entities & fields. * - * @throws MigrationValidationException|\Exception + * @throws MigrationValidationException */ public function validateField( string $entityName, @@ -80,8 +80,6 @@ public function validateField( * Resolves a potentially nested field path (e.g., "prices.shippingMethodId") to its target. * Traverses through association fields to find the final entity definition and field. * - * @throws \Exception - * * @return array{EntityDefinition, Field}|null */ public function resolveFieldPath(string $entityName, string $fieldPath): ?array @@ -171,6 +169,9 @@ private function validateScalarField( $this->validateFieldByFieldSerializer($field, $value, $isRequired, $existence, $parameters); } + /** + * @throws MigrationValidationException + */ private function validateFieldByFieldSerializer( Field $field, mixed $value, @@ -229,6 +230,9 @@ private function validateFieldByFieldSerializer( } } + /** + * @throws MigrationValidationException + */ private function validateToManyAssociationStructure(Field $field, mixed $value, string $entityName): void { if (!\is_array($value)) { @@ -258,6 +262,9 @@ private function validateToManyAssociationStructure(Field $field, mixed $value, } } + /** + * @throws MigrationValidationException + */ private function validateToOneAssociationStructure(Field $field, mixed $value, string $entityName): void { if (!\is_array($value)) { @@ -277,6 +284,9 @@ private function validateToOneAssociationStructure(Field $field, mixed $value, s } } + /** + * @throws MigrationValidationException + */ private function validateTranslationAssociationStructure(TranslationsAssociationField $field, mixed $value, string $entityName): void { if (!\is_array($value)) { diff --git a/src/Resources/app/administration/src/module/swag-migration/snippet/de.json b/src/Resources/app/administration/src/module/swag-migration/snippet/de.json index 020f0a63e..dc8e4d1c5 100644 --- a/src/Resources/app/administration/src/module/swag-migration/snippet/de.json +++ b/src/Resources/app/administration/src/module/swag-migration/snippet/de.json @@ -628,7 +628,6 @@ "SWAG_MIGRATION_VALIDATION_INVALID_OPTIONAL_FIELD_VALUE": "Optionales Feld hat einen ungültigen Wert", "SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_TRANSLATION": "Erforderliche Übersetzung ist ungültig", "SWAG_MIGRATION_VALIDATION_MISSING_REQUIRED_FIELD": "Erforderliches Feld fehlt", - "SWAG_MIGRATION_VALIDATION_UNEXPECTED_FIELD": "Unerwartetes Feld in Daten gefunden", "SWAG_MIGRATION__DEACTIVATED_PACK_LANGUAGE": "Pack-Sprache ist deaktiviert", "SWAG_MIGRATION__INVALID_EMAIL_ADDRESS": "Ungültige E-Mail-Adresse", "SWAG_MIGRATION__SHOPWARE_UNSUPPORTED_MAIL_TEMPLATE_TYPE": "E-Mail-Template-Typ nicht unterstützt", @@ -652,7 +651,6 @@ "fetchExistingFixesFailed": "Das Abrufen vorhandener Korrekturen ist fehlgeschlagen.", "fetchExampleFailed": "Beispieldaten konnten nicht abgerufen werden.", "validationFailed": "Validierung fehlgeschlagen.", - "invalidJsonInput": "Diese Eingabe ist kein gültiges JSON.", "resetResolutionFailed": "Fehler beim Zurücksetzen der Fehlerbehebung." }, "step": { diff --git a/src/Resources/app/administration/src/module/swag-migration/snippet/en.json b/src/Resources/app/administration/src/module/swag-migration/snippet/en.json index 4142f26e9..cbbacd83a 100644 --- a/src/Resources/app/administration/src/module/swag-migration/snippet/en.json +++ b/src/Resources/app/administration/src/module/swag-migration/snippet/en.json @@ -479,7 +479,6 @@ "SWAG_MIGRATION_VALIDATION_INVALID_OPTIONAL_FIELD_VALUE": "Optional field has an invalid value", "SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_TRANSLATION": "Required translation is invalid", "SWAG_MIGRATION_VALIDATION_MISSING_REQUIRED_FIELD": "Required field is missing", - "SWAG_MIGRATION_VALIDATION_UNEXPECTED_FIELD": "Unexpected field found in data", "SWAG_MIGRATION__DEACTIVATED_PACK_LANGUAGE": "Pack language is deactivated", "SWAG_MIGRATION__INVALID_EMAIL_ADDRESS": "Invalid email address", "SWAG_MIGRATION__SHOPWARE_UNSUPPORTED_MAIL_TEMPLATE_TYPE": "Unsupported mail template type", @@ -501,7 +500,6 @@ "noEntityIdsFound": "No valid entity IDs found in the selected log entries.", "fetchFilterDataFailed": "Failed fetching filter data.", "fetchExistingFixesFailed": "Failed fetching existing fixes.", - "invalidJsonInput": "This input is not valid JSON.", "resetResolutionFailed": "Failed resetting error resolution.", "fetchExampleFailed": "Failed fetching example data.", "validationFailed": "Validation failed." diff --git a/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php b/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php index e2885fd6e..c23d33d5a 100644 --- a/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php +++ b/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php @@ -8,11 +8,13 @@ namespace SwagMigrationAssistant\Test\integration\Migration\Validation; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Shopware\Core\Defaults; use Shopware\Core\Framework\Context; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; +use Shopware\Core\Framework\Uuid\Uuid; use SwagMigrationAssistant\Migration\Validation\Exception\MigrationValidationException; use SwagMigrationAssistant\Migration\Validation\MigrationFieldValidationService; @@ -32,40 +34,57 @@ protected function setUp(): void $this->migrationFieldValidationService = static::getContainer()->get(MigrationFieldValidationService::class); } - public function testNotExistingEntityDefinitionSkipsValidation(): void - { - // Unknown entities are silently skipped - static::expectNotToPerformAssertions(); + /** + * @param class-string<\Throwable>|null $exception + */ + #[DataProvider('validateFieldProvider')] + public function testValidateField( + string $entityName, + string $fieldName, + mixed $value, + ?string $exception, + ): void { + if ($exception) { + static::expectException($exception); + } else { + static::expectNotToPerformAssertions(); + } $this->migrationFieldValidationService->validateField( - 'unknown_entity', - 'field', - 'value', + $entityName, + $fieldName, + $value, Context::createDefaultContext(), ); } - public function testNotExistingFieldSkipsValidation(): void + public static function validateFieldProvider(): \Generator { - // Unknown entities are silently skipped - static::expectNotToPerformAssertions(); - - $this->migrationFieldValidationService->validateField( - 'product', - 'nonExistingField', - 'value', - Context::createDefaultContext(), - ); - } - - public function testValidPriceField(): void - { - static::expectNotToPerformAssertions(); - - $this->migrationFieldValidationService->validateField( - 'product', - 'price', - [ + yield 'not existing entity' => [ + 'entityName' => 'unknown_entity', + 'fieldName' => 'name', + 'value' => 'value', + 'exception' => null, + ]; + + yield 'not existing field' => [ + 'entityName' => 'product', + 'fieldName' => 'nonExistingField', + 'value' => 'value', + 'exception' => null, + ]; + + yield 'valid string field' => [ + 'entityName' => 'product', + 'fieldName' => 'name', + 'value' => 'Valid Product Name', + 'exception' => null, + ]; + + yield 'valid price field' => [ + 'entityName' => 'product', + 'fieldName' => 'price', + 'value' => [ [ 'currencyId' => Defaults::CURRENCY, 'gross' => 100.0, @@ -73,18 +92,13 @@ public function testValidPriceField(): void 'linked' => true, ], ], - Context::createDefaultContext(), - ); - } + 'exception' => null, + ]; - public function testInvalidPriceFieldGrossType(): void - { - static::expectException(MigrationValidationException::class); - - $this->migrationFieldValidationService->validateField( - 'product', - 'price', - [ + yield 'invalid price field (gross type)' => [ + 'entityName' => 'product', + 'fieldName' => 'price', + 'value' => [ [ 'currencyId' => Defaults::CURRENCY, 'gross' => 'invalid', // should be numeric @@ -92,18 +106,13 @@ public function testInvalidPriceFieldGrossType(): void 'linked' => true, ], ], - Context::createDefaultContext(), - ); - } - - public function testInvalidPriceFieldMissingNet(): void - { - static::expectException(MigrationValidationException::class); + 'exception' => MigrationValidationException::class, + ]; - $this->migrationFieldValidationService->validateField( - 'product', - 'price', - [ + yield 'invalid price field (missing net)' => [ + 'entityName' => 'product', + 'fieldName' => 'price', + 'value' => [ [ 'currencyId' => Defaults::CURRENCY, 'gross' => 100.0, @@ -111,18 +120,13 @@ public function testInvalidPriceFieldMissingNet(): void 'linked' => true, ], ], - Context::createDefaultContext(), - ); - } - - public function testInvalidPriceFieldCurrencyId(): void - { - static::expectException(MigrationValidationException::class); + 'exception' => MigrationValidationException::class, + ]; - $this->migrationFieldValidationService->validateField( - 'product', - 'price', - [ + yield 'invalid price field (invalid currencyId)' => [ + 'entityName' => 'product', + 'fieldName' => 'price', + 'value' => [ [ 'currencyId' => 'not-a-valid-uuid', 'gross' => 100.0, @@ -130,82 +134,187 @@ public function testInvalidPriceFieldCurrencyId(): void 'linked' => true, ], ], - Context::createDefaultContext(), - ); - } - - public function testResolveFieldPathSimpleField(): void - { - $result = $this->migrationFieldValidationService->resolveFieldPath('product', 'name'); - - static::assertNotNull($result); - static::assertCount(2, $result); - static::assertSame('product', $result[0]->getEntityName()); - static::assertSame('name', $result[1]->getPropertyName()); - } - - public function testResolveFieldPathNestedField(): void - { - $result = $this->migrationFieldValidationService->resolveFieldPath('shipping_method', 'prices.shippingMethodId'); - - static::assertNotNull($result); - static::assertCount(2, $result); - static::assertSame('shipping_method_price', $result[0]->getEntityName()); - static::assertSame('shippingMethodId', $result[1]->getPropertyName()); - } - - public function testResolveFieldPathDeeplyNested(): void - { - $result = $this->migrationFieldValidationService->resolveFieldPath('product', 'categories.media.alt'); - - static::assertNotNull($result); - static::assertCount(2, $result); - static::assertSame('media', $result[0]->getEntityName()); - static::assertSame('alt', $result[1]->getPropertyName()); - } - - public function testResolveFieldPathUnknownEntity(): void - { - $result = $this->migrationFieldValidationService->resolveFieldPath('unknown_entity', 'field'); - - static::assertNull($result); - } - - public function testResolveFieldPathUnknownField(): void - { - $result = $this->migrationFieldValidationService->resolveFieldPath('product', 'unknownField'); - - static::assertNull($result); - } - - public function testResolveFieldPathUnknownNestedField(): void - { - $result = $this->migrationFieldValidationService->resolveFieldPath('shipping_method', 'prices.unknownField'); - - static::assertNull($result); + 'exception' => MigrationValidationException::class, + ]; + + yield 'valid UUID field' => [ + 'entityName' => 'product', + 'fieldName' => 'id', + 'value' => Uuid::randomHex(), + 'exception' => null, + ]; + + yield 'invalid UUID field' => [ + 'entityName' => 'product', + 'fieldName' => 'id', + 'value' => 'invalid-uuid', + 'exception' => MigrationValidationException::class, + ]; + + yield 'valid nested field' => [ + 'entityName' => 'shipping_method', + 'fieldName' => 'prices.shippingMethodId', + 'value' => Uuid::randomHex(), + 'exception' => null, + ]; + + yield 'invalid nested field' => [ + 'entityName' => 'shipping_method', + 'fieldName' => 'prices.shippingMethodId', + 'value' => 'invalid-uuid', + 'exception' => MigrationValidationException::class, + ]; + + yield 'valid translation field' => [ + 'entityName' => 'product', + 'fieldName' => 'translations', + 'value' => ['en-GB' => [], 'de-DE' => []], + 'exception' => null, + ]; + + yield 'invalid translation field (non array)' => [ + 'entityName' => 'product', + 'fieldName' => 'translations', + 'value' => 'not-an-array', + 'exception' => MigrationValidationException::class, + ]; + + yield 'invalid translation field (non translations)' => [ + 'entityName' => 'product', + 'fieldName' => 'translations', + 'value' => ['en-GB' => null, 'de-DE' => []], + 'exception' => MigrationValidationException::class, + ]; + + yield 'valid to many field' => [ + 'entityName' => 'product', + 'fieldName' => 'tags', + 'value' => [ + ['id' => Uuid::randomHex()], + ['id' => Uuid::randomHex()], + ], + 'exception' => null, + ]; + + yield 'invalid to many field (non array)' => [ + 'entityName' => 'product', + 'fieldName' => 'tags', + 'value' => 'not-an-array', + 'exception' => MigrationValidationException::class, + ]; + + yield 'invalid to many field (non array child)' => [ + 'entityName' => 'product', + 'fieldName' => 'tags', + 'value' => [ + ['id' => Uuid::randomHex()], + 'not-an-array', + ], + 'exception' => MigrationValidationException::class, + ]; + + yield 'invalid to many field (invalid uuid)' => [ + 'entityName' => 'product', + 'fieldName' => 'tags', + 'value' => [ + ['id' => Uuid::randomHex()], + ['id' => 'invalid-uuid'], + ], + 'exception' => MigrationValidationException::class, + ]; + + yield 'valid to one field' => [ + 'entityName' => 'product', + 'fieldName' => 'tax', + 'value' => [ + 'id' => Uuid::randomHex(), + ], + 'exception' => null, + ]; + + yield 'invalid to one field (non array)' => [ + 'entityName' => 'product', + 'fieldName' => 'tax', + 'value' => 'not-an-array', + 'exception' => MigrationValidationException::class, + ]; + + yield 'invalid to one field (invalid id)' => [ + 'entityName' => 'product', + 'fieldName' => 'tax', + 'value' => [ + 'id' => 'invalid-uuid', + ], + 'exception' => MigrationValidationException::class, + ]; } - public function testValidateNestedField(): void - { - static::expectNotToPerformAssertions(); - - $this->migrationFieldValidationService->validateField( - 'shipping_method', - 'prices.shippingMethodId', - 'a5d7a3b4c5d6e7f8a9b0c1d2e3f4a5b6', - Context::createDefaultContext(), - ); + /** + * @param array{entityName: string, propertyName: string}|null $expectedResult + */ + #[DataProvider('resolveFieldPathProvider')] + public function testResolveFieldPath( + string $entityName, + string $fieldName, + ?array $expectedResult, + ): void { + $result = $this->migrationFieldValidationService->resolveFieldPath($entityName, $fieldName); + + if ($expectedResult !== null) { + static::assertNotNull($result); + static::assertCount(2, $result); + static::assertSame($expectedResult['entityName'], $result[0]->getEntityName()); + static::assertSame($expectedResult['propertyName'], $result[1]->getPropertyName()); + } else { + static::assertNull($result); + } } - public function testValidateNestedFieldInvalid(): void + public static function resolveFieldPathProvider(): \Generator { - static::expectException(MigrationValidationException::class); - - $this->migrationFieldValidationService->validateField( - 'shipping_method', - 'prices.shippingMethodId', - 'not-a-valid-uuid', - Context::createDefaultContext(), - ); + yield 'simple field' => [ + 'entityName' => 'product', + 'fieldName' => 'name', + 'expectedResult' => [ + 'entityName' => 'product', + 'propertyName' => 'name', + ], + ]; + + yield 'nested field' => [ + 'entityName' => 'shipping_method', + 'fieldName' => 'prices.shippingMethodId', + 'expectedResult' => [ + 'entityName' => 'shipping_method_price', + 'propertyName' => 'shippingMethodId', + ], + ]; + + yield 'deeply nested field' => [ + 'entityName' => 'product', + 'fieldName' => 'categories.media.alt', + 'expectedResult' => [ + 'entityName' => 'media', + 'propertyName' => 'alt', + ], + ]; + + yield 'unknown entity' => [ + 'entityName' => 'unknown_entity', + 'fieldName' => 'field', + 'expectedResult' => null, + ]; + + yield 'unknown field' => [ + 'entityName' => 'product', + 'fieldName' => 'unknownField', + 'expectedResult' => null, + ]; + + yield 'unknown nested field' => [ + 'entityName' => 'shipping_method', + 'fieldName' => 'prices.unknownField', + 'expectedResult' => null, + ]; } }