Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/phpstan.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
parameters:
level: 9
paths:
- src
includes:
- vendor/phpstan/phpstan-strict-rules/rules.neon
1 change: 1 addition & 0 deletions src/DependencyInjection/RegisterDoctrineTypePass.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $types */
$types = $container->getParameter('doctrine.dbal.connection_factory.types');
$types[TranslatableStringType::NAME] = ['class' => TranslatableStringType::class];

Expand Down
4 changes: 4 additions & 0 deletions src/DependencyInjection/WebfactoryPolyglotExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
56 changes: 35 additions & 21 deletions src/Doctrine/PersistentTranslatable.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,6 +26,9 @@
use Webfactory\Bundle\PolyglotBundle\TranslatableInterface;

/**
* @template T
* @implements TranslatableInterface<T>
*
* 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().
Expand Down Expand Up @@ -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<object> $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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<array-key, object> $collection */
$collection = $this->translationCollection->getValue($this->entity);
$collection->add($entity);

self::$_translations[$this->class][$this->oid][$locale] = $entity;
$this->unitOfWork->persist($entity);
Expand All @@ -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);
Expand All @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -241,13 +251,17 @@ private function isTranslationCached(string $locale): bool
*/
private function cacheTranslation(string $locale): void
{
/** @var $translationsInAllLanguages Selectable */
/** @var Selectable<array-key, object> $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;
}

Expand All @@ -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(
Expand Down
28 changes: 20 additions & 8 deletions src/Doctrine/PolyglotListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -39,28 +40,34 @@ final class PolyglotListener
private array $translatedClasses = [];

/**
* @var array<WeakReference>
* @var array<WeakReference<object>>
*/
private array $entitiesWithTranslatables = [];

/**
* @var list<PersistentTranslatable>
* @var list<PersistentTranslatable<mixed>>
*/
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<EntityManager> $event
*/
public function postLoad(LifecycleEventArgs $event): void
{
// Called when the entity has been hydrated
$this->injectPersistentTranslatables($event->getObjectManager(), $event->getObject());
}

/**
* @param LifecycleEventArgs<EntityManager> $event
*/
public function prePersist(LifecycleEventArgs $event): void
{
// Called when a new entity is passed to persist() for the first time
Expand Down Expand Up @@ -109,7 +116,7 @@ public function postFlush(PostFlushEventArgs $event): void
/**
* @return list<TranslatableClassMetadata>
*/
private function getTranslationMetadatas(object $entity, EntityManager $em): array
private function getTranslationMetadatas(object $entity, EntityManagerInterface $em): array
{
$class = $entity::class;

Expand All @@ -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;
}
}
Expand All @@ -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<object> $className
*/
private function loadTranslationMetadataForClass(string $className, EntityManagerInterface $em): ?TranslatableClassMetadata
{
// In memory cache
if (isset($this->translatedClasses[$className])) {
Expand All @@ -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;
Expand All @@ -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);
Expand Down
27 changes: 17 additions & 10 deletions src/Doctrine/SerializedTranslatableClassMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,33 +11,40 @@

final class SerializedTranslatableClassMetadata
{
/**
* @var class-string<object>
*/
public string $class;

/**
* @var class-string<object>
*/
public string $translationClass;

/**
* @var array<string, array{0: string, 1: string}>
* @var array<string, array{0: class-string<object>, 1: string}>
*/
public array $translationFieldMapping = [];
public array $translationFieldMapping;

/**
* @var array<string, array{0: string, 1: string}>
* @var array<string, array{0: class-string<object>, 1: string}>
*/
public array $translatedProperties = [];
public array $translatedProperties;

/**
* @var array{0: string, 1: string}
* @var array{0: class-string<object>, 1: string}
*/
public array $translationLocaleProperty = [];
public array $translationLocaleProperty;

/**
* @var array{0: string, 1: string}
* @var array{0: class-string<object>, 1: string}
*/
public array $translationsCollectionProperty = [];
public array $translationsCollectionProperty;

/**
* @var array{0: string, 1: string}
* @var array{0: class-string<object>, 1: string}
*/
public array $translationMappingProperty = [];
public array $translationMappingProperty;

public string $primaryLocale;
}
Loading