diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index d27e225a5..1920b9aa9 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -1907,6 +1907,11 @@
+
+
+
+
+
@@ -2004,10 +2009,6 @@
get('MvcTranslator')]]>
-
-
-
-
@@ -2018,7 +2019,6 @@
-
getServiceLocator()]]>
diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php
index 188ebc39d..ae6babd67 100644
--- a/src/ConfigProvider.php
+++ b/src/ConfigProvider.php
@@ -25,12 +25,14 @@ public function getDependencyConfig()
{
return [
'aliases' => [
- 'ValidatorManager' => ValidatorPluginManager::class,
+ Translator\TranslatorInterface::class => Translator\Translator::class,
+ 'ValidatorManager' => ValidatorPluginManager::class,
// Legacy Zend Framework aliases
'Zend\Validator\ValidatorPluginManager' => ValidatorPluginManager::class,
],
'factories' => [
+ Translator\Translator::class => Translator\TranslatorFactory::class,
ValidatorPluginManager::class => ValidatorPluginManagerFactory::class,
],
];
diff --git a/src/Translator/DummyTranslator.php b/src/Translator/DummyTranslator.php
new file mode 100644
index 000000000..fb153abd0
--- /dev/null
+++ b/src/Translator/DummyTranslator.php
@@ -0,0 +1,25 @@
+translator->translate($message, $textDomain, $locale);
+ }
+
+ /**
+ * Provide a pluralized translation of the given string using the given text domain and locale
+ *
+ * @param string $singular
+ * @param string $plural
+ * @param int $number
+ * @param string $textDomain
+ * @param string $locale
+ * @return string
+ */
+ public function translatePlural($singular, $plural, $number, $textDomain = 'default', $locale = null)
+ {
+ return $this->translator->translatePlural($singular, $plural, $number, $textDomain, $locale);
+ }
+}
diff --git a/src/Translator/TranslatorFactory.php b/src/Translator/TranslatorFactory.php
new file mode 100644
index 000000000..35086ab73
--- /dev/null
+++ b/src/Translator/TranslatorFactory.php
@@ -0,0 +1,122 @@
+has(TranslatorInterface::class)) {
+ return new Translator($container->get(TranslatorInterface::class));
+ }
+
+ return $this->marshalTranslator($container);
+ }
+
+ /**
+ * Marshal an Translator.
+ *
+ * If configuration exists, will pass it to the I18nTranslator::factory,
+ * decorating the returned instance in an MvcTranslator.
+ *
+ * Otherwise:
+ *
+ * - returns an Translator decorating a DummyTranslator instance if
+ * ext/intl is not loaded.
+ * - returns an Translator decorating an empty I18nTranslator instance.
+ */
+ private function marshalTranslator(ContainerInterface $container): Translator
+ {
+ // Load a translator from configuration, if possible
+ $translator = $this->marshalTranslatorFromConfig($container);
+
+ if ($translator instanceof Translator) {
+ return $translator;
+ }
+
+ // If ext/intl is not loaded, return a dummy translator
+ if (! extension_loaded('intl')) {
+ return new Translator(new DummyTranslator());
+ }
+
+ return new Translator(new I18nTranslator());
+ }
+
+ /**
+ * Attempt to marshal a translator from configuration.
+ *
+ * Returns:
+ * - an Translator seeded with a DummyTranslator if "translator"
+ * configuration is available, and evaluates to boolean false.
+ * - an Translator seed with an I18nTranslator if "translator"
+ * configuration is available, and is a non-empty array or a Traversable
+ * instance.
+ * - null in all other cases, including absence of a configuration service.
+ */
+ private function marshalTranslatorFromConfig(ContainerInterface $container): ?Translator
+ {
+ if (! $container->has('config')) {
+ return null;
+ }
+
+ $config = $container->get('config');
+
+ if (! is_array($config) || ! array_key_exists('translator', $config)) {
+ return null;
+ }
+
+ // 'translator' => false
+ if ($config['translator'] === false) {
+ return new Translator(new DummyTranslator());
+ }
+
+ // Empty translator configuration
+ if (is_array($config['translator']) && empty($config['translator'])) {
+ return null;
+ }
+
+ // Unusable translator configuration
+ if (! is_array($config['translator']) && ! $config['translator'] instanceof Traversable) {
+ return null;
+ }
+
+ // Create translator from configuration
+ $i18nTranslator = I18nTranslator::factory($config['translator']);
+
+ // Inject plugins, if present
+ if ($container->has('TranslatorPluginManager')) {
+ $loaderManager = $container->get('TranslatorPluginManager');
+
+ assert($loaderManager instanceof LoaderPluginManager);
+
+ $i18nTranslator->setPluginManager($loaderManager);
+ }
+
+ // Inject into service manager instances
+ if ($container instanceof ServiceManager) {
+ $container->setService(TranslatorInterface::class, $i18nTranslator);
+ }
+
+ return new Translator($i18nTranslator);
+ }
+}
diff --git a/src/ValidatorPluginManager.php b/src/ValidatorPluginManager.php
index f4db1d800..6d0408626 100644
--- a/src/ValidatorPluginManager.php
+++ b/src/ValidatorPluginManager.php
@@ -2,6 +2,7 @@
namespace Laminas\Validator;
+use Laminas\I18n\Translator\TranslatorInterface;
use Laminas\I18n\Validator as I18nValidator;
use Laminas\ServiceManager\AbstractPluginManager;
use Laminas\ServiceManager\Exception\InvalidServiceException;
@@ -584,15 +585,27 @@ public function injectTranslator($first, $second)
$validator = $first;
}
+ if (! $validator instanceof Translator\TranslatorAwareInterface) {
+ return;
+ }
+
// V2 means we pull it from the parent container
if ($container === $this && method_exists($container, 'getServiceLocator') && $container->getServiceLocator()) {
$container = $container->getServiceLocator();
}
- if ($validator instanceof Translator\TranslatorAwareInterface) {
- if ($container && $container->has('MvcTranslator')) {
- $validator->setTranslator($container->get('MvcTranslator'));
- }
+ if (! $container instanceof ContainerInterface) {
+ return;
+ }
+
+ if ($container->has('MvcTranslator')) {
+ $validator->setTranslator($container->get('MvcTranslator'));
+
+ return;
+ }
+
+ if ($container->has(TranslatorInterface::class)) {
+ $validator->setTranslator($container->get(Translator\TranslatorInterface::class));
}
}
diff --git a/test/Translator/TranslatorFactoryTest.php b/test/Translator/TranslatorFactoryTest.php
new file mode 100644
index 000000000..426b57d7a
--- /dev/null
+++ b/test/Translator/TranslatorFactoryTest.php
@@ -0,0 +1,365 @@
+createMock(TranslatorInterface::class);
+
+ $container = $this->createMock(ServiceManager::class);
+ $container
+ ->expects(self::once())
+ ->method('has')
+ ->with(TranslatorInterface::class)
+ ->willReturn(true);
+ $container
+ ->expects(self::once())
+ ->method('get')
+ ->with(TranslatorInterface::class)
+ ->willReturn($translator);
+
+ self::assertInstanceOf(ContainerInterface::class, $container);
+
+ $factory = new TranslatorFactory();
+ $test = $factory($container);
+
+ $this->assertInstanceOf(Translator::class, $test);
+
+ $prop = new ReflectionProperty($test, 'translator');
+ $this->assertSame($translator, $prop->getValue($test));
+ }
+
+ /** @psalm-return array */
+ public static function expectedTranslatorProvider(): array
+ {
+ return extension_loaded('intl')
+ ? ['intl-loaded' => [I18nTranslator::class]]
+ : ['no-intl-loaded' => [DummyTranslator::class]];
+ }
+
+ /**
+ * @dataProvider expectedTranslatorProvider
+ * @psalm-param class-string $expected
+ */
+ public function testFactoryReturnsTranslatorDecoratingDefaultTranslatorWhenNoConfigPresent(
+ string $expected
+ ): void {
+ $container = $this->createMock(ServiceManager::class);
+ $container
+ ->expects(self::exactly(2))
+ ->method('has')
+ ->willReturnMap(
+ [
+ [TranslatorInterface::class, false],
+ ['config', false],
+ ]
+ );
+ $container
+ ->expects(self::never())
+ ->method('get');
+
+ self::assertInstanceOf(ContainerInterface::class, $container);
+
+ $factory = new TranslatorFactory();
+ $test = $factory($container);
+
+ $this->assertInstanceOf(Translator::class, $test);
+
+ $prop = new ReflectionProperty($test, 'translator');
+ $this->assertInstanceOf($expected, $prop->getValue($test));
+ }
+
+ /**
+ * @dataProvider expectedTranslatorProvider
+ * @psalm-param class-string $expected
+ */
+ public function testFactoryReturnsMvcDecoratorDecoratingDefaultTranslatorWhenNoTranslatorConfigPresent(
+ string $expected
+ ): void {
+ $container = $this->createMock(ServiceManager::class);
+ $container
+ ->expects(self::exactly(2))
+ ->method('has')
+ ->willReturnMap(
+ [
+ [TranslatorInterface::class, false],
+ ['Zend\I18n\Translator\TranslatorInterface', false],
+ ['config', true],
+ ]
+ );
+ $container
+ ->expects(self::once())
+ ->method('get')
+ ->with('config')
+ ->willReturn([]);
+
+ self::assertInstanceOf(ContainerInterface::class, $container);
+
+ $factory = new TranslatorFactory();
+ $test = $factory($container);
+
+ $this->assertInstanceOf(Translator::class, $test);
+
+ $prop = new ReflectionProperty($test, 'translator');
+ $this->assertInstanceOf($expected, $prop->getValue($test));
+ }
+
+ public function testFactoryReturnsMvcDecoratorDecoratingDummyTranslatorWhenTranslatorConfigIsFalse(): void
+ {
+ $container = $this->createMock(ServiceManager::class);
+ $container
+ ->expects(self::exactly(2))
+ ->method('has')
+ ->willReturnMap(
+ [
+ [TranslatorInterface::class, false],
+ ['Zend\I18n\Translator\TranslatorInterface', false],
+ ['config', true],
+ ]
+ );
+ $container
+ ->expects(self::once())
+ ->method('get')
+ ->with('config')
+ ->willReturn(['translator' => false]);
+
+ self::assertInstanceOf(ContainerInterface::class, $container);
+
+ $factory = new TranslatorFactory();
+ $test = $factory($container);
+
+ $this->assertInstanceOf(Translator::class, $test);
+
+ $prop = new ReflectionProperty($test, 'translator');
+ $this->assertInstanceOf(DummyTranslator::class, $prop->getValue($test));
+ }
+
+ /**
+ * @dataProvider expectedTranslatorProvider
+ * @psalm-param class-string $expected
+ */
+ public function testFactoryReturnsMvcDecoratorDecoratingDefaultTranslatorWhenEmptyTranslatorConfigPresent(
+ string $expected
+ ): void {
+ $container = $this->createMock(ServiceManager::class);
+ $container
+ ->expects(self::exactly(2))
+ ->method('has')
+ ->willReturnMap(
+ [
+ [TranslatorInterface::class, false],
+ ['Zend\I18n\Translator\TranslatorInterface', false],
+ ['config', true],
+ ]
+ );
+ $container
+ ->expects(self::once())
+ ->method('get')
+ ->with('config')
+ ->willReturn(['translator' => []]);
+
+ self::assertInstanceOf(ContainerInterface::class, $container);
+
+ $factory = new TranslatorFactory();
+ $test = $factory($container);
+
+ $this->assertInstanceOf(Translator::class, $test);
+
+ $prop = new ReflectionProperty($test, 'translator');
+ $this->assertInstanceOf($expected, $prop->getValue($test));
+ }
+
+ /** @psalm-return array, 1: class-string}> */
+ public static function invalidTranslatorConfig(): array
+ {
+ $expectedTranslator = extension_loaded('intl')
+ ? I18nTranslator::class
+ : DummyTranslator::class;
+
+ return [
+ 'null' => [['translator' => null], $expectedTranslator],
+ 'true' => [['translator' => true], $expectedTranslator],
+ 'zero' => [['translator' => 0], $expectedTranslator],
+ 'int' => [['translator' => 1], $expectedTranslator],
+ 'float-0' => [['translator' => 0.0], $expectedTranslator],
+ 'float' => [['translator' => 1.1], $expectedTranslator],
+ 'string' => [['translator' => 'invalid'], $expectedTranslator],
+ 'object' => [['translator' => (object) ['translator' => 'invalid']], $expectedTranslator],
+ ];
+ }
+
+ /**
+ * @param array $config
+ * @psalm-param class-string $expected
+ * @dataProvider invalidTranslatorConfig
+ */
+ public function testFactoryReturnsDecoratorDecoratingDefaultTranslatorWithInvalidTranslatorConfig(
+ $config,
+ $expected
+ ): void {
+ $container = $this->createMock(ServiceManager::class);
+ $container
+ ->expects(self::exactly(2))
+ ->method('has')
+ ->willReturnMap(
+ [
+ [TranslatorInterface::class, false],
+ ['Zend\I18n\Translator\TranslatorInterface', false],
+ ['config', true],
+ ]
+ );
+ $container
+ ->expects(self::once())
+ ->method('get')
+ ->with('config')
+ ->willReturn($config);
+
+ self::assertInstanceOf(ContainerInterface::class, $container);
+
+ $factory = new TranslatorFactory();
+ $test = $factory($container);
+
+ $this->assertInstanceOf(Translator::class, $test);
+
+ $prop = new ReflectionProperty($test, 'translator');
+ $this->assertInstanceOf($expected, $prop->getValue($test));
+ }
+
+ /**
+ * @psalm-return array|ArrayAccess}>
+ */
+ public static function validTranslatorConfig(): array
+ {
+ $locale = Locale::getDefault() === 'en-US' ? 'de-DE' : Locale::getDefault();
+ $config = [
+ 'locale' => $locale,
+ 'event_manager_enabled' => true,
+ ];
+
+ return [
+ 'array' => [$config],
+ 'traversable' => [new ArrayObject($config)],
+ ];
+ }
+
+ /**
+ * @requires extension intl
+ * @dataProvider validTranslatorConfig
+ * @param array|ArrayAccess $config
+ */
+ public function testFactoryReturnsConfiguredTranslatorWhenValidConfigIsPresent($config): void
+ {
+ $container = $this->createMock(ServiceManager::class);
+ $container
+ ->expects(self::exactly(3))
+ ->method('has')
+ ->willReturnMap(
+ [
+ [TranslatorInterface::class, false],
+ ['Zend\I18n\Translator\TranslatorInterface', false],
+ ['config', true],
+ ['TranslatorPluginManager', false],
+ ]
+ );
+ $container
+ ->expects(self::once())
+ ->method('get')
+ ->with('config')
+ ->willReturn(['translator' => $config]);
+ $container
+ ->expects(self::once())
+ ->method('setService')
+ ->with(TranslatorInterface::class, new IsInstanceOf(I18nTranslator::class));
+
+ self::assertInstanceOf(ContainerInterface::class, $container);
+
+ $factory = new TranslatorFactory();
+ $test = $factory($container);
+
+ $this->assertInstanceOf(Translator::class, $test);
+
+ $prop = new ReflectionProperty($test, 'translator');
+ $decorated = $prop->getValue($test);
+
+ $this->assertInstanceOf(I18nTranslator::class, $decorated);
+ $locale = $config['locale'] ?? null;
+ self::assertIsString($locale);
+ $this->assertEquals($locale, $decorated->getLocale());
+ $this->assertTrue($decorated->isEventManagerEnabled());
+ }
+
+ /**
+ * @param array|ArrayAccess $config
+ * @requires extension intl
+ * @dataProvider validTranslatorConfig
+ */
+ public function testFactoryReturnsConfiguredTranslatorInjectedWithTranslatorPluginManagerWhenValidConfigIsPresent(
+ $config
+ ): void {
+ $loaders = $this->createMock(LoaderPluginManager::class);
+
+ $container = $this->createMock(ServiceManager::class);
+ $container
+ ->expects(self::exactly(3))
+ ->method('has')
+ ->willReturnMap(
+ [
+ [TranslatorInterface::class, false],
+ ['Zend\I18n\Translator\TranslatorInterface', false],
+ ['config', true],
+ ['TranslatorPluginManager', true],
+ ]
+ );
+ $container
+ ->expects(self::exactly(2))
+ ->method('get')
+ ->willReturnMap(
+ [
+ ['config', ['translator' => $config]],
+ ['TranslatorPluginManager', $loaders],
+ ]
+ );
+ $container
+ ->expects(self::once())
+ ->method('setService')
+ ->with(TranslatorInterface::class, new IsInstanceOf(I18nTranslator::class));
+
+ self::assertInstanceOf(ContainerInterface::class, $container);
+
+ $factory = new TranslatorFactory();
+ $test = $factory($container);
+
+ $this->assertInstanceOf(Translator::class, $test);
+
+ $prop = new ReflectionProperty($test, 'translator');
+ $decorated = $prop->getValue($test);
+
+ $this->assertInstanceOf(I18nTranslator::class, $decorated);
+ $this->assertEquals($config['locale'], $decorated->getLocale());
+ $this->assertTrue($decorated->isEventManagerEnabled());
+ $this->assertSame($loaders, $decorated->getPluginManager());
+ }
+}
diff --git a/test/Translator/TranslatorTest.php b/test/Translator/TranslatorTest.php
new file mode 100644
index 000000000..af613f1ea
--- /dev/null
+++ b/test/Translator/TranslatorTest.php
@@ -0,0 +1,78 @@
+i18nTranslator = $this->createMock(I18nTranslator::class);
+ $this->translator = new Translator($this->i18nTranslator);
+ }
+
+ public function testTranslate(): void
+ {
+ $message = 'This is the message';
+ $textDomain = 'default';
+ $locale = 'en_US';
+
+ $this->i18nTranslator->expects($this->once())
+ ->method('translate')
+ ->with($message, $textDomain, $locale)
+ ->willReturn($message);
+
+ $this->assertEquals(
+ $message,
+ $this->translator->translate(
+ $message,
+ $textDomain,
+ $locale
+ )
+ );
+ }
+
+ public function testTranslatePlural(): void
+ {
+ $singular = 'singular';
+ $plural = 'plural';
+ $number = 2;
+ $textDomain = 'default';
+ $locale = 'en_US';
+
+ $this->i18nTranslator->expects($this->once())
+ ->method('translatePlural')
+ ->with(
+ $singular,
+ $plural,
+ $number,
+ $textDomain,
+ $locale
+ )
+ ->willReturn($singular);
+
+ $this->assertEquals(
+ $singular,
+ $this->translator->translatePlural(
+ $singular,
+ $plural,
+ $number,
+ $textDomain,
+ $locale
+ )
+ );
+ }
+}
diff --git a/test/ValidatorPluginManagerTest.php b/test/ValidatorPluginManagerTest.php
index 10843fb98..01435b1b6 100644
--- a/test/ValidatorPluginManagerTest.php
+++ b/test/ValidatorPluginManagerTest.php
@@ -11,6 +11,7 @@
use Laminas\Validator\Exception\RuntimeException;
use Laminas\Validator\Explode;
use Laminas\Validator\NotEmpty;
+use Laminas\Validator\Translator\TranslatorInterface;
use Laminas\Validator\ValidatorInterface;
use Laminas\Validator\ValidatorPluginManager;
use LaminasTest\Validator\TestAsset\InMemoryContainer;
@@ -58,20 +59,53 @@ public function testAllowsInjectingTranslator(): void
self::assertEquals($translator, $validator->getTranslator());
}
- public function testNoTranslatorInjectedWhenTranslatorIsNotPresent(): void
+ public function testAllowsInjectingTranslatorInterface(): void
{
+ $translator = $this->createMock(Translator::class);
+
$container = $this->createMock(ContainerInterface::class);
+ $container
+ ->expects(self::exactly(2))
+ ->method('has')
+ ->willReturnMap(
+ [
+ ['MvcTranslator', false],
+ [\Laminas\I18n\Translator\TranslatorInterface::class, true],
+ ]
+ );
+
$container
->expects(self::once())
+ ->method('get')
+ ->with(TranslatorInterface::class)
+ ->willReturn($translator);
+
+ $validators = new ValidatorPluginManager($container);
+
+ $validator = $validators->get(NotEmpty::class);
+
+ self::assertInstanceOf(AbstractValidator::class, $validator);
+ self::assertEquals($translator, $validator->getTranslator());
+ }
+
+ public function testNoTranslatorInjectedWhenTranslatorIsNotPresent(): void
+ {
+ $container = $this->createMock(ContainerInterface::class);
+
+ $container
+ ->expects(self::exactly(2))
->method('has')
- ->with('MvcTranslator')
- ->willReturn(false);
+ ->willReturnMap(
+ [
+ ['MvcTranslator', false],
+ [TranslatorInterface::class, false],
+ ]
+ );
$container
->expects(self::never())
- ->method('get')
- ->with('MvcTranslator');
+ ->method('get');
$validators = new ValidatorPluginManager($container);