diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index d9526bfb44..9573277ef1 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -9156,18 +9156,6 @@ parameters: count: 1 path: src/lib/MVC/Symfony/Translation/TranslatableExceptionsFileVisitor.php - - - message: '#^Access to an undefined property PhpParser\\Node\\Arg\|PhpParser\\Node\\VariadicPlaceholder\:\:\$value\.$#' - identifier: property.notFound - count: 2 - path: src/lib/MVC/Symfony/Translation/ValidationErrorFileVisitor.php - - - - message: '#^Method Ibexa\\Core\\MVC\\Symfony\\Translation\\ValidationErrorFileVisitor\:\:setLogger\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: src/lib/MVC/Symfony/Translation/ValidationErrorFileVisitor.php - - message: '#^Parameter \#1 \$desc of method JMS\\TranslationBundle\\Model\\Message\:\:setDesc\(\) expects string, string\|null given\.$#' identifier: argument.type diff --git a/src/bundle/Core/Resources/config/services.yml b/src/bundle/Core/Resources/config/services.yml index 7bda7b07e4..328634fc32 100644 --- a/src/bundle/Core/Resources/config/services.yml +++ b/src/bundle/Core/Resources/config/services.yml @@ -371,3 +371,10 @@ services: decoration_priority: 500 arguments: $inner: '@.inner' + + + jms_translation.doc_parser: + class: Doctrine\Common\Annotations\DocParser + calls: + - [setImports, [{ desc: 'JMS\TranslationBundle\Annotation\Desc', domain: 'Ibexa\Core\MVC\Symfony\Translation\Annotation\Domain', ignore: 'JMS\TranslationBundle\Annotation\Ignore', meaning: 'JMS\TranslationBundle\Annotation\Meaning' }]] + - [setIgnoreNotImportedAnnotations, [true]] diff --git a/src/bundle/Core/Resources/translations/ibexa_repository_exceptions.en.xlf b/src/bundle/Core/Resources/translations/ibexa_repository_exceptions.en.xlf index 20ba368c9e..f6ae888b77 100644 --- a/src/bundle/Core/Resources/translations/ibexa_repository_exceptions.en.xlf +++ b/src/bundle/Core/Resources/translations/ibexa_repository_exceptions.en.xlf @@ -8,9 +8,14 @@ limitationValues for '%identifier%' Limitation can not be empty]]> - limitationValues for '%identifier%' Limitation can not be empty]]> + limitationValues for '%identifier%' Limitation can not be empty]]> key: $limitationValue->limitationValues for '%identifier%' Limitation can not be empty + + limitationValues[%key%] => Invalid SiteAccess value '%value%']]> + limitationValues[%key%] => Invalid SiteAccess value '%value%']]> + key: $limitationValue->limitationValues[%key%] => Invalid SiteAccess value '%value%' + '%actualValue%' is incorrect value '%actualValue%' is incorrect value @@ -23,17 +28,17 @@ A valid file is required. The following file extensions are not allowed: %extensionsBlackList% - A valid file is required. The following file extensions are not allowed: %extensionsBlackList% + A valid file is required. The following file extensions are not allowed: %extensionsBlackList% key: A valid file is required. The following file extensions are not allowed: %extensionsBlackList% A valid image file is required. - A valid image file is required. + A valid image file is required. key: A valid image file is required. Alternative text is required. - Alternative text is required. + Alternative text is required. key: Alternative text is required. @@ -73,7 +78,7 @@ Content %type% is not a valid asset target - Content %type% is not a valid asset target + Content %type% is not a valid asset target key: Content %type% is not a valid asset target @@ -83,7 +88,7 @@ Content type %contentTypeIdentifier% is not a valid relation target - Content type %contentTypeIdentifier% is not a valid relation target + Content type %contentTypeIdentifier% is not a valid relation target key: Content type %contentTypeIdentifier% is not a valid relation target @@ -93,7 +98,7 @@ Content with identifier %contentId% is not a valid relation target - Content with identifier %contentId% is not a valid relation target + Content with identifier %contentId% is not a valid relation target key: Content with identifier %contentId% is not a valid relation target @@ -113,27 +118,27 @@ Country with Alpha2 code '%alpha2%' is not defined in FieldType settings. - Country with Alpha2 code '%alpha2%' is not defined in FieldType settings. + Country with Alpha2 code '%alpha2%' is not defined in FieldType settings. key: Country with Alpha2 code '%alpha2%' is not defined in FieldType settings. Each keyword must be a string. - Each keyword must be a string. + Each keyword must be a string. key: Each keyword must be a string. Email '%email%' is used by another user. You must enter a unique email. - Email '%email%' is used by another user. You must enter a unique email. + Email '%email%' is used by another user. You must enter a unique email. key: Email '%email%' is used by another user. You must enter a unique email. Email required - Email required + Email required key: Email required Enabled must be boolean value - Enabled must be boolean value + Enabled must be boolean value key: Enabled must be boolean value @@ -153,39 +158,49 @@ Field definition does not allow multiple countries to be selected. - Field definition does not allow multiple countries to be selected. + Field definition does not allow multiple countries to be selected. key: Field definition does not allow multiple countries to be selected. Field definition does not allow multiple options to be selected. - Field definition does not allow multiple options to be selected. + Field definition does not allow multiple options to be selected. key: Field definition does not allow multiple options to be selected. + + Field type %field_type_identifier% is not searchable + Field type %field_type_identifier% is not searchable + key: Field type %field_type_identifier% is not searchable + FieldType '%fieldType%' does not accept settings - FieldType '%fieldType%' does not accept settings + FieldType '%fieldType%' does not accept settings key: FieldType '%fieldType%' does not accept settings FieldType '%fieldType%' expects setting '%setting%' to be of type '%type%' - FieldType '%fieldType%' expects setting '%setting%' to be of type '%type%' + FieldType '%fieldType%' expects setting '%setting%' to be of type '%type%' key: FieldType '%fieldType%' expects setting '%setting%' to be of type '%type%' ISBN value must be in a valid ISBN-10 format - ISBN value must be in a valid ISBN-10 format + ISBN value must be in a valid ISBN-10 format key: ISBN value must be in a valid ISBN-10 format ISBN-10 must be 10 character length - ISBN-10 must be 10 character length + ISBN-10 must be 10 character length key: ISBN-10 must be 10 character length Invalid login format - Invalid login format + Invalid login format key: Invalid login format + + Keyword value must be less than or equal to 255 characters. + Keyword value must be less than or equal to 255 characters. + key: Keyword value must be less than or equal to 255 characters. + Limitation '%limitation%' not found. It must be implemented or configured to use %blockingLimitation% Limitation '%limitation%' not found. It must be implemented or configured to use %blockingLimitation% @@ -198,27 +213,27 @@ Login required - Login required + Login required key: Login required New password cannot be the same as old password - New password cannot be the same as old password + New password cannot be the same as old password key: New password cannot be the same as old password Option with index %index% does not exist in the field definition. - Option with index %index% does not exist in the field definition. + Option with index %index% does not exist in the field definition. key: Option with index %index% does not exist in the field definition. Password expiration warning value should be lower then password expiration value - Password expiration warning value should be lower then password expiration value + Password expiration warning value should be lower then password expiration value key: Password expiration warning value should be lower then password expiration value Password required - Password required + Password required key: Password required @@ -233,77 +248,77 @@ Setting '%setting%' contains unsupported mime types - Setting '%setting%' contains unsupported mime types + Setting '%setting%' contains unsupported mime types key: Setting '%setting%' contains unsupported mime types Setting '%setting%' has unknown default value - Setting '%setting%' has unknown default value + Setting '%setting%' has unknown default value key: Setting '%setting%' has unknown default value Setting '%setting%' is of unknown type - Setting '%setting%' is of unknown type + Setting '%setting%' is of unknown type key: Setting '%setting%' is of unknown type Setting '%setting%' is unknown - Setting '%setting%' is unknown + Setting '%setting%' is unknown key: Setting '%setting%' is unknown Setting '%setting%' must be either %selection_browse% or %selection_dropdown% - Setting '%setting%' must be either %selection_browse% or %selection_dropdown% + Setting '%setting%' must be either %selection_browse% or %selection_dropdown% key: Setting '%setting%' must be either %selection_browse% or %selection_dropdown% Setting '%setting%' value cannot be lower than 0 - Setting '%setting%' value cannot be lower than 0 + Setting '%setting%' value cannot be lower than 0 key: Setting '%setting%' value cannot be lower than 0 Setting '%setting%' value must be of array type - Setting '%setting%' value must be of array type + Setting '%setting%' value must be of array type key: Setting '%setting%' value must be of array type Setting '%setting%' value must be of boolean type - Setting '%setting%' value must be of boolean type + Setting '%setting%' value must be of boolean type key: Setting '%setting%' value must be of boolean type Setting '%setting%' value must be of either null or bool - Setting '%setting%' value must be of either null or bool + Setting '%setting%' value must be of either null or bool key: Setting '%setting%' value must be of either null or bool Setting '%setting%' value must be of either null, string or integer - Setting '%setting%' value must be of either null, string or integer + Setting '%setting%' value must be of either null, string or integer key: Setting '%setting%' value must be of either null, string or integer Setting '%setting%' value must be of integer type - Setting '%setting%' value must be of integer type + Setting '%setting%' value must be of integer type key: Setting '%setting%' value must be of integer type Setting 'Current date and time adjusted by' can be used only when setting 'Default value' is set to 'Adjusted current datetime' - Setting 'Current date and time adjusted by' can be used only when setting 'Default value' is set to 'Adjusted current datetime' + Setting 'Current date and time adjusted by' can be used only when setting 'Default value' is set to 'Adjusted current datetime' key: Setting 'Current date and time adjusted by' can be used only when setting 'Default value' is set to 'Adjusted current datetime' Setting 'Current date and time adjusted by' value must be an instance of 'DateInterval' class - Setting 'Current date and time adjusted by' value must be an instance of 'DateInterval' class + Setting 'Current date and time adjusted by' value must be an instance of 'DateInterval' class key: Setting 'Current date and time adjusted by' value must be an instance of 'DateInterval' class Setting 'Default value' is of unknown type - Setting 'Default value' is of unknown type + Setting 'Default value' is of unknown type key: Setting 'Default value' is of unknown type Setting 'Use seconds' value must be of boolean type - Setting 'Use seconds' value must be of boolean type + Setting 'Use seconds' value must be of boolean type key: Setting 'Use seconds' value must be of boolean type @@ -318,82 +333,82 @@ The file size cannot exceed %size% byte. - The file size cannot exceed %size% byte. + The file size cannot exceed %size% byte. key: The file size cannot exceed %size% byte. The file size cannot exceed %size% bytes. - The file size cannot exceed %size% bytes. + The file size cannot exceed %size% bytes. key: The file size cannot exceed %size% bytes. The file size cannot exceed %size% megabyte. - The file size cannot exceed %size% megabyte. + The file size cannot exceed %size% megabyte. key: The file size cannot exceed %size% megabyte. The file size cannot exceed %size% megabytes. - The file size cannot exceed %size% megabytes. + The file size cannot exceed %size% megabytes. key: The file size cannot exceed %size% megabytes. The following options are available for setting '%setting%': %selection_browse%, %selection_dropdown%, %selection_list_with_radio_buttons%, %selection_list_with_checkboxes%, %selection_multiple_selection_list%, %selection_template_based_multiple%, %selection_template_based_single% - The following options are available for setting '%setting%': %selection_browse%, %selection_dropdown%, %selection_list_with_radio_buttons%, %selection_list_with_checkboxes%, %selection_multiple_selection_list%, %selection_template_based_multiple%, %selection_template_based_single% + The following options are available for setting '%setting%': %selection_browse%, %selection_dropdown%, %selection_list_with_radio_buttons%, %selection_list_with_checkboxes%, %selection_multiple_selection_list%, %selection_template_based_multiple%, %selection_template_based_single% key: The following options are available for setting '%setting%': %selection_browse%, %selection_dropdown%, %selection_list_with_radio_buttons%, %selection_list_with_checkboxes%, %selection_multiple_selection_list%, %selection_template_based_multiple%, %selection_template_based_single% The given e-mail '%email%' is invalid - The given e-mail '%email%' is invalid + The given e-mail '%email%' is invalid key: The given e-mail '%email%' is invalid The mime type of the file is invalid (%mimeType%). Allowed mime types are %mimeTypes%. - The mime type of the file is invalid (%mimeType%). Allowed mime types are %mimeTypes%. + The mime type of the file is invalid (%mimeType%). Allowed mime types are %mimeTypes%. key: The mime type of the file is invalid (%mimeType%). Allowed mime types are %mimeTypes%. The selected content items number cannot be higher than %limit%. - The selected content items number cannot be higher than %limit%. + The selected content items number cannot be higher than %limit%. key: The selected content items number cannot be higher than %limit%. The string can not exceed %size% character. - The string can not exceed %size% character. + The string can not exceed %size% character. key: The string can not exceed %size% character. The string can not exceed %size% characters. - The string can not exceed %size% characters. + The string can not exceed %size% characters. key: The string can not exceed %size% characters. The string cannot be shorter than %size% character. - The string cannot be shorter than %size% character. + The string cannot be shorter than %size% character. key: The string cannot be shorter than %size% character. The string cannot be shorter than %size% characters. - The string cannot be shorter than %size% characters. + The string cannot be shorter than %size% characters. key: The string cannot be shorter than %size% characters. The user login '%login%' is used by another user. You must enter a unique login. - The user login '%login%' is used by another user. You must enter a unique login. + The user login '%login%' is used by another user. You must enter a unique login. key: The user login '%login%' is used by another user. You must enter a unique login. The value can not be higher than %size%. - The value can not be higher than %size%. + The value can not be higher than %size%. key: The value can not be higher than %size%. The value can not be lower than %size%. - The value can not be lower than %size%. + The value can not be lower than %size%. key: The value can not be lower than %size%. The value must be a valid email address. - The value must be a valid email address. + The value must be a valid email address. key: The value must be a valid email address. @@ -408,62 +423,67 @@ Validator %validator% expects parameter %parameter% to be of %type% type. - Validator %validator% expects parameter %parameter% to be of %type% type. + Validator %validator% expects parameter %parameter% to be of %type% type. key: Validator %validator% expects parameter %parameter% to be of %type% type. Validator %validator% expects parameter %parameter% to be of %type%. - Validator %validator% expects parameter %parameter% to be of %type%. + Validator %validator% expects parameter %parameter% to be of %type%. key: Validator %validator% expects parameter %parameter% to be of %type%. Validator %validator% expects parameter %parameter% to be set. - Validator %validator% expects parameter %parameter% to be set. + Validator %validator% expects parameter %parameter% to be set. key: Validator %validator% expects parameter %parameter% to be set. + + Validator '%parameter%' is unknown + Validator '%parameter%' is unknown + key: Validator '%parameter%' is unknown + Validator '%validator%' is unknown - Validator '%validator%' is unknown + Validator '%validator%' is unknown key: Validator '%validator%' is unknown Validator parameter '%parameter%' is unknown - Validator parameter '%parameter%' is unknown + Validator parameter '%parameter%' is unknown key: Validator parameter '%parameter%' is unknown Validator parameter '%parameter%' value can't be negative - Validator parameter '%parameter%' value can't be negative + Validator parameter '%parameter%' value can't be negative key: Validator parameter '%parameter%' value can't be negative Validator parameter '%parameter%' value must be an integer - Validator parameter '%parameter%' value must be an integer + Validator parameter '%parameter%' value must be an integer key: Validator parameter '%parameter%' value must be an integer Validator parameter '%parameter%' value must be equal to/greater than 0 - Validator parameter '%parameter%' value must be equal to/greater than 0 + Validator parameter '%parameter%' value must be equal to/greater than 0 key: Validator parameter '%parameter%' value must be equal to/greater than 0 Validator parameter '%parameter%' value must be of integer type - Validator parameter '%parameter%' value must be of integer type + Validator parameter '%parameter%' value must be of integer type key: Validator parameter '%parameter%' value must be of integer type Validator parameter '%parameter%' value must be regex for now - Validator parameter '%parameter%' value must be regex for now + Validator parameter '%parameter%' value must be regex for now key: Validator parameter '%parameter%' value must be regex for now Validator parameter 'maxStringLength' can't be shorter than validator parameter 'minStringLength' value - Validator parameter 'maxStringLength' can't be shorter than validator parameter 'minStringLength' value + Validator parameter 'maxStringLength' can't be shorter than validator parameter 'minStringLength' value key: Validator parameter 'maxStringLength' can't be shorter than validator parameter 'minStringLength' value Value for required field definition '%identifier%' with language '%languageCode%' is empty - Value for required field definition '%identifier%' with language '%languageCode%' is empty + Value for required field definition '%identifier%' with language '%languageCode%' is empty key: Value for required field definition '%identifier%' with language '%languageCode%' is empty @@ -473,32 +493,32 @@ '%value%' does not equal Location's path string: '%path_string%']]> - '%value%' does not equal Location's path string: '%path_string%']]> + '%value%' does not equal Location's path string: '%path_string%']]> key: limitationValues[%key%] => '%value%' does not equal Location's path string: '%path_string%' '%value%' does not exist in the backend]]> - '%value%' does not exist in the backend]]> + '%value%' does not exist in the backend]]> key: limitationValues[%key%] => '%value%' does not exist in the backend '%value%' must be 1 (owner)]]> - '%value%' must be 1 (owner)]]> + '%value%' must be 1 (owner)]]> key: limitationValues[%key%] => '%value%' must be 1 (owner) '%value%' must be an integer]]> - '%value%' must be an integer]]> + '%value%' must be an integer]]> key: limitationValues[%key%] => '%value%' must be an integer '%value%' must be either 1 (owner) or 2 (session)]]> - '%value%' must be either 1 (owner) or 2 (session)]]> + '%value%' must be either 1 (owner) or 2 (session)]]> key: limitationValues[%key%] => '%value%' must be either 1 (owner) or 2 (session) '%languageCodes%' translation(s) do not exist]]> - '%languageCodes%' translation(s) do not exist]]> + '%languageCodes%' translation(s) do not exist]]> key: limitationValues[] => '%languageCodes%' translation(s) do not exist diff --git a/src/bundle/Core/Resources/translations/validators.en.xlf b/src/bundle/Core/Resources/translations/validators.en.xlf index 93900c2ff4..96dfe8c2cc 100644 --- a/src/bundle/Core/Resources/translations/validators.en.xlf +++ b/src/bundle/Core/Resources/translations/validators.en.xlf @@ -8,7 +8,7 @@ Identifier already exists. - Identifier already exists. + Identifier already exists. key: ibexa.identifier_already_exists diff --git a/src/lib/FieldType/BaseTextType.php b/src/lib/FieldType/BaseTextType.php index 3e28d47f99..9ed0945650 100644 --- a/src/lib/FieldType/BaseTextType.php +++ b/src/lib/FieldType/BaseTextType.php @@ -63,11 +63,10 @@ protected function checkValueStructure(BaseValue $value): void protected function buildUnknownValidatorError(string $parameterName, string $validatorIdentifier): ValidationError { return new ValidationError( - "Validator '$parameterName' is unknown", + /** @Desc("Validator '%parameter%' is unknown") */ + "Validator '%parameter%' is unknown", null, - [ - $parameterName => $validatorIdentifier, - ] + ['%parameter%' => $parameterName] ); } diff --git a/src/lib/FieldType/ISBN/Type.php b/src/lib/FieldType/ISBN/Type.php index 3f17ed6a7e..652ccf0488 100644 --- a/src/lib/FieldType/ISBN/Type.php +++ b/src/lib/FieldType/ISBN/Type.php @@ -13,6 +13,7 @@ use Ibexa\Core\FieldType\FieldType; use Ibexa\Core\FieldType\ValidationError; use Ibexa\Core\FieldType\Value as BaseValue; +use JMS\TranslationBundle\Annotation\Ignore; use JMS\TranslationBundle\Model\Message; use JMS\TranslationBundle\Translation\TranslationContainerInterface; @@ -157,7 +158,10 @@ public function validate(FieldDefinition $fieldDefinition, SPIValue $fieldValue) } else { // ISBN-13 check if (!$this->validateISBN13Checksum($isbnTestNumber, $error)) { + // TODO: Replace the out-parameter error flow with stable translation keys/templates. + // JMS extraction cannot read a ValidationError ID passed through a variable. $validationErrors[] = new ValidationError( + /** @Ignore */ $error, null, [], diff --git a/src/lib/FieldType/Keyword/Type.php b/src/lib/FieldType/Keyword/Type.php index fc740e4e32..128044974b 100644 --- a/src/lib/FieldType/Keyword/Type.php +++ b/src/lib/FieldType/Keyword/Type.php @@ -25,6 +25,7 @@ class Type extends FieldType implements TranslationContainerInterface { public const MAX_KEYWORD_LENGTH = 255; + private const string ERROR_MESSAGE_MAX_KEYWORD_LENGTH = 'Keyword value must be less than or equal to 255 characters.'; /** * Returns the field type identifier for this field type. @@ -120,7 +121,7 @@ public function validate(FieldDefinition $fieldDefinition, SPIValue $fieldValue) ); } elseif (mb_strlen($keyword) > self::MAX_KEYWORD_LENGTH) { $validationErrors[] = new ValidationError( - 'Keyword value must be less than or equal to ' . self::MAX_KEYWORD_LENGTH . ' characters.', + self::ERROR_MESSAGE_MAX_KEYWORD_LENGTH, null, [], 'values' diff --git a/src/lib/FieldType/Validator/BaseNumericValidator.php b/src/lib/FieldType/Validator/BaseNumericValidator.php index d0741c91b3..e5cc869d6c 100644 --- a/src/lib/FieldType/Validator/BaseNumericValidator.php +++ b/src/lib/FieldType/Validator/BaseNumericValidator.php @@ -10,6 +10,7 @@ use Ibexa\Core\FieldType\ValidationError; use Ibexa\Core\FieldType\Validator; +use JMS\TranslationBundle\Annotation\Ignore; abstract class BaseNumericValidator extends Validator { @@ -24,7 +25,10 @@ public function validateConstraints($constraints): array foreach ($constraints as $name => $value) { $validationErrorMessage = $this->getConstraintsValidationErrorMessage($name, $value); if (null !== $validationErrorMessage) { + // TODO: Return stable translation keys/templates instead of a computed message string. + // JMS extraction cannot read a ValidationError ID passed through a variable. $validationErrors[] = new ValidationError( + /** @Ignore */ $validationErrorMessage, null, [ diff --git a/src/lib/FieldType/Validator/StringLengthValidator.php b/src/lib/FieldType/Validator/StringLengthValidator.php index 85c136967e..3f1babe225 100644 --- a/src/lib/FieldType/Validator/StringLengthValidator.php +++ b/src/lib/FieldType/Validator/StringLengthValidator.php @@ -11,6 +11,7 @@ use Ibexa\Core\FieldType\ValidationError; use Ibexa\Core\FieldType\Validator; use Ibexa\Core\FieldType\Value as BaseValue; +use JMS\TranslationBundle\Annotation\Desc; /** * Validator for checking min. and max. length of strings. @@ -47,11 +48,10 @@ public function validateConstraints($constraints) case 'maxStringLength': if ($value !== false && !is_int($value) && !(null === $value)) { $validationErrors[] = new ValidationError( - sprintf('Validator parameter \'%s\' value must be of integer type', self::PARAMETER_NAME), + /** @Desc("Validator parameter '%parameter%' value must be of integer type") */ + "Validator parameter '%parameter%' value must be of integer type", null, - [ - self::PARAMETER_NAME => $name, - ] + [self::PARAMETER_NAME => $name] ); } elseif ($value < 0) { $validationErrors[] = new ValidationError( diff --git a/src/lib/Limitation/SiteAccessLimitationType.php b/src/lib/Limitation/SiteAccessLimitationType.php index 7776be10c6..d7a323e35c 100644 --- a/src/lib/Limitation/SiteAccessLimitationType.php +++ b/src/lib/Limitation/SiteAccessLimitationType.php @@ -83,12 +83,9 @@ public function validate(APILimitationValue $limitationValue): array foreach ($limitationValue->limitationValues as $key => $value) { if (!isset($siteAccessList[$value])) { $validationErrors[] = new ValidationError( - "\$limitationValue->limitationValues[%key%] => Invalid SiteAccess value \"$value\"", + "\$limitationValue->limitationValues[%key%] => Invalid SiteAccess value '%value%'", null, - [ - 'value' => $value, - 'key' => $key, - ] + ['%key%' => $key, '%value%' => $value] ); } } diff --git a/src/lib/MVC/Symfony/Translation/Annotation/Domain.php b/src/lib/MVC/Symfony/Translation/Annotation/Domain.php new file mode 100644 index 0000000000..c54fc06684 --- /dev/null +++ b/src/lib/MVC/Symfony/Translation/Annotation/Domain.php @@ -0,0 +1,35 @@ +value = $values['value']; + } +} diff --git a/src/lib/MVC/Symfony/Translation/ValidationErrorFileVisitor.php b/src/lib/MVC/Symfony/Translation/ValidationErrorFileVisitor.php index 40884bc9e8..4d892e41f7 100644 --- a/src/lib/MVC/Symfony/Translation/ValidationErrorFileVisitor.php +++ b/src/lib/MVC/Symfony/Translation/ValidationErrorFileVisitor.php @@ -4,10 +4,12 @@ * @copyright Copyright (C) Ibexa AS. All rights reserved. * @license For full copyright and license information view LICENSE file distributed with this source code. */ +declare(strict_types=1); namespace Ibexa\Core\MVC\Symfony\Translation; use Doctrine\Common\Annotations\DocParser; +use Ibexa\Core\MVC\Symfony\Translation\Annotation\Domain; use JMS\TranslationBundle\Annotation\Desc; use JMS\TranslationBundle\Annotation\Ignore; use JMS\TranslationBundle\Annotation\Meaning; @@ -17,12 +19,20 @@ use JMS\TranslationBundle\Model\MessageCatalogue; use JMS\TranslationBundle\Translation\Extractor\FileVisitorInterface; use JMS\TranslationBundle\Translation\FileSourceFactory; -use PhpParser\Comment\Doc; use PhpParser\Node; +use PhpParser\Node\Arg; +use PhpParser\Node\Expr\ClassConstFetch; +use PhpParser\Node\Expr\New_; +use PhpParser\Node\Identifier; +use PhpParser\Node\Name; use PhpParser\Node\Scalar\String_; +use PhpParser\Node\Stmt\ClassConst; +use PhpParser\Node\Stmt\ClassLike; +use PhpParser\Node\Stmt\Namespace_; use PhpParser\NodeTraverser; use PhpParser\NodeVisitor; use Psr\Log\LoggerInterface; +use SplFileInfo; use Twig\Node\Node as TwigNode; /** @@ -36,22 +46,25 @@ class ValidationErrorFileVisitor implements LoggerAwareInterface, FileVisitorInt private MessageCatalogue $catalogue; - private \SplFileInfo $file; + private SplFileInfo $file; private DocParser $docParser; - private LoggerInterface $logger; + private ?LoggerInterface $logger = null; - private Node $previousNode; + private ?Node $previousNode = null; + + /** @var list> */ + private array $classStringConstantStack = []; + + /** @var list */ + private array $classNameStack = []; + + /** @var list */ + private array $namespaceStack = []; protected string $defaultDomain = 'ibexa_repository_exceptions'; - /** - * DefaultPhpFileExtractor constructor. - * - * @param \Doctrine\Common\Annotations\DocParser $docParser - * @param \JMS\TranslationBundle\Translation\FileSourceFactory $fileSourceFactory - */ public function __construct(DocParser $docParser, FileSourceFactory $fileSourceFactory) { $this->docParser = $docParser; @@ -60,27 +73,47 @@ public function __construct(DocParser $docParser, FileSourceFactory $fileSourceF $this->traverser->addVisitor($this); } - /** - * @param \Psr\Log\LoggerInterface $logger - */ - public function setLogger(LoggerInterface $logger) + public function setLogger(LoggerInterface $logger): void { $this->logger = $logger; } public function enterNode(Node $node): null { - if (!$node instanceof Node\Expr\New_ - || !$node->class instanceof Node\Name - || strtolower((string)$node->class) !== 'validationerror' + if ($node instanceof Namespace_) { + $this->namespaceStack[] = $node->name?->toString(); + $this->previousNode = $node; + + return null; + } + + if ($node instanceof ClassLike) { + $this->classStringConstantStack[] = $this->extractStringConstants($node); + $this->classNameStack[] = $node->name?->toString(); + $this->previousNode = $node; + + return null; + } + + if (!$node instanceof New_ + || !$node->class instanceof Name + || strtolower((string) $node->class) !== 'validationerror' ) { $this->previousNode = $node; return null; } + $idArg = $this->getArgument($node, 0); + if (null === $idArg) { + $this->previousNode = $node; + + return null; + } + $ignore = false; $desc = $meaning = null; + $domain = $this->defaultDomain; if (null !== $docComment = $this->getDocCommentForNode($node)) { foreach ($this->docParser->parse($docComment, 'file ' . $this->file . ' near line ' . $node->getLine()) as $annot) { if ($annot instanceof Ignore) { @@ -89,18 +122,26 @@ public function enterNode(Node $node): null $desc = $annot->text; } elseif ($annot instanceof Meaning) { $meaning = $annot->text; + } elseif ($annot instanceof Domain) { + $domain = $annot->value; } } } - if (!$node->args[0]->value instanceof String_) { + $id = $this->resolveTranslationId($idArg->value); + if (null === $id) { if ($ignore) { return null; } - $message = sprintf('Can only extract the translation ID from a scalar string, but got "%s". Refactor your code to make it extractable, or add the doc comment /** @Ignore */ to this code element (in %s on line %d).', get_class($node->args[0]->value), $this->file, $node->args[0]->value->getLine()); + $message = sprintf( + 'Can only extract the translation ID from a scalar string, but got "%s". Refactor your code to make it extractable, or add the doc comment /** @Ignore */ to this code element (in %s on line %d).', + get_class($idArg->value), + $this->file, + $idArg->value->getLine() + ); - if (isset($this->logger)) { + if (null !== $this->logger) { $this->logger->error($message); return null; @@ -109,15 +150,15 @@ public function enterNode(Node $node): null throw new RuntimeException($message); } - $message = new Message($node->args[0]->value->value, $this->defaultDomain); + $message = new Message($id, $domain); $message->setDesc($desc); $message->setMeaning($meaning); $message->addSource($this->fileSourceFactory->create($this->file, $node->getLine())); $this->catalogue->add($message); - // plural - if (isset($node->args[1]) && $node->args[1]->value instanceof String_) { - $message = new Message($node->args[1]->value->value, $this->defaultDomain); + $pluralArg = $this->getArgument($node, 1); + if (null !== $pluralArg && null !== ($pluralId = $this->resolveTranslationId($pluralArg->value))) { + $message = new Message($pluralId, $domain); $message->setDesc($desc); $message->setMeaning($meaning); $message->addSource($this->fileSourceFactory->create($this->file, $node->getLine())); @@ -128,24 +169,29 @@ public function enterNode(Node $node): null } /** - * @param \SplFileInfo $file - * @param \JMS\TranslationBundle\Model\MessageCatalogue $catalogue - * @param \PhpParser\Node\Stmt[] $ast + * @param array<\PhpParser\Node> $nodes */ - public function visitPhpFile(\SplFileInfo $file, MessageCatalogue $catalogue, array $ast): void - { - $this->file = $file; - $this->catalogue = $catalogue; - $this->traverser->traverse($ast); - } - public function beforeTraverse(array $nodes): null { + $this->previousNode = null; + $this->classStringConstantStack = []; + $this->classNameStack = []; + $this->namespaceStack = []; + return null; } public function leaveNode(Node $node): null { + if ($node instanceof ClassLike) { + array_pop($this->classStringConstantStack); + array_pop($this->classNameStack); + } + + if ($node instanceof Namespace_) { + array_pop($this->namespaceStack); + } + return null; } @@ -155,46 +201,122 @@ public function afterTraverse(array $nodes): null } /** - * @param \SplFileInfo $file - * @param \JMS\TranslationBundle\Model\MessageCatalogue $catalogue + * @param array<\PhpParser\Node> $ast */ - public function visitFile(\SplFileInfo $file, MessageCatalogue $catalogue): void + public function visitPhpFile(SplFileInfo $file, MessageCatalogue $catalogue, array $ast): void + { + $this->file = $file; + $this->catalogue = $catalogue; + $this->traverser->traverse($ast); + } + + public function visitFile(SplFileInfo $file, MessageCatalogue $catalogue): void { } - /** - * @param \SplFileInfo $file - * @param \JMS\TranslationBundle\Model\MessageCatalogue $catalogue - * @param \Twig\Node\Node $ast - */ - public function visitTwigFile(\SplFileInfo $file, MessageCatalogue $catalogue, TwigNode $ast): void + public function visitTwigFile(SplFileInfo $file, MessageCatalogue $catalogue, TwigNode $ast): void { } - /** - * @param \PhpParser\Node\Expr\New_ $node - * - * @return string|null - */ - private function getDocCommentForNode(Node $node): ?string + private function getDocCommentForNode(New_ $node): ?string { - // check if there is a doc comment for the ID argument - // ->trans(/** @Desc("FOO") */ 'my.id') - if (null !== $comment = $node->args[0]->getDocComment()) { + $idArg = $this->getArgument($node, 0); + if (null !== $idArg && null !== ($comment = $idArg->getDocComment())) { return $comment->getText(); } - // this may be placed somewhere up in the hierarchy, - // -> /** @Desc("FOO") */ trans('my.id') - // /** @Desc("FOO") */ ->trans('my.id') - // /** @Desc("FOO") */ $translator->trans('my.id') if (null !== $comment = $node->getDocComment()) { return $comment->getText(); } - return - ($comment = $this->previousNode->getDocComment()) !== null + return null !== $this->previousNode && null !== ($comment = $this->previousNode->getDocComment()) ? $comment->getText() : null; } + + private function getArgument(New_ $node, int $index): ?Arg + { + return isset($node->args[$index]) && $node->args[$index] instanceof Arg + ? $node->args[$index] + : null; + } + + private function resolveTranslationId(Node $node): ?string + { + if ($node instanceof String_) { + return $node->value; + } + + if (!$node instanceof ClassConstFetch || !$node->name instanceof Identifier) { + return null; + } + + if (!$this->isCurrentClassConstantFetch($node)) { + return null; + } + + $constants = end($this->classStringConstantStack); + if (false === $constants) { + return null; + } + + return $constants[$node->name->toString()] ?? null; + } + + private function isCurrentClassConstantFetch(ClassConstFetch $node): bool + { + if (!$node->class instanceof Name || [] === $this->classStringConstantStack) { + return false; + } + + $className = strtolower($node->class->toString()); + if (in_array($className, ['self', 'static'], true)) { + return true; + } + + $currentClass = $this->getCurrentClassName(); + if (null === $currentClass) { + return false; + } + + if ($className === strtolower($currentClass)) { + return true; + } + + $namespace = end($this->namespaceStack); + if (false === $namespace || null === $namespace) { + return false; + } + + return ltrim($className, '\\') === strtolower($namespace . '\\' . $currentClass); + } + + private function getCurrentClassName(): ?string + { + $className = end($this->classNameStack); + + return false === $className ? null : $className; + } + + /** + * @return array + */ + private function extractStringConstants(ClassLike $class): array + { + $constants = []; + + foreach ($class->stmts ?? [] as $statement) { + if (!$statement instanceof ClassConst) { + continue; + } + + foreach ($statement->consts as $const) { + if ($const->value instanceof String_) { + $constants[$const->name->toString()] = $const->value->value; + } + } + } + + return $constants; + } } diff --git a/src/lib/Repository/ContentTypeService.php b/src/lib/Repository/ContentTypeService.php index 21fe013dec..a0dc65b809 100644 --- a/src/lib/Repository/ContentTypeService.php +++ b/src/lib/Repository/ContentTypeService.php @@ -708,7 +708,9 @@ protected function validateFieldDefinitionCreateStruct(FieldDefinitionCreateStru if ($fieldDefinitionCreateStruct->isSearchable && !$fieldType->isSearchable()) { $validationErrors[] = new ValidationError( - "FieldType '{$fieldDefinitionCreateStruct->fieldTypeIdentifier}' is not searchable" + 'Field type %field_type_identifier% is not searchable', + null, + ['%field_type_identifier%' => $fieldDefinitionCreateStruct->fieldTypeIdentifier] ); } diff --git a/src/lib/Repository/Mapper/ContentTypeDomainMapper.php b/src/lib/Repository/Mapper/ContentTypeDomainMapper.php index 881de51212..b0854acaf6 100644 --- a/src/lib/Repository/Mapper/ContentTypeDomainMapper.php +++ b/src/lib/Repository/Mapper/ContentTypeDomainMapper.php @@ -277,7 +277,11 @@ public function buildSPIFieldDefinitionFromUpdateStruct( $validationErrors = []; if ($fieldDefinitionUpdateStruct->isSearchable && !$fieldType->isSearchable()) { $validationErrors[] = new ValidationError( - "FieldType '{$fieldDefinition->fieldTypeIdentifier}' is not searchable" + 'Field type %field_type_identifier% is not searchable', + null, + [ + '%field_type_identifier%' => $fieldDefinition->fieldTypeIdentifier, + ] ); } $validationErrors = array_merge( diff --git a/src/lib/Repository/Validator/UserPasswordValidator.php b/src/lib/Repository/Validator/UserPasswordValidator.php index a802df09ef..11f0c3a2b0 100644 --- a/src/lib/Repository/Validator/UserPasswordValidator.php +++ b/src/lib/Repository/Validator/UserPasswordValidator.php @@ -9,6 +9,7 @@ namespace Ibexa\Core\Repository\Validator; use Ibexa\Core\FieldType\ValidationError; +use JMS\TranslationBundle\Annotation\Ignore; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Validation; @@ -198,6 +199,14 @@ private function isCompromised( */ private function createValidationError(string $message, array $values = []): ValidationError { - return new ValidationError($message, null, $values, 'password'); + // TODO: Refactor this helper to use stable translation keys/templates. + // JMS extraction cannot read a ValidationError ID passed through a variable. + return new ValidationError( + /** @Ignore */ + $message, + null, + $values, + 'password' + ); } } diff --git a/tests/lib/Limitation/SiteAccessLimitationTypeTest.php b/tests/lib/Limitation/SiteAccessLimitationTypeTest.php index 2996a4f4a5..01f83d2aa1 100644 --- a/tests/lib/Limitation/SiteAccessLimitationTypeTest.php +++ b/tests/lib/Limitation/SiteAccessLimitationTypeTest.php @@ -9,6 +9,7 @@ use Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException; use Ibexa\Contracts\Core\Repository\Exceptions\NotImplementedException; +use Ibexa\Contracts\Core\Repository\Values\Translation\Message; use Ibexa\Contracts\Core\Repository\Values\User\Limitation; use Ibexa\Contracts\Core\Repository\Values\User\Limitation\ObjectStateLimitation; use Ibexa\Contracts\Core\Repository\Values\User\Limitation\SiteAccessLimitation; @@ -177,6 +178,27 @@ public function testValidateError(SiteAccessLimitation $limitation, $errorCount, self::assertCount($errorCount, $validationErrors); } + /** + * @depends testConstruct + */ + public function testValidateErrorMessage(SiteAccessLimitationType $limitationType): void + { + $validationErrors = $limitationType->validate( + new SiteAccessLimitation( + [ + 'limitationValues' => ['2339567439'], + ] + ) + ); + + self::assertCount(1, $validationErrors); + self::assertInstanceOf(Message::class, $validationErrors[0]->getTranslatableMessage()); + self::assertSame( + "\$limitationValue->limitationValues[0] => Invalid SiteAccess value '2339567439'", + (string) $validationErrors[0]->getTranslatableMessage() + ); + } + /** * @depends testConstruct * diff --git a/tests/lib/MVC/Symfony/Translation/BaseMessageExtractorPhpFileVisitorTestCase.php b/tests/lib/MVC/Symfony/Translation/BaseMessageExtractorPhpFileVisitorTestCase.php index f2543a0fc0..0f5ac0b1f4 100644 --- a/tests/lib/MVC/Symfony/Translation/BaseMessageExtractorPhpFileVisitorTestCase.php +++ b/tests/lib/MVC/Symfony/Translation/BaseMessageExtractorPhpFileVisitorTestCase.php @@ -9,6 +9,10 @@ namespace Ibexa\Tests\Core\MVC\Symfony\Translation; use Doctrine\Common\Annotations\DocParser; +use Ibexa\Core\MVC\Symfony\Translation\Annotation\Domain; +use JMS\TranslationBundle\Annotation\Desc; +use JMS\TranslationBundle\Annotation\Ignore; +use JMS\TranslationBundle\Annotation\Meaning; use JMS\TranslationBundle\Model\MessageCatalogue; use JMS\TranslationBundle\Translation\Extractor\FileVisitorInterface; use JMS\TranslationBundle\Translation\FileSourceFactory; @@ -38,6 +42,13 @@ abstract protected function buildVisitor( protected function setUp(): void { $docParser = new DocParser(); + $docParser->setImports([ + 'desc' => Desc::class, + 'domain' => Domain::class, + 'ignore' => Ignore::class, + 'meaning' => Meaning::class, + ]); + $fileSourceFactory = new FileSourceFactory(self::FIXTURES_DIR); $factory = new ParserFactory(); $this->phpParser = $factory->createForHostVersion(); diff --git a/tests/lib/MVC/Symfony/Translation/ValidationErrorFileVisitorTest.php b/tests/lib/MVC/Symfony/Translation/ValidationErrorFileVisitorTest.php index 9dbc44e9a3..504417c4dd 100644 --- a/tests/lib/MVC/Symfony/Translation/ValidationErrorFileVisitorTest.php +++ b/tests/lib/MVC/Symfony/Translation/ValidationErrorFileVisitorTest.php @@ -11,8 +11,10 @@ use Doctrine\Common\Annotations\DocParser; use Ibexa\Core\MVC\Symfony\Translation\ValidationErrorFileVisitor; use JMS\TranslationBundle\Model\Message; +use JMS\TranslationBundle\Model\MessageCatalogue; use JMS\TranslationBundle\Translation\Extractor\FileVisitorInterface; use JMS\TranslationBundle\Translation\FileSourceFactory; +use SplFileInfo; /** * @covers \Ibexa\Core\MVC\Symfony\Translation\ValidationErrorFileVisitor @@ -30,10 +32,50 @@ public static function getDataForTestExtractTranslation(): iterable new Message('error_1.singular_only', 'ibexa_repository_exceptions'), new Message('error_2.singular', 'ibexa_repository_exceptions'), new Message('error_2.plural', 'ibexa_repository_exceptions'), + new Message('error_3.with_desc', 'ibexa_repository_exceptions'), + new Message('error_4.validators_domain', 'validators'), ], ]; } + public function testExtractTranslationKeepsDescriptionForClassConstant(): void + { + $messageCatalogue = new MessageCatalogue(); + $file = self::FIXTURES_DIR . 'ValidationErrorUsageStub.php'; + $fileInfo = new SplFileInfo($file); + + $ast = $this->getASTFromFile($file); + $this->visitor->visitPhpFile( + $fileInfo, + $messageCatalogue, + $ast + ); + + $message = $messageCatalogue->get('error_3.with_desc', 'ibexa_repository_exceptions'); + + self::assertNotNull($message); + self::assertSame('Validation error extracted from class const', $message->getDesc()); + } + + public function testExtractTranslationAllowsDomainOverride(): void + { + $messageCatalogue = new MessageCatalogue(); + $file = self::FIXTURES_DIR . 'ValidationErrorUsageStub.php'; + $fileInfo = new SplFileInfo($file); + + $ast = $this->getASTFromFile($file); + $this->visitor->visitPhpFile( + $fileInfo, + $messageCatalogue, + $ast + ); + + $message = $messageCatalogue->get('error_4.validators_domain', 'validators'); + + self::assertNotNull($message); + self::assertSame('Validation error extracted into validators domain', $message->getDesc()); + } + protected function buildVisitor(DocParser $docParser, FileSourceFactory $fileSourceFactory): FileVisitorInterface { return new ValidationErrorFileVisitor( diff --git a/tests/lib/MVC/Symfony/Translation/fixtures/ValidationErrorUsageStub.php b/tests/lib/MVC/Symfony/Translation/fixtures/ValidationErrorUsageStub.php index 5802f16f00..cd93c4146f 100644 --- a/tests/lib/MVC/Symfony/Translation/fixtures/ValidationErrorUsageStub.php +++ b/tests/lib/MVC/Symfony/Translation/fixtures/ValidationErrorUsageStub.php @@ -9,12 +9,19 @@ namespace Ibexa\Tests\Core\MVC\Symfony\Translation\fixtures; use Ibexa\Core\FieldType\ValidationError; +use Ibexa\Core\MVC\Symfony\Translation\Annotation\Domain; +use JMS\TranslationBundle\Annotation\Desc; /** * @see \Ibexa\Tests\Core\MVC\Symfony\Translation\ValidationErrorFileVisitorTest */ final class ValidationErrorUsageStub { + private const SINGULAR = 'error_2.singular'; + private const PLURAL = 'error_2.plural'; + private const WITH_DESC = 'error_3.with_desc'; + private const WITH_VALIDATORS_DOMAIN = 'error_4.validators_domain'; + /** * @return \Ibexa\Contracts\Core\FieldType\ValidationError[] */ @@ -23,5 +30,17 @@ public function getErrors(): iterable yield new ValidationError('error_1.singular_only'); yield new ValidationError('error_2.singular', 'error_2.plural'); + + yield new ValidationError(static::SINGULAR, self::PLURAL); + + yield new ValidationError( + /** @Desc("Validation error extracted from class const") */ + self::WITH_DESC + ); + + yield new ValidationError( + /** @Desc("Validation error extracted into validators domain") @Domain("validators") */ + self::WITH_VALIDATORS_DOMAIN + ); } } diff --git a/tests/lib/Repository/ContentTypeServiceValidationErrorTest.php b/tests/lib/Repository/ContentTypeServiceValidationErrorTest.php new file mode 100644 index 0000000000..5d82f1a9b9 --- /dev/null +++ b/tests/lib/Repository/ContentTypeServiceValidationErrorTest.php @@ -0,0 +1,68 @@ +createMock(RepositoryInterface::class), + $this->createMock(Handler::class), + $this->createMock(UserHandler::class), + $this->createMock(ContentDomainMapper::class), + $this->createMock(ContentTypeDomainMapper::class), + $this->createMock(FieldTypeRegistry::class), + $this->createMock(PermissionResolver::class) + ) extends ContentTypeService { + /** + * @return array<\Ibexa\Contracts\Core\FieldType\ValidationError> + */ + public function exposeValidateFieldDefinitionCreateStruct( + FieldDefinitionCreateStruct $fieldDefinitionCreateStruct, + SPIFieldType $fieldType + ): array { + return $this->validateFieldDefinitionCreateStruct($fieldDefinitionCreateStruct, $fieldType); + } + }; + + $fieldDefinitionCreateStruct = new FieldDefinitionCreateStruct(); + $fieldDefinitionCreateStruct->fieldTypeIdentifier = 'ibexa_non_searchable'; + $fieldDefinitionCreateStruct->isSearchable = true; + $fieldDefinitionCreateStruct->validatorConfiguration = []; + $fieldDefinitionCreateStruct->fieldSettings = []; + + $fieldType = $this->createMock(SPIFieldType::class); + $fieldType->method('isSearchable')->willReturn(false); + $fieldType->method('validateValidatorConfiguration')->willReturn([]); + $fieldType->method('validateFieldSettings')->willReturn([]); + + $validationErrors = $service->exposeValidateFieldDefinitionCreateStruct($fieldDefinitionCreateStruct, $fieldType); + + self::assertCount(1, $validationErrors); + self::assertInstanceOf(Message::class, $validationErrors[0]->getTranslatableMessage()); + self::assertSame( + 'Field type ibexa_non_searchable is not searchable', + (string) $validationErrors[0]->getTranslatableMessage() + ); + } +} diff --git a/tests/lib/Repository/Mapper/ContentTypeDomainMapperValidationErrorTest.php b/tests/lib/Repository/Mapper/ContentTypeDomainMapperValidationErrorTest.php new file mode 100644 index 0000000000..6b810160d0 --- /dev/null +++ b/tests/lib/Repository/Mapper/ContentTypeDomainMapperValidationErrorTest.php @@ -0,0 +1,80 @@ +createMock(SPIFieldType::class); + $fieldType->method('isSearchable')->willReturn(false); + $fieldType->method('validateValidatorConfiguration')->willReturn([]); + $fieldType->method('validateFieldSettings')->willReturn([]); + + $fieldTypeRegistry = $this->createMock(FieldTypeRegistry::class); + $fieldTypeRegistry->method('getFieldType')->with('ibexa_non_searchable')->willReturn($fieldType); + + $mapper = new ContentTypeDomainMapper( + $this->createMock(SPITypeHandler::class), + $this->createMock(SPILanguageHandler::class), + $fieldTypeRegistry + ); + + $fieldDefinitionUpdateStruct = new FieldDefinitionUpdateStruct(); + $fieldDefinitionUpdateStruct->isSearchable = true; + $fieldDefinitionUpdateStruct->validatorConfiguration = []; + $fieldDefinitionUpdateStruct->fieldSettings = []; + + $fieldDefinition = new FieldDefinition([ + 'id' => 1, + 'identifier' => 'test_field', + 'fieldTypeIdentifier' => 'ibexa_non_searchable', + 'fieldGroup' => 'content', + 'position' => 0, + 'isTranslatable' => false, + 'isRequired' => false, + 'isThumbnail' => false, + 'isInfoCollector' => false, + 'isSearchable' => false, + 'mainLanguageCode' => 'eng-GB', + 'names' => ['eng-GB' => 'Test field'], + 'descriptions' => ['eng-GB' => ''], + 'validatorConfiguration' => [], + 'fieldSettings' => [], + 'defaultValue' => null, + 'prioritizedLanguages' => [], + ]); + + try { + $mapper->buildSPIFieldDefinitionFromUpdateStruct($fieldDefinitionUpdateStruct, $fieldDefinition, 'eng-GB'); + self::fail('Expected ContentTypeFieldDefinitionValidationException was not thrown.'); + } catch (ContentTypeFieldDefinitionValidationException $exception) { + $errors = $exception->getFieldErrors(); + + self::assertArrayHasKey('test_field', $errors); + self::assertCount(1, $errors['test_field']); + self::assertInstanceOf(Message::class, $errors['test_field'][0]->getTranslatableMessage()); + self::assertSame( + 'Field type ibexa_non_searchable is not searchable', + (string) $errors['test_field'][0]->getTranslatableMessage() + ); + } + } +}