diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml new file mode 100644 index 0000000..cea2c31 --- /dev/null +++ b/.github/workflows/phpstan.yml @@ -0,0 +1,28 @@ +# Update this by running +# curl https://gist.githubusercontent.com/mpdude/ca93a185bcbf56eb7e341632ad4f8263/raw/fix-cs-php.yml > .github/workflows/fix-cs-php.yml + +on: + push: + branches: ['master'] + pull_request: + branches: ['*'] + +name: Coding Standards + +jobs: + phpstan: + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@main + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + + - name: Install Composer dependencies + run: composer install --no-progress --prefer-dist + + - name: Run PHPStan + run: composer run phpstan \ No newline at end of file diff --git a/composer.json b/composer.json index b018251..210cdb0 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,8 @@ "require-dev": { "doctrine/common": "^2.0|^3.1", "doctrine/doctrine-bundle": "^2.0", + "phpstan/phpstan": "^1.12", + "phpstan/phpstan-strict-rules": "^1.6", "phpunit/phpunit": "^9.6.18", "symfony/error-handler": "^6.4|^7.0", "symfony/framework-bundle": "^5.4|^6.4|^7.0", diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..e5dcceb --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,6 @@ +parameters: + level: 9 + paths: + - src +includes: + - vendor/phpstan/phpstan-strict-rules/rules.neon \ No newline at end of file diff --git a/src/DependencyInjection/RegisterDoctrineTypePass.php b/src/DependencyInjection/RegisterDoctrineTypePass.php index 794d5b8..968ea30 100644 --- a/src/DependencyInjection/RegisterDoctrineTypePass.php +++ b/src/DependencyInjection/RegisterDoctrineTypePass.php @@ -15,6 +15,7 @@ public function process(ContainerBuilder $container): void throw new RuntimeException('This bundle expects DoctrineBundle to be registered, it should provide the doctrine.dbal.connection_factory.types container parameter'); } + /** @var array $types */ $types = $container->getParameter('doctrine.dbal.connection_factory.types'); $types[TranslatableStringType::NAME] = ['class' => TranslatableStringType::class]; diff --git a/src/DependencyInjection/WebfactoryPolyglotExtension.php b/src/DependencyInjection/WebfactoryPolyglotExtension.php index ecc59e8..b49dc77 100644 --- a/src/DependencyInjection/WebfactoryPolyglotExtension.php +++ b/src/DependencyInjection/WebfactoryPolyglotExtension.php @@ -22,6 +22,10 @@ public function load(array $configs, ContainerBuilder $container): void $loader->load('services.xml'); $m = ['defaultLocale' => 'de_DE']; + + /** + * @var array{defaultLocale: string} $c + */ foreach ($configs as $c) { $m = array_merge($m, $c); } diff --git a/src/Doctrine/PersistentTranslatable.php b/src/Doctrine/PersistentTranslatable.php index 309d303..e3666b9 100644 --- a/src/Doctrine/PersistentTranslatable.php +++ b/src/Doctrine/PersistentTranslatable.php @@ -9,6 +9,7 @@ namespace Webfactory\Bundle\PolyglotBundle\Doctrine; +use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Criteria; use Doctrine\Common\Collections\Selectable; use Doctrine\ORM\UnitOfWork; @@ -25,6 +26,9 @@ use Webfactory\Bundle\PolyglotBundle\TranslatableInterface; /** + * @template T + * @implements TranslatableInterface + * * This class implements `TranslatableInterface` for entities that are managed by * the entity manager. PolyglotListener will replace `Translatable` instances with * instances of this class as soon as a new entity is passed to EntityManager::persist(). @@ -61,17 +65,17 @@ final class PersistentTranslatable implements TranslatableInterface private LoggerInterface $logger; /** - * @param UnitOfWork $unitOfWork The UoW managing the entity that contains this PersistentTranslatable - * @param class-string $class The class of the entity containing this PersistentTranslatable instance - * @param object $entity The entity containing this PersistentTranslatable instance - * @param string $primaryLocale The locale for which the translated value will be persisted in the "main" entity - * @param DefaultLocaleProvider $defaultLocaleProvider DefaultLocaleProvider that provides the locale to use when no explicit locale is passed to e. g. translate() - * @param ReflectionProperty $translationProperty ReflectionProperty pointing to the field in the translations class that holds the translated value to use - * @param ReflectionProperty $translationCollection ReflectionProperty pointing to the collection in the main class that holds translation instances - * @param ReflectionClass $translationClass ReflectionClass for the class holding translated values - * @param ReflectionProperty $localeField ReflectionProperty pointing to the field in the translations class that holds a translation's locale - * @param ReflectionProperty $translationMapping ReflectionProperty pointing to the field in the translations class that refers back to the main entity (the owning side of the one-to-many translations collection). - * @param ReflectionProperty $translatedProperty ReflectionProperty pointing to the field in the main entity where this PersistentTranslatable instance will be used + * @param UnitOfWork $unitOfWork The UoW managing the entity that contains this PersistentTranslatable + * @param class-string $class The class of the entity containing this PersistentTranslatable instance + * @param object $entity The entity containing this PersistentTranslatable instance + * @param string $primaryLocale The locale for which the translated value will be persisted in the "main" entity + * @param DefaultLocaleProvider $defaultLocaleProvider DefaultLocaleProvider that provides the locale to use when no explicit locale is passed to e. g. translate() + * @param ReflectionProperty $translationProperty ReflectionProperty pointing to the field in the translations class that holds the translated value to use + * @param ReflectionProperty $translationCollection ReflectionProperty pointing to the collection in the main class that holds translation instances + * @param ReflectionClass $translationClass ReflectionClass for the class holding translated values + * @param ReflectionProperty $localeField ReflectionProperty pointing to the field in the translations class that holds a translation's locale + * @param ReflectionProperty $translationMapping ReflectionProperty pointing to the field in the translations class that refers back to the main entity (the owning side of the one-to-many translations collection). + * @param ReflectionProperty $translatedProperty ReflectionProperty pointing to the field in the main entity where this PersistentTranslatable instance will be used */ public function __construct( private readonly UnitOfWork $unitOfWork, @@ -116,7 +120,7 @@ public function eject(): void $type = $this->translatedProperty->getType(); if ($type instanceof ReflectionNamedType && TranslatableInterface::class === $type->getName() && \is_string($value)) { - if (!$this->valueForEjection || $this->valueForEjection->getPrimaryValue() !== $value) { + if (null === $this->valueForEjection || $this->valueForEjection->getPrimaryValue() !== $value) { $this->valueForEjection = new UninitializedPersistentTranslatable($value); } $value = $this->valueForEjection; @@ -150,7 +154,10 @@ private function createTranslationEntity(string $locale): object $this->localeField->setValue($entity, $locale); $this->translationMapping->setValue($entity, $this->entity); - $this->translationCollection->getValue($this->entity)->add($entity); + + /** @var Collection $collection */ + $collection = $this->translationCollection->getValue($this->entity); + $collection->add($entity); self::$_translations[$this->class][$this->oid][$locale] = $entity; $this->unitOfWork->persist($entity); @@ -160,12 +167,13 @@ private function createTranslationEntity(string $locale): object public function setTranslation(mixed $value, ?string $locale = null): void { - $locale = $locale ?: $this->getDefaultLocale(); + $locale ??= $this->getDefaultLocale(); + if ($locale === $this->primaryLocale) { $this->setPrimaryValue($value); } else { $entity = $this->getTranslationEntity($locale); - if (!$entity) { + if (null === $entity) { $entity = $this->createTranslationEntity($locale); } $this->translationProperty->setValue($entity, $value); @@ -177,13 +185,15 @@ public function setTranslation(mixed $value, ?string $locale = null): void */ public function translate(?string $locale = null): mixed { - $locale = $locale ?: $this->getDefaultLocale(); + $locale ??= $this->getDefaultLocale(); + try { if ($locale === $this->primaryLocale) { return $this->primaryValue; } - if ($entity = $this->getTranslationEntity($locale)) { + $entity = $this->getTranslationEntity($locale); + if (null !== $entity) { $translated = $this->translationProperty->getValue($entity); if (null !== $translated) { return $translated; @@ -205,12 +215,12 @@ public function translate(?string $locale = null): mixed public function isTranslatedInto(string $locale): bool { if ($locale === $this->primaryLocale) { - return !empty($this->primaryValue); + return '' !== $this->primaryValue && null !== $this->primaryValue; } $entity = $this->getTranslationEntity($locale); - return $entity && null !== $this->translationProperty->getValue($entity); + return null !== $entity && null !== $this->translationProperty->getValue($entity); } public function __toString(): string @@ -241,13 +251,17 @@ private function isTranslationCached(string $locale): bool */ private function cacheTranslation(string $locale): void { - /** @var $translationsInAllLanguages Selectable */ + /** @var Selectable $translationsInAllLanguages */ $translationsInAllLanguages = $this->translationCollection->getValue($this->entity); $criteria = $this->createLocaleCriteria($locale); $translationsFilteredByLocale = $translationsInAllLanguages->matching($criteria); $translationInLocale = ($translationsFilteredByLocale->count() > 0) ? $translationsFilteredByLocale->first() : null; + if (\is_bool($translationInLocale)) { + return; + } + self::$_translations[$this->class][$this->oid][$locale] = $translationInLocale; } @@ -268,7 +282,7 @@ private function stringifyException(Throwable $e): string { $exceptionAsString = ''; while (null !== $e) { - if (!empty($exceptionAsString)) { + if ('' !== $exceptionAsString) { $exceptionAsString .= \PHP_EOL.'Previous exception: '.\PHP_EOL; } $exceptionAsString .= \sprintf( diff --git a/src/Doctrine/PolyglotListener.php b/src/Doctrine/PolyglotListener.php index e01c5c2..fea14ac 100644 --- a/src/Doctrine/PolyglotListener.php +++ b/src/Doctrine/PolyglotListener.php @@ -10,6 +10,7 @@ namespace Webfactory\Bundle\PolyglotBundle\Doctrine; use Doctrine\ORM\EntityManager; +use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Event\PostFlushEventArgs; use Doctrine\ORM\Event\PreFlushEventArgs; use Doctrine\Persistence\Event\LifecycleEventArgs; @@ -39,28 +40,34 @@ final class PolyglotListener private array $translatedClasses = []; /** - * @var array + * @var array> */ private array $entitiesWithTranslatables = []; /** - * @var list + * @var list> */ private array $ejectedTranslatables = []; public function __construct( private readonly DefaultLocaleProvider $defaultLocaleProvider, - private readonly LoggerInterface $logger = null ?? new NullLogger(), + private readonly LoggerInterface $logger = new NullLogger(), private readonly RuntimeReflectionService $reflectionService = new RuntimeReflectionService(), ) { } + /** + * @param LifecycleEventArgs $event + */ public function postLoad(LifecycleEventArgs $event): void { // Called when the entity has been hydrated $this->injectPersistentTranslatables($event->getObjectManager(), $event->getObject()); } + /** + * @param LifecycleEventArgs $event + */ public function prePersist(LifecycleEventArgs $event): void { // Called when a new entity is passed to persist() for the first time @@ -109,7 +116,7 @@ public function postFlush(PostFlushEventArgs $event): void /** * @return list */ - private function getTranslationMetadatas(object $entity, EntityManager $em): array + private function getTranslationMetadatas(object $entity, EntityManagerInterface $em): array { $class = $entity::class; @@ -118,7 +125,8 @@ private function getTranslationMetadatas(object $entity, EntityManager $em): arr $classMetadata = $em->getClassMetadata($class); foreach (array_merge([$classMetadata->name], $classMetadata->parentClasses) as $className) { - if ($tm = $this->loadTranslationMetadataForClass($className, $em)) { + $tm = $this->loadTranslationMetadataForClass($className, $em); + if (null !== $tm) { $this->translatableClassMetadatasByClass[$class][] = $tm; } } @@ -127,7 +135,10 @@ private function getTranslationMetadatas(object $entity, EntityManager $em): arr return $this->translatableClassMetadatasByClass[$class]; } - private function loadTranslationMetadataForClass($className, EntityManager $em): ?TranslatableClassMetadata + /** + * @param class-string $className + */ + private function loadTranslationMetadataForClass(string $className, EntityManagerInterface $em): ?TranslatableClassMetadata { // In memory cache if (isset($this->translatedClasses[$className])) { @@ -138,8 +149,9 @@ private function loadTranslationMetadataForClass($className, EntityManager $em): $cache = $em->getConfiguration()->getMetadataCache(); $cacheKey = $this->getCacheKey($className); - if ($cache?->hasItem($cacheKey)) { + if (null !== $cache && $cache->hasItem($cacheKey)) { $item = $cache->getItem($cacheKey); + /** @var SerializedTranslatableClassMetadata|null $data */ $data = $item->get(); if (null === $data) { $this->translatedClasses[$className] = null; @@ -163,7 +175,7 @@ private function loadTranslationMetadataForClass($className, EntityManager $em): } // Save if cache driver available - if ($cache) { + if (null !== $cache) { $item = $cache->getItem($cacheKey); $item->set($meta?->sleep()); $cache->save($item); diff --git a/src/Doctrine/SerializedTranslatableClassMetadata.php b/src/Doctrine/SerializedTranslatableClassMetadata.php index fc3fdbf..92317d0 100644 --- a/src/Doctrine/SerializedTranslatableClassMetadata.php +++ b/src/Doctrine/SerializedTranslatableClassMetadata.php @@ -11,33 +11,40 @@ final class SerializedTranslatableClassMetadata { + /** + * @var class-string + */ public string $class; + + /** + * @var class-string + */ public string $translationClass; /** - * @var array + * @var array, 1: string}> */ - public array $translationFieldMapping = []; + public array $translationFieldMapping; /** - * @var array + * @var array, 1: string}> */ - public array $translatedProperties = []; + public array $translatedProperties; /** - * @var array{0: string, 1: string} + * @var array{0: class-string, 1: string} */ - public array $translationLocaleProperty = []; + public array $translationLocaleProperty; /** - * @var array{0: string, 1: string} + * @var array{0: class-string, 1: string} */ - public array $translationsCollectionProperty = []; + public array $translationsCollectionProperty; /** - * @var array{0: string, 1: string} + * @var array{0: class-string, 1: string} */ - public array $translationMappingProperty = []; + public array $translationMappingProperty; public string $primaryLocale; } diff --git a/src/Doctrine/TranslatableClassMetadata.php b/src/Doctrine/TranslatableClassMetadata.php index 41b56df..9b3bfe8 100644 --- a/src/Doctrine/TranslatableClassMetadata.php +++ b/src/Doctrine/TranslatableClassMetadata.php @@ -12,12 +12,14 @@ use Doctrine\ORM\EntityManager; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\ClassMetadataFactory; +use Doctrine\ORM\Mapping\InverseSideMapping; use Doctrine\Persistence\Mapping\RuntimeReflectionService; use Psr\Log\LoggerInterface; use ReflectionClass; use ReflectionProperty; use RuntimeException; use Webfactory\Bundle\PolyglotBundle\Attribute; +use Webfactory\Bundle\PolyglotBundle\Exception\ShouldNotHappen; use Webfactory\Bundle\PolyglotBundle\Locale\DefaultLocaleProvider; use Webfactory\Bundle\PolyglotBundle\Translatable; @@ -60,7 +62,7 @@ final class TranslatableClassMetadata private ?ReflectionProperty $translationLocaleProperty = null; /** - * Die Übersetzungs-Klasse. + * @var ReflectionClass|null */ private ?ReflectionClass $translationClass = null; @@ -72,19 +74,22 @@ final class TranslatableClassMetadata private ?LoggerInterface $logger = null; /** - * @param class-string $class The FQCN for the entity class whose translatable fields are described by this - * TranslatableClassMetadata instance. If the class has base entity classes (or mapped - * superclasses), a separate instance of TranslatableClassMetadata will be used for - * their fields. + * @param class-string $class The FQCN for the entity class whose translatable fields are described by this + * TranslatableClassMetadata instance. If the class has base entity classes (or mapped + * superclasses), a separate instance of TranslatableClassMetadata will be used for + * their fields. */ private function __construct( private readonly string $class ) { } + /** + * @param class-string $class + */ public static function parseFromClass(string $class, ClassMetadataFactory $classMetadataFactory): ?self { - /** @var ClassMetadata $cm */ + /** @var ClassMetadata $cm */ $cm = $classMetadataFactory->getMetadataFor($class); $tm = new static($class); @@ -107,21 +112,47 @@ public function setLogger(?LoggerInterface $logger = null): void public function sleep(): SerializedTranslatableClassMetadata { + if (null === $this->translationClass) { + throw new ShouldNotHappen('translationClass cannot be null'); + } + + if (null === $this->primaryLocale) { + throw new ShouldNotHappen('primaryLocale cannot be null'); + } + + if (null === $this->translationLocaleProperty) { + throw new ShouldNotHappen('translationLocaleProperty cannot be null'); + } + + if (null === $this->translationMappingProperty) { + throw new ShouldNotHappen('translationMappingProperty cannot be null'); + } + + if (null === $this->translationsCollectionProperty) { + throw new ShouldNotHappen('translationsCollectionProperty cannot be null'); + } + $sleep = new SerializedTranslatableClassMetadata(); $sleep->class = $this->class; $sleep->primaryLocale = $this->primaryLocale; $sleep->translationClass = $this->translationClass->name; foreach ($this->translationFieldMapping as $fieldname => $property) { + // @see https://github.com/phpstan/phpstan/issues/11334 + // @phpstan-ignore assign.propertyType $sleep->translationFieldMapping[$fieldname] = [$property->class, $property->name]; } foreach ($this->translatedProperties as $fieldname => $property) { + // @phpstan-ignore assign.propertyType $sleep->translatedProperties[$fieldname] = [$property->class, $property->name]; } + // @phpstan-ignore assign.propertyType $sleep->translationLocaleProperty = [$this->translationLocaleProperty->class, $this->translationLocaleProperty->name]; + // @phpstan-ignore assign.propertyType $sleep->translationsCollectionProperty = [$this->translationsCollectionProperty->class, $this->translationsCollectionProperty->name]; + // @phpstan-ignore assign.propertyType $sleep->translationMappingProperty = [$this->translationMappingProperty->class, $this->translationMappingProperty->name]; return $sleep; @@ -134,11 +165,13 @@ public static function wakeup(SerializedTranslatableClassMetadata $data, Runtime $self->translationClass = $reflectionService->getClass($data->translationClass); foreach ($data->translationFieldMapping as $fieldname => $property) { - $self->translationFieldMapping[$fieldname] = $reflectionService->getAccessibleProperty(...$property); + $self->translationFieldMapping[$fieldname] = $reflectionService->getAccessibleProperty(...$property) ?? + throw new ShouldNotHappen("Cannot get reflection on {$property[0]}::{$property[1]}"); } foreach ($data->translatedProperties as $fieldname => $property) { - $self->translatedProperties[$fieldname] = $reflectionService->getAccessibleProperty(...$property); + $self->translatedProperties[$fieldname] = $reflectionService->getAccessibleProperty(...$property) ?? + throw new ShouldNotHappen("Cannot get reflection on {$property[0]}::{$property[1]}"); } $self->translationsCollectionProperty = $reflectionService->getAccessibleProperty(...$data->translationsCollectionProperty); @@ -179,9 +212,12 @@ private function assertAttributesAreComplete(string $class): void } } + /** + * @param ClassMetadata $cm + */ private function findTranslatedProperties(ClassMetadata $cm, ClassMetadataFactory $classMetadataFactory): void { - if (!$this->translationClass) { + if (null === $this->translationClass) { return; } @@ -189,7 +225,7 @@ private function findTranslatedProperties(ClassMetadata $cm, ClassMetadataFactor $translationClassMetadata = $classMetadataFactory->getMetadataFor($this->translationClass->getName()); /* Iterate all properties of the class, not only those mapped by Doctrine */ - foreach ($cm->getReflectionClass()->getProperties() as $reflectionProperty) { + foreach ($cm->getReflectionClass()?->getProperties() ?? [] as $reflectionProperty) { $propertyName = $reflectionProperty->name; /* @@ -197,23 +233,30 @@ private function findTranslatedProperties(ClassMetadata $cm, ClassMetadataFactor already contains that declaration, we need not include it. */ $declaringClass = $reflectionProperty->getDeclaringClass()->name; - if ($declaringClass !== $cm->name && $cm->parentClasses && is_a($cm->parentClasses[0], $declaringClass, true)) { + if ($declaringClass !== $cm->name && [] !== $cm->parentClasses && is_a($cm->parentClasses[0], $declaringClass, true)) { continue; } $attributes = $reflectionProperty->getAttributes(Attribute\Translatable::class); - if (!$attributes) { + if ([] === $attributes) { continue; } $attribute = $attributes[0]->newInstance(); - $this->translatedProperties[$propertyName] = $reflectionService->getAccessibleProperty($cm->name, $propertyName); - $translationFieldname = $attribute->getTranslationFieldname() ?: $propertyName; - $this->translationFieldMapping[$propertyName] = $reflectionService->getAccessibleProperty($translationClassMetadata->name, $translationFieldname); + $this->translatedProperties[$propertyName] = $reflectionService->getAccessibleProperty($cm->name, $propertyName) ?? + throw new ShouldNotHappen("Cannot get reflection for {$cm->name}::{$propertyName}."); + + $translationFieldname = $attribute->getTranslationFieldname() ?? $propertyName; + + $this->translationFieldMapping[$propertyName] = $reflectionService->getAccessibleProperty($translationClassMetadata->name, $translationFieldname) ?? + throw new ShouldNotHappen("Cannot get reflection for {$translationClassMetadata->name}::{$translationFieldname}."); } } + /** + * @param ClassMetadata $cm + */ private function findTranslationsCollection(ClassMetadata $cm, ClassMetadataFactory $classMetadataFactory): void { foreach ($cm->associationMappings as $fieldName => $mapping) { @@ -224,12 +267,16 @@ private function findTranslationsCollection(ClassMetadata $cm, ClassMetadataFact $reflectionProperty = $cm->getReflectionProperty($fieldName); - if ($reflectionProperty->getAttributes(Attribute\TranslationCollection::class)) { + if (null !== $reflectionProperty && [] !== $reflectionProperty->getAttributes(Attribute\TranslationCollection::class)) { + if (!$mapping instanceof InverseSideMapping) { + return; + } + $this->translationsCollectionProperty = $reflectionProperty; - $translationEntityMetadata = $classMetadataFactory->getMetadataFor($mapping['targetEntity']); + $translationEntityMetadata = $classMetadataFactory->getMetadataFor($mapping->targetEntity); $this->translationClass = $translationEntityMetadata->getReflectionClass(); - $this->translationMappingProperty = $translationEntityMetadata->getReflectionProperty($mapping['mappedBy']); + $this->translationMappingProperty = $translationEntityMetadata->getReflectionProperty($mapping->mappedBy); $this->parseTranslationsEntity($translationEntityMetadata); return; @@ -237,6 +284,9 @@ private function findTranslationsCollection(ClassMetadata $cm, ClassMetadataFact } } + /** + * @param ClassMetadata $cm + */ private function findPrimaryLocale(ClassMetadata $cm): void { foreach (array_merge([$cm->name], $cm->parentClasses) as $class) { @@ -250,12 +300,15 @@ private function findPrimaryLocale(ClassMetadata $cm): void } } + /** + * @param ClassMetadata $cm + */ private function parseTranslationsEntity(ClassMetadata $cm): void { foreach ($cm->fieldMappings as $fieldName => $mapping) { $reflectionProperty = $cm->getReflectionProperty($fieldName); - if ($reflectionProperty->getAttributes(Attribute\Locale::class)) { + if (null !== $reflectionProperty && [] !== $reflectionProperty->getAttributes(Attribute\Locale::class)) { $this->translationLocaleProperty = $reflectionProperty; return; @@ -274,13 +327,13 @@ public function injectNewPersistentTranslatables(object $entity, EntityManager $ $entityManager->getUnitOfWork(), $this->class, $entity, - $this->primaryLocale, + $this->primaryLocale ?? new ShouldNotHappen('primaryLocale cannot be null.'), $defaultLocaleProvider, $this->translationFieldMapping[$fieldName], - $this->translationsCollectionProperty, - $this->translationClass, - $this->translationLocaleProperty, - $this->translationMappingProperty, + $this->translationsCollectionProperty ?? throw new ShouldNotHappen('primaryLocale cannot be null.'), + $this->translationClass ?? throw new ShouldNotHappen('primaryLocale cannot be null.'), + $this->translationLocaleProperty ?? throw new ShouldNotHappen('primaryLocale cannot be null.'), + $this->translationMappingProperty ?? throw new ShouldNotHappen('primaryLocale cannot be null.'), $property, $this->logger ); @@ -289,7 +342,7 @@ public function injectNewPersistentTranslatables(object $entity, EntityManager $ } /** - * @return list + * @return list> */ public function ejectPersistentTranslatables(object $entity): array { diff --git a/src/Doctrine/TranslatableStringType.php b/src/Doctrine/TranslatableStringType.php index ce38328..624b220 100644 --- a/src/Doctrine/TranslatableStringType.php +++ b/src/Doctrine/TranslatableStringType.php @@ -5,6 +5,7 @@ use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Types\Type; use RuntimeException; +use Webfactory\Bundle\PolyglotBundle\Exception\ShouldNotHappen; /** * Doctrine type to support mapping database string types (VARCHAR, TEXT etc.) @@ -17,10 +18,11 @@ class TranslatableStringType extends Type public function getSQLDeclaration(array $column, AbstractPlatform $platform): string { - if ($column['options']['use_text_column'] ?? false) { + if (isset($column['options']) && \is_array($column['options']) && ($column['options']['use_text_column'] ?? false)) { return $platform->getClobTypeDeclarationSQL($column); } + // @phpstan-ignore function.alreadyNarrowedType if (method_exists($platform, 'getStringTypeDeclarationSQL')) { return $platform->getStringTypeDeclarationSQL($column); } else { @@ -49,6 +51,10 @@ public function convertToDatabaseValue($value, AbstractPlatform $platform): ?str public function convertToPHPValue($value, AbstractPlatform $platform): UninitializedPersistentTranslatable { + if (!\is_string($value)) { + throw new ShouldNotHappen('Translated value is not string.'); + } + return new UninitializedPersistentTranslatable($value); } diff --git a/src/Doctrine/UninitializedPersistentTranslatable.php b/src/Doctrine/UninitializedPersistentTranslatable.php index 66270a5..6d4a530 100644 --- a/src/Doctrine/UninitializedPersistentTranslatable.php +++ b/src/Doctrine/UninitializedPersistentTranslatable.php @@ -7,6 +7,7 @@ /** * @psalm-internal Webfactory\Bundle\PolyglotBundle + * @implements TranslatableInterface */ final class UninitializedPersistentTranslatable implements TranslatableInterface { diff --git a/src/Entity/BaseTranslation.php b/src/Entity/BaseTranslation.php index 91b3efa..bf00708 100644 --- a/src/Entity/BaseTranslation.php +++ b/src/Entity/BaseTranslation.php @@ -28,22 +28,22 @@ class BaseTranslation #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] - protected $id; + protected mixed $id; /** * @ORM\Column */ #[Polyglot\Locale] #[ORM\Column] - protected $locale; + protected ?string $locale; /** * @ORM\JoinColumn(name="entity_id", referencedColumnName="id", nullable=false) */ #[ORM\JoinColumn(name: 'entity_id', referencedColumnName: 'id', nullable: false)] - protected $entity; + protected object $entity; - public function getLocale() + public function getLocale(): ?string { return $this->locale; } diff --git a/src/Exception/ShouldNotHappen.php b/src/Exception/ShouldNotHappen.php new file mode 100644 index 0000000..bed1d2a --- /dev/null +++ b/src/Exception/ShouldNotHappen.php @@ -0,0 +1,16 @@ + */ final class Translatable implements TranslatableInterface { /** * Maps locales to translations. * - * @var array + * @var array */ private array $translations = []; + /** + * @param T $value + */ public function __construct( mixed $value = null, private string|DefaultLocaleProvider|null $defaultLocale = null, @@ -66,23 +72,29 @@ public function setDefaultLocale(string $locale): void $this->defaultLocale = $locale; } + /** + * @return T|null + */ public function translate(?string $locale = null): mixed { - $locale = $locale ?: $this->getDefaultLocale(); + $locale ??= $this->getDefaultLocale(); return $this->translations[$locale] ?? null; } + /** + * @param T $value + */ public function setTranslation(mixed $value, ?string $locale = null): void { - $locale = $locale ?: $this->getDefaultLocale(); + $locale ??= $this->getDefaultLocale(); $this->translations[$locale] = $value; } public function isTranslatedInto(string $locale): bool { - return isset($this->translations[$locale]) && !empty($this->translations[$locale]); + return isset($this->translations[$locale]) && '' !== (string) $this->translations[$locale]; } public function __toString(): string @@ -92,6 +104,7 @@ public function __toString(): string /** * Copies translations from this object into the given one. + * @param TranslatableInterface $p */ public function copy(TranslatableInterface $p): void { diff --git a/src/TranslatableChain.php b/src/TranslatableChain.php index 0f624b5..affb7cb 100644 --- a/src/TranslatableChain.php +++ b/src/TranslatableChain.php @@ -9,21 +9,32 @@ * * Goes through a list of `TranslatableInterface` instances and returns the first * (optionally, non-empty) translation found. Updates are passed to the primary translatable. + * + * @template T + * @implements TranslatableInterface */ final class TranslatableChain implements TranslatableInterface { /** - * @var list + * @var list> */ private array $translatables; + /** + * @param TranslatableInterface ...$translatables + * @return self + */ public static function firstNonEmpty(TranslatableInterface ...$translatables): self { return new self(function ($value) { - return null !== $value && '' !== trim($value); + return null !== $value && '' !== trim((string) $value); }, ...$translatables); } + /** + * @param TranslatableInterface ...$translatables + * @return self + */ public static function firstTranslation(TranslatableInterface ...$translatables): self { return new self(function ($value) { @@ -31,18 +42,30 @@ public static function firstTranslation(TranslatableInterface ...$translatables) }, ...$translatables); } + /** + * @param Closure(T): bool $comparator + * @param TranslatableInterface ...$translatables + */ private function __construct( private readonly Closure $comparator, TranslatableInterface ...$translatables, ) { - $this->translatables = $translatables; + $this->translatables = array_values($translatables); } + /** + * @return T|null + */ public function translate(?string $locale = null): mixed { $c = $this->comparator; foreach ($this->translatables as $translation) { $value = $translation->translate($locale); + + if (null === $value) { + continue; + } + if ($c($value)) { return $value; } @@ -69,6 +92,6 @@ public function isTranslatedInto(string $locale): bool public function __toString(): string { - return $this->translate(); + return (string) $this->translate(); } } diff --git a/src/TranslatableInterface.php b/src/TranslatableInterface.php index 8e7b191..ff58827 100644 --- a/src/TranslatableInterface.php +++ b/src/TranslatableInterface.php @@ -21,7 +21,7 @@ interface TranslatableInterface * * @param string|null $locale The target locale or null for the current locale. * - * @return TTranslatedValue The translation or null if not available. + * @return TTranslatedValue|null The translation or null if not available. */ public function translate(?string $locale = null): mixed; diff --git a/tests/Fixtures/Entity/TestEntityTranslation.php b/tests/Fixtures/Entity/TestEntityTranslation.php index 20a9932..4384786 100644 --- a/tests/Fixtures/Entity/TestEntityTranslation.php +++ b/tests/Fixtures/Entity/TestEntityTranslation.php @@ -22,25 +22,23 @@ class TestEntityTranslation extends BaseTranslation * @var TestEntity */ #[ORM\ManyToOne(targetEntity: TestEntity::class, inversedBy: 'translations')] - protected $entity; + protected object $entity; /** * Contains the translation. * * Must be protected to be usable when this class is used as base for a mock. - * - * @var string */ #[ORM\Column(type: 'string')] - protected $text; + protected ?string $text; - /** - * @param string|null $text - */ - public function __construct($locale = null, $text = null, ?TestEntity $entity = null) + public function __construct(?string $locale = null, ?string $text = null, ?TestEntity $entity = null) { $this->locale = $locale; $this->text = $text; - $this->entity = $entity; + + if (null !== $entity) { + $this->entity = $entity; + } } }