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);