diff --git a/src/DependencyInjection/migration.xml b/src/DependencyInjection/migration.xml index b008b1b85..c375ecf6a 100644 --- a/src/DependencyInjection/migration.xml +++ b/src/DependencyInjection/migration.xml @@ -425,7 +425,9 @@ - + + + diff --git a/src/Exception/MigrationException.php b/src/Exception/MigrationException.php index ff1c28442..4b5cffb2f 100644 --- a/src/Exception/MigrationException.php +++ b/src/Exception/MigrationException.php @@ -87,15 +87,11 @@ 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'; - - public const DUPLICATE_SOURCE_CONNECTION = 'SWAG_MIGRATION__DUPLICATE_SOURCE_CONNECTION'; + 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/Mapping/MappingService.php b/src/Migration/Mapping/MappingService.php index d84ab7b04..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,22 +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 - { - $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/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/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/MigrationValidationInvalidFieldValueLog.php b/src/Migration/Validation/Log/MigrationValidationInvalidAssociationLog.php similarity index 80% rename from src/Migration/Validation/Log/MigrationValidationInvalidFieldValueLog.php rename to src/Migration/Validation/Log/MigrationValidationInvalidAssociationLog.php index 5b5340165..ce1ffd980 100644 --- a/src/Migration/Validation/Log/MigrationValidationInvalidFieldValueLog.php +++ b/src/Migration/Validation/Log/MigrationValidationInvalidAssociationLog.php @@ -11,7 +11,7 @@ use SwagMigrationAssistant\Migration\Logging\Log\Builder\AbstractMigrationLogEntry; #[Package('fundamentals@after-sales')] -readonly class MigrationValidationInvalidFieldValueLog extends AbstractMigrationLogEntry +readonly class MigrationValidationInvalidAssociationLog 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_ASSOCIATION'; } } 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/MigrationValidationUnexpectedFieldLog.php b/src/Migration/Validation/Log/MigrationValidationInvalidRequiredFieldValueLog.php similarity index 74% rename from src/Migration/Validation/Log/MigrationValidationUnexpectedFieldLog.php rename to src/Migration/Validation/Log/MigrationValidationInvalidRequiredFieldValueLog.php index d363bf846..9a4cc1b38 100644 --- a/src/Migration/Validation/Log/MigrationValidationUnexpectedFieldLog.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 MigrationValidationUnexpectedFieldLog extends AbstractMigrationLogEntry +readonly class MigrationValidationInvalidRequiredFieldValueLog extends AbstractMigrationLogEntry { public function isUserFixable(): bool { @@ -20,11 +20,11 @@ public function isUserFixable(): bool public function getLevel(): string { - return self::LOG_LEVEL_WARNING; + return self::LOG_LEVEL_ERROR; } public function getCode(): string { - return 'SWAG_MIGRATION_VALIDATION_UNEXPECTED_FIELD'; + 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/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 e1d939c2a..79b3ba14c 100644 --- a/src/Migration/Validation/MigrationValidationService.php +++ b/src/Migration/Validation/MigrationValidationService.php @@ -7,11 +7,24 @@ 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\FkField; 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; @@ -19,37 +32,70 @@ 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\Mapping\MappingServiceInterface; 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\MigrationValidationInvalidFieldValueLog; -use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidForeignKeyLog; +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\Migration\Validation\Log\MigrationValidationUnexpectedFieldLog; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\Service\ResetInterface; /** * @internal */ #[Package('fundamentals@after-sales')] -readonly class MigrationValidationService +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. + * + * Example: + * [ + * 'entity_name' => [ + * 'required_field_name' => true, + * ], + * ] + * + * @var array> + */ + private array $requiredDefinitionFieldsCache = []; + 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 Connection $connection, ) { } + public function reset(): void + { + $this->requiredDefinitionFieldsCache = []; + } + /** * @param array|null $convertedEntity * @param array $sourceData + * + * @throws \Exception|Exception */ public function validate( MigrationContextInterface $migrationContext, @@ -62,6 +108,10 @@ public function validate( return null; } + if (!$this->definitionRegistry->has($entityName)) { + return null; + } + $entityDefinition = $this->definitionRegistry->getByEntityName($entityName); $validationContext = new MigrationValidationContext( @@ -76,22 +126,8 @@ public function validate( new MigrationPreValidationEvent($validationContext), ); - try { - $this->validateEntityStructure($validationContext); - $this->validateFields($validationContext); - $this->validateAssociations($validationContext); - } catch (\Throwable $exception) { - $validationContext->getValidationResult()->addLog( - MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) - ->withEntityName($validationContext->getEntityDefinition()->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), @@ -106,150 +142,363 @@ public function validate( return $validationContext->getValidationResult(); } + /** + * @throws \Exception|Exception + */ private function validateEntityStructure(MigrationValidationContext $validationContext): void { - $fields = $validationContext->getEntityDefinition()->getFields(); + $entityDefinition = $validationContext->getEntityDefinition(); - $requiredFields = array_values(array_map( - static fn (Field $field) => $field->getPropertyName(), - $fields->filterByFlag(Required::class)->getElements() - )); + $requiredFields = $this->getRequiredFields( + $entityDefinition->getFields(), + $entityDefinition->getEntityName() + ); - $convertedFieldNames = array_keys($validationContext->getConvertedData()); - $missingRequiredFields = array_diff($requiredFields, $convertedFieldNames); + $missingRequiredFields = array_diff( + array_keys($requiredFields), + array_keys($validationContext->getConvertedData()) + ); foreach ($missingRequiredFields as $missingField) { - $validationContext->getValidationResult()->addLog( - MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) - ->withEntityName($validationContext->getEntityDefinition()->getEntityName()) - ->withFieldName($missingField) - ->withConvertedData($validationContext->getConvertedData()) - ->withEntityId($validationContext->getConvertedData()['id'] ?? null) - ->build(MigrationValidationMissingRequiredFieldLog::class) - ); + $this->addMissingRequiredFieldLog($validationContext, $missingField); + } + } + + /** + * @throws \Exception|Exception + */ + private function validateFieldValues(MigrationValidationContext $validationContext): void + { + $convertedData = $validationContext->getConvertedData(); + $id = $convertedData['id'] ?? null; + + if (!$this->validateId($validationContext, $id)) { + return; } - $unexpectedFields = array_diff($convertedFieldNames, array_keys($fields->getElements())); + $entityDefinition = $validationContext->getEntityDefinition(); + $entityName = $entityDefinition->getEntityName(); + $fields = $entityDefinition->getFields(); - foreach ($unexpectedFields as $unexpectedField) { - $validationContext->getValidationResult()->addLog( - MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) - ->withEntityName($validationContext->getEntityDefinition()->getEntityName()) - ->withFieldName($unexpectedField) - ->withConvertedData($validationContext->getConvertedData()) - ->withEntityId($validationContext->getConvertedData()['id'] ?? null) - ->build(MigrationValidationUnexpectedFieldLog::class) + $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 validateFields(MigrationValidationContext $validationContext): void + private function validateId(MigrationValidationContext $validationContext, mixed $id): bool { - $fields = $validationContext->getEntityDefinition()->getFields(); + if ($id === null) { + $this->addExceptionLog( + $validationContext, + MigrationValidationException::unexpectedNullValue('id') + ); - if (!isset($validationContext->getConvertedData()['id'])) { - throw MigrationException::unexpectedNullValue('id'); + return false; } - if (!Uuid::isValid($validationContext->getConvertedData()['id'])) { - throw MigrationException::invalidId($validationContext->getConvertedData()['id'], $validationContext->getEntityDefinition()->getEntityName()); + if (!\is_string($id) || !Uuid::isValid($id)) { + $this->addExceptionLog( + $validationContext, + MigrationValidationException::invalidId((string) $id, $validationContext->getEntityDefinition()->getEntityName()) + ); + + return false; } - $entityExistence = EntityExistence::createForEntity( - $validationContext->getEntityDefinition()->getEntityName(), - ['id' => $validationContext->getConvertedData()['id']], - ); + return true; + } - $parameters = new WriteParameterBag( - $validationContext->getEntityDefinition(), - WriteContext::createFromContext($validationContext->getContext()), - '', - new WriteCommandQueue(), + 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 ); - foreach ($validationContext->getConvertedData() 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) { + $entityName = $parameters->getDefinition()->getEntityName(); + $propertyName = $field->getPropertyName(); + + if ($field instanceof TranslationsAssociationField) { + throw MigrationValidationException::invalidTranslation($entityName, $propertyName, $e->getMessage()); } - $field = clone $fields->get($fieldName); - $field->setFlags(new Required()); + if ($isRequired) { + throw MigrationValidationException::invalidRequiredFieldValue($entityName, $propertyName, $e->getMessage()); + } - $keyValue = new KeyValuePair( - $field->getPropertyName(), - $value, - true + 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)) + ); + } - try { - $serializer = $field->getSerializer(); - \iterator_to_array($serializer->encode($field, $entityExistence, $keyValue, $parameters), false); - } catch (\Throwable $e) { - $validationContext->getValidationResult()->addLog( - MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) - ->withEntityName($validationContext->getEntityDefinition()->getEntityName()) - ->withFieldName($fieldName) - ->withConvertedData([$fieldName => $value]) - ->withSourceData($validationContext->getSourceData()) - ->withExceptionMessage($e->getMessage()) - ->withExceptionTrace($e->getTrace()) - ->withEntityId($validationContext->getConvertedData()['id'] ?? null) - ->build(MigrationValidationInvalidFieldValueLog::class) + 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) ); } } } - private function validateAssociations(MigrationValidationContext $validationContext): void + /** + * @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 + * + * @return array + */ + private function getRequiredFields(CompiledFieldCollection $fields, string $entityName): array { - $fields = $validationContext->getEntityDefinition()->getFields(); + if (isset($this->requiredDefinitionFieldsCache[$entityName])) { + return $this->requiredDefinitionFieldsCache[$entityName]; + } - $fkFields = array_values(array_map( - static fn (Field $field) => $field->getPropertyName(), - $fields->filterInstance(FkField::class)->getElements() - )); + $requiredDbColumns = $this->getRequiredDatabaseColumns($entityName); + $requiredFields = []; - foreach ($fkFields as $fkFieldName) { - if (!isset($validationContext->getConvertedData()[$fkFieldName])) { + foreach ($fields->filterByFlag(Required::class) as $field) { + if (\in_array($field::class, self::SYSTEM_MANAGED_FIELDS, true)) { continue; } - $fkValue = $validationContext->getConvertedData()[$fkFieldName]; + if (!($field instanceof StorageAware)) { + $requiredFields[$field->getPropertyName()] = true; - if ($fkValue === '') { continue; } - $fkField = $fields->get($fkFieldName); - - if (!$fkField instanceof FkField) { - throw MigrationException::unexpectedNullValue($fkFieldName); + if (isset($requiredDbColumns[$field->getStorageName()])) { + $requiredFields[$field->getPropertyName()] = true; } + } - $referenceEntity = $fkField->getReferenceEntity(); + return $this->requiredDefinitionFieldsCache[$entityName] = $requiredFields; + } - if (!$referenceEntity) { - throw MigrationException::unexpectedNullValue($fkFieldName); - } + /** + * @throws Exception + * + * @return array + */ + private function getRequiredDatabaseColumns(string $entityName): array + { + $requiredColumns = []; - $hasMapping = $this->mappingService->hasValidMappingByEntityId( - $validationContext->getMigrationContext()->getConnection()->getId(), - $referenceEntity, - $fkValue, - $validationContext->getContext() - ); + $columns = $this->connection + ->createSchemaManager() + ->listTableColumns($entityName); - if (!$hasMapping) { - $validationContext->getValidationResult()->addLog( - MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) - ->withEntityName($validationContext->getEntityDefinition()->getEntityName()) - ->withFieldName($fkFieldName) - ->withConvertedData([$fkFieldName => $fkValue]) - ->withSourceData($validationContext->getSourceData()) - ->withEntityId($validationContext->getConvertedData()['id'] ?? null) - ->build(MigrationValidationInvalidForeignKeyLog::class) - ); + foreach ($columns as $column) { + if ($column->getNotnull() && $column->getDefault() === null && !$column->getAutoincrement()) { + $requiredColumns[$column->getName()] = true; } } + + 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, + string $fieldName, + mixed $value, + string $entityId, + ): void { + $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($validationContext->getEntityDefinition()->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 + { + $convertedData = $validationContext->getConvertedData(); + $entityId = isset($convertedData['id']) ? (string) $convertedData['id'] : null; + + $validationContext->getValidationResult()->addLog( + MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) + ->withEntityName($validationContext->getEntityDefinition()->getEntityName()) + ->withSourceData($validationContext->getSourceData()) + ->withConvertedData($convertedData) + ->withExceptionMessage($exception->getMessage()) + ->withExceptionTrace($exception->getTrace()) + ->withEntityId($entityId) + ->build(MigrationValidationExceptionLog::class) + ); } } 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']); 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-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 8f0ef08cd..1a703364a 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/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/src/Resources/app/administration/src/module/swag-migration/snippet/de.json b/src/Resources/app/administration/src/module/swag-migration/snippet/de.json index 6fe398d3d..194bb245d 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 b9e2998a7..d3259cf71 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/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 24fa7d714..a59bd915e 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 @@ -182,8 +182,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/MigrationServicesTrait.php b/tests/MigrationServicesTrait.php index 9135d0557..92d228953 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; @@ -211,7 +212,7 @@ protected function getMigrationDataConverter( $this->getContainer()->get(DefinitionInstanceRegistry::class), $this->getContainer()->get('event_dispatcher'), $loggingService, - $mappingService, + $this->getContainer()->get(Connection::class), ); return new MigrationDataConverter( @@ -221,7 +222,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..3ea51f83b 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; @@ -26,11 +27,13 @@ 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\MigrationValidationInvalidFieldValueLog; -use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidForeignKeyLog; +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\Migration\Validation\Log\MigrationValidationUnexpectedFieldLog; use SwagMigrationAssistant\Migration\Validation\MigrationValidationResult; use SwagMigrationAssistant\Migration\Validation\MigrationValidationService; use SwagMigrationAssistant\Profile\Shopware54\Shopware54Profile; @@ -47,6 +50,8 @@ class MigrationValidationServiceTest extends TestCase private const CONNECTION_ID = '01991554142d73348ea58793d98f1989'; + private MigrationContext $migrationContext; + private MigrationValidationService $validationService; /** @@ -76,7 +81,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 +153,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, @@ -166,10 +172,89 @@ 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); } + 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()); + + // Only 'stock' should be required as its not nullable in db and has no default + static::assertCount(1, $missingFields); + static::assertContains('stock', $missingFields); + } + + /** + * @param array $convertedData + */ + #[DataProvider('invalidIdProvider')] + public function testShouldLogWhenEntityHasInvalidOrMissingId(array $convertedData, string $expectedExceptionMessage): void + { + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + $convertedData, + 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($expectedExceptionMessage, $exceptionLog->getExceptionMessage()); + } + + /** + * @return \Generator, string}> + */ + public static function invalidIdProvider(): \Generator + { + $baseData = [ + 'level' => 'error', + 'code' => 'some_code', + 'userFixable' => true, + 'createdAt' => (new \DateTime())->format(\DATE_ATOM), + ]; + + yield 'missing id (null)' => [ + $baseData, + MigrationValidationException::unexpectedNullValue('id')->getMessage(), + ]; + + yield 'invalid uuid string' => [ + [...$baseData, 'id' => 'invalid-uuid'], + 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(), + ]; + + yield 'empty string id' => [ + [...$baseData, 'id' => ''], + MigrationValidationException::invalidId('', SwagMigrationLoggingDefinition::ENTITY_NAME)->getMessage(), + ]; + } + /** * @param array $convertedData * @param array> $mappings @@ -178,25 +263,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, @@ -205,10 +277,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 = [ @@ -243,25 +366,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', ], [ - MigrationValidationInvalidFieldValueLog::class, + MigrationValidationInvalidOptionalFieldValueLog::class, ], ]; @@ -271,7 +382,7 @@ public static function entityStructureAndFieldProvider(): \Generator 'code' => str_repeat('sw', 128), ], [ - MigrationValidationInvalidFieldValueLog::class, + MigrationValidationInvalidRequiredFieldValueLog::class, ], ]; @@ -291,7 +402,7 @@ public static function entityStructureAndFieldProvider(): \Generator 'sourceData' => "\xB1\x31", ], [ - MigrationValidationInvalidFieldValueLog::class, + MigrationValidationInvalidOptionalFieldValueLog::class, ], ]; @@ -303,14 +414,12 @@ public static function entityStructureAndFieldProvider(): \Generator 'code' => ['sw'], 'userFixable' => true, 'createdAt' => (new \DateTime())->format(\DATE_ATOM), - 'unexpectedField' => 'value', ], [ MigrationValidationMissingRequiredFieldLog::class, - MigrationValidationUnexpectedFieldLog::class, - MigrationValidationInvalidFieldValueLog::class, - MigrationValidationInvalidFieldValueLog::class, - MigrationValidationInvalidFieldValueLog::class, + MigrationValidationInvalidRequiredFieldValueLog::class, + MigrationValidationInvalidRequiredFieldValueLog::class, + MigrationValidationInvalidRequiredFieldValueLog::class, ], ]; } @@ -348,15 +457,6 @@ public static function associationProvider(): \Generator [], ]; - yield 'invalid fk' => [ - [ - ...$log, - 'runId' => Uuid::randomHex(), - ], - [$mapping], - [MigrationValidationInvalidForeignKeyLog::class], - ]; - yield 'fk field not in converted data' => [ $log, [], @@ -370,7 +470,7 @@ public static function associationProvider(): \Generator ], [], [ - MigrationValidationInvalidFieldValueLog::class, + MigrationValidationInvalidOptionalFieldValueLog::class, ], ]; @@ -381,8 +481,299 @@ public static function associationProvider(): \Generator ], [], [ - MigrationValidationInvalidFieldValueLog::class, + MigrationValidationInvalidOptionalFieldValueLog::class, + ], + ]; + } + + /** + * 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()], + 'invalid-entry', + ['id' => 'invalid-uuid'], + ], + ], + [ + // Only first error is logged since validation throws on first failure + MigrationValidationInvalidAssociationLog::class, + ], + ]; + } + + /** + * @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); + } + + 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()); } } diff --git a/tests/unit/Migration/Logging/Log/MigrationLogTest.php b/tests/unit/Migration/Logging/Log/MigrationLogTest.php index cdf99f432..c2ac1943c 100644 --- a/tests/unit/Migration/Logging/Log/MigrationLogTest.php +++ b/tests/unit/Migration/Logging/Log/MigrationLogTest.php @@ -39,10 +39,11 @@ 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\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\Migration\Validation\Log\MigrationValidationUnexpectedFieldLog; use SwagMigrationAssistant\Profile\Shopware54\Shopware54Profile; use SwagMigrationAssistant\Test\Mock\Gateway\Dummy\Local\DummyLocalGateway; @@ -107,32 +108,39 @@ public static function logProvider(): \Generator 'userFixable' => false, ]; - yield MigrationValidationInvalidFieldValueLog::class => [ - 'logClass' => MigrationValidationInvalidFieldValueLog::class, - 'code' => 'SWAG_MIGRATION_VALIDATION_INVALID_FIELD_VALUE', + yield MigrationValidationInvalidAssociationLog::class => [ + 'logClass' => MigrationValidationInvalidAssociationLog::class, + 'code' => 'SWAG_MIGRATION_VALIDATION_INVALID_ASSOCIATION', 'level' => AbstractMigrationLogEntry::LOG_LEVEL_ERROR, 'userFixable' => true, ]; - yield MigrationValidationInvalidForeignKeyLog::class => [ - 'logClass' => MigrationValidationInvalidForeignKeyLog::class, - 'code' => 'SWAG_MIGRATION_VALIDATION_INVALID_FOREIGN_KEY', + 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_REQUIRED_FIELD_VALUE', + 'level' => AbstractMigrationLogEntry::LOG_LEVEL_ERROR, + 'userFixable' => true, + ]; + yield MigrationValidationMissingRequiredFieldLog::class => [ 'logClass' => MigrationValidationMissingRequiredFieldLog::class, 'code' => 'SWAG_MIGRATION_VALIDATION_MISSING_REQUIRED_FIELD', 'level' => AbstractMigrationLogEntry::LOG_LEVEL_ERROR, - 'userFixable' => false, + 'userFixable' => true, ]; - yield MigrationValidationUnexpectedFieldLog::class => [ - 'logClass' => MigrationValidationUnexpectedFieldLog::class, - 'code' => 'SWAG_MIGRATION_VALIDATION_UNEXPECTED_FIELD', - 'level' => AbstractMigrationLogEntry::LOG_LEVEL_WARNING, - 'userFixable' => true, + yield MigrationValidationInvalidRequiredTranslation::class => [ + 'logClass' => MigrationValidationInvalidRequiredTranslation::class, + 'code' => 'SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_TRANSLATION', + 'level' => AbstractMigrationLogEntry::LOG_LEVEL_ERROR, + 'userFixable' => false, ]; yield AssociationRequiredMissingLog::class => [