Skip to content

Commit 2dc51a0

Browse files
Fix TuuidType exceptions, improve unit and integration tests, add Doctrine type registration test
1 parent 689e2d9 commit 2dc51a0

18 files changed

Lines changed: 623 additions & 95 deletions

README.md

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,11 @@ Configure your available locales and, optionally, the default one and disabled f
6464
```yaml
6565
# config/packages/tmi_translation.yaml
6666
tmi_translation:
67-
locales: [en, fr, ja] # Required: available locales
68-
# default_locale: en # Optional: uses kernel.default_locale if not set
69-
# disabled_firewalls: ['admin'] # Optional: disable filter for specific firewalls
67+
locales: ['en_US', 'de_DE', 'it_IT'] # Required: available locales
68+
# default_locale: 'en_US' # Optional: uses kernel.default_locale if not set
69+
# disabled_firewalls: ['main'] # Optional: disable filter for specific firewalls
7070
```
7171

72-
73-
7472
## 🚀 Quick Start
7573

7674
### Make your entity translatable
@@ -103,7 +101,7 @@ class Product implements TranslatableInterface
103101
Use the service `tmi_translation.translator.entity_translator` to translate a source entity to a target language.
104102

105103
```php
106-
$translatedEntity = $this->get('tmi_translation.translator.entity_translator')->translate($entity, 'de');
104+
$translatedEntity = $this->get('tmi_translation.translator.entity_translator')->translate($entity, 'de_DE');
107105
```
108106

109107
Every attribute of the source entity will be cloned into a new entity, unless specified otherwise with the `EmptyOnTranslate`
@@ -179,7 +177,7 @@ For doing so, you can disable the filter by configuring the disabled_firewalls o
179177
# config/packages/tmi_translation.yaml
180178
tmi_translation:
181179
locales: [en, de, it]
182-
disabled_firewalls: ['admin'] # Disable filter for 'admin' firewall
180+
disabled_firewalls: ['main'] # Disable filter for 'main' firewall
183181
```
184182
185183
### Quick Fix for unique fields

composer.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,17 @@
8484
"rector": [
8585
"Composer\\Config::disableProcessTimeout",
8686
"vendor/bin/rector process --dry-run"
87+
],
88+
"check": [
89+
"@test",
90+
"@cs-check",
91+
"@stan-min"
92+
],
93+
"post-install-cmd": [
94+
"@check"
95+
],
96+
"post-update-cmd": [
97+
"@check"
8798
]
8899
},
89100
"config": {

src/Doctrine/Model/TranslatableInterface.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ interface TranslatableInterface
1010
{
1111
public function getTuuid(): Tuuid|null;
1212

13-
public function setTuuid(Tuuid|null $tuuid): self;
14-
1513
public function getLocale(): string|null;
1614

1715
public function setLocale(string|null $locale = null): self;

src/Doctrine/Model/TranslatableTrait.php

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,16 @@
66

77
use Doctrine\DBAL\Types\Types;
88
use Doctrine\ORM\Mapping as ORM;
9-
use Symfony\Component\Uid\Uuid;
109
use Tmi\TranslationBundle\Doctrine\Attribute\SharedAmongstTranslations;
1110
use Tmi\TranslationBundle\ValueObject\Tuuid;
1211

1312
trait TranslatableTrait
1413
{
15-
#[ORM\Column(type: Types::GUID, length: 36, nullable: true)]
14+
#[ORM\Column(type: 'tuuid', length: 36, nullable: true)]
1615
#[SharedAmongstTranslations]
1716
private Tuuid|null $tuuid = null;
1817

19-
#[ORM\Column(type: Types::STRING, length: 7, nullable: true)]
18+
#[ORM\Column(type: Types::STRING, length: 5, nullable: true)]
2019
private string|null $locale = null;
2120

2221
#[ORM\Column(type: Types::JSON)]
@@ -25,7 +24,7 @@ trait TranslatableTrait
2524
final public function generateTuuid(): void
2625
{
2726
if (null === $this->tuuid) {
28-
$this->tuuid = new Tuuid(Uuid::v4()->toRfc4122());
27+
$this->tuuid = Tuuid::generate();
2928
}
3029
}
3130

@@ -34,16 +33,33 @@ final public function generateTuuid(): void
3433
*/
3534
final public function setTuuid(Tuuid|null $tuuid): self
3635
{
37-
$this->tuuid = $tuuid;
36+
// Initial assignment always allowed (including null for cloning/tests)
37+
if (null === $this->tuuid) {
38+
$this->tuuid = $tuuid;
3839

39-
return $this;
40+
return $this;
41+
}
42+
43+
// Doctrine rehydration of the same value:
44+
// - Only applies if both sides are Tuuid instances
45+
// - Compare via Tuuid::equals()
46+
if ($tuuid instanceof Tuuid && $this->tuuid->equals($tuuid)) {
47+
return $this;
48+
}
49+
50+
// Everything else would reassign a previously set Tuuid
51+
throw new \LogicException('Tuuid is immutable and cannot be reassigned.');
4052
}
4153

4254
/**
4355
* Returns entity's Translation UUID.
4456
*/
45-
final public function getTuuid(): Tuuid|null
57+
final public function getTuuid(): Tuuid
4658
{
59+
if (null === $this->tuuid) {
60+
$this->tuuid = Tuuid::generate();
61+
}
62+
4763
return $this->tuuid;
4864
}
4965

src/Doctrine/Type/TuuidType.php

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,42 +7,55 @@
77
use Doctrine\DBAL\Platforms\AbstractPlatform;
88
use Doctrine\DBAL\Types\ConversionException;
99
use Doctrine\DBAL\Types\GuidType;
10+
use Symfony\Component\Uid\Uuid;
1011
use Tmi\TranslationBundle\ValueObject\Tuuid;
1112

1213
final class TuuidType extends GuidType
1314
{
1415
public const string NAME = 'tuuid';
1516

16-
#[\Override]
17-
public function convertToPHPValue($value, AbstractPlatform $platform): Tuuid|null
17+
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): Tuuid|null
1818
{
1919
if (null === $value) {
20-
return null;
20+
return Tuuid::generate();
2121
}
2222

23-
try {
23+
if ($value instanceof Tuuid) {
24+
return $value;
25+
}
26+
27+
if (is_string($value) && Uuid::isValid($value)) {
2428
return new Tuuid($value);
25-
} catch (\InvalidArgumentException $e) {
26-
throw new ConversionException(sprintf('Cannot convert "%s" to Tuuid', $value), 0, $e);
2729
}
30+
31+
throw new ConversionException(sprintf('Cannot convert "%s" to Tuuid (PHPValue)', get_debug_type($value)));
2832
}
2933

30-
#[\Override]
31-
public function convertToDatabaseValue($value, AbstractPlatform $platform): string|null
34+
public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): string|null
3235
{
3336
if (null === $value) {
34-
return null;
37+
return Tuuid::generate()->getValue();
38+
}
39+
40+
if ($value instanceof Tuuid) {
41+
return $value->getValue();
3542
}
3643

37-
if (!$value instanceof Tuuid) {
38-
throw new \InvalidArgumentException('Value must be a Tuuid object.');
44+
if (is_string($value) && Uuid::isValid($value)) {
45+
return new Tuuid($value)->getValue();
3946
}
4047

41-
return (string) $value;
48+
throw new ConversionException(sprintf('Cannot convert "%s" to Tuuid (DatabaseValue)', get_debug_type($value)));
4249
}
4350

4451
public function getName(): string
4552
{
4653
return self::NAME;
4754
}
55+
56+
public function getMappedDatabaseTypes(AbstractPlatform $platform): array
57+
{
58+
// So that SchemaTool does not cause any problems during mapping
59+
return ['guid'];
60+
}
4861
}

src/Translation/EntityTranslator.php

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -85,21 +85,25 @@ public function processTranslation(TranslationArgs $args): mixed
8585

8686
// Handle top-level entities that implement TranslatableInterface
8787
if ($entity instanceof TranslatableInterface) {
88-
$tuuid = $entity->getTuuid();
89-
$cacheKey = $tuuid instanceof Tuuid ? $tuuid->getValue().':'.$locale : null;
88+
$cacheKey = null;
89+
90+
$tuuid = $entity->getTuuid();
91+
if ($tuuid instanceof Tuuid && !empty($tuuid->getValue())) {
92+
$cacheKey = $tuuid->getValue().':'.$locale;
93+
}
9094

9195
// Return cached translation immediately if available
92-
if ($cacheKey && isset($this->translationCache[(string) $tuuid][$locale])) {
96+
if (null !== $cacheKey && isset($this->translationCache[(string) $tuuid][$locale])) {
9397
return $this->translationCache[(string) $tuuid][$locale];
9498
}
9599

96100
// Detect cycles to avoid infinite recursion
97-
if ($cacheKey && isset($this->inProgress[$cacheKey])) {
101+
if (null !== $cacheKey && isset($this->inProgress[$cacheKey])) {
98102
return $entity;
99103
}
100104

101105
// Mark as in-progress and attempt to warm up existing translations from the database
102-
if ($cacheKey) {
106+
if (null !== $cacheKey) {
103107
$this->inProgress[$cacheKey] = true;
104108
// Warmup existing translations from DB
105109
$this->warmupTranslations([$entity], $locale);
@@ -167,21 +171,17 @@ public function processTranslation(TranslationArgs $args): mixed
167171

168172
$translated = $handler->translate($args);
169173

170-
// POST_TRANSLATE event
171174
if ($entity instanceof TranslatableInterface && $translated instanceof TranslatableInterface) {
175+
// POST_TRANSLATE event
172176
$this->eventDispatcher->dispatch(
173177
new TranslateEvent($entity, $locale, $translated),
174178
TranslateEvent::POST_TRANSLATE,
175179
);
176-
}
177180

178-
// Store translation in cache for reuse
179-
if ($translated instanceof TranslatableInterface && null !== $translated->getTuuid()) {
181+
// Store translation in cache for reuse
180182
$this->translationCache[$translated->getTuuid()->getValue()][$translated->getLocale()] = $translated;
181-
}
182183

183-
// Remove from in-progress set
184-
if ($entity instanceof TranslatableInterface && null !== $entity->getTuuid()) {
184+
// Remove from in-progress set
185185
unset($this->inProgress[$entity->getTuuid()->getValue().':'.$locale]);
186186
}
187187

@@ -238,10 +238,10 @@ private function warmupTranslations(array $entities, string $locale): void
238238
continue;
239239
}
240240
$tuuid = $entity->getTuuid();
241-
if (null === $tuuid || isset($this->translationCache[(string) $tuuid][$locale])) {
241+
if (isset($this->translationCache[(string) $tuuid][$locale])) {
242242
continue;
243243
}
244-
$byClass[$entity::class][] = (string) $tuuid;
244+
$byClass[$entity::class][] = $tuuid->getValue();
245245
}
246246

247247
/** @var class-string<TranslatableInterface> $class */
@@ -265,7 +265,7 @@ private function warmupTranslations(array $entities, string $locale): void
265265

266266
foreach ($translations as $translation) {
267267
// @codeCoverageIgnoreStart
268-
$this->translationCache[(string) $translation->getTuuid()][$translation->getLocale()] = $translation;
268+
$this->translationCache[$translation->getTuuid()->getValue()][$translation->getLocale()] = $translation;
269269
// @codeCoverageIgnoreEnd
270270
}
271271
}

src/ValueObject/Tuuid.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public function __toString(): string
4141
public static function generate(): self
4242
{
4343
// Creates a new UUIDv7 (time-based, SEO-friendly sequence)
44-
return new self(Uuid::v7()->toRfc4122());
44+
return new self(strtolower(Uuid::v7()->toRfc4122()));
4545
}
4646

4747
/**

tests/DependencyInjection/TmiTranslationExtensionTest.php

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,20 @@
44

55
namespace Tmi\TranslationBundle\Test\DependencyInjection;
66

7+
use Doctrine\DBAL\Exception;
8+
use Doctrine\DBAL\Types\Exception\TypesException;
9+
use Doctrine\DBAL\Types\Type;
710
use PHPUnit\Framework\TestCase;
811
use Symfony\Component\DependencyInjection\ContainerBuilder;
912
use Tmi\TranslationBundle\DependencyInjection\TmiTranslationExtension;
13+
use Tmi\TranslationBundle\Doctrine\Type\TuuidType;
1014
use Tmi\TranslationBundle\EventSubscriber\LocaleFilterConfigurator;
1115

1216
final class TmiTranslationExtensionTest extends TestCase
1317
{
1418
/**
15-
* @throws \Exception
19+
* @throws TypesException
20+
* @throws Exception
1621
*/
1722
public function testLoad(): void
1823
{
@@ -23,7 +28,7 @@ public function testLoad(): void
2328
[
2429
'locales' => ['en_US', 'de_DE', 'it_IT'],
2530
'default_locale' => 'en_US',
26-
'disabled_firewalls' => ['admin'],
31+
'disabled_firewalls' => ['main'],
2732
],
2833
];
2934
$extension->load($config, $container);
@@ -51,4 +56,43 @@ public function testPrependDoesNothing(): void
5156
// Assert the container is still an instance of ContainerBuilder
5257
$this->assertInstanceOf(ContainerBuilder::class, $container);
5358
}
59+
60+
/**
61+
* @throws Exception
62+
* @throws TypesException
63+
* @throws \ReflectionException
64+
*/
65+
public function testDoctrineTypeRegistration(): void
66+
{
67+
// Unregister TuuidType if it exists
68+
if (Type::hasType(TuuidType::NAME)) {
69+
$reflection = new \ReflectionClass(Type::class);
70+
$typesMap = $reflection->getProperty('typesMap');
71+
$map = $typesMap->getValue();
72+
unset($map[TuuidType::NAME]);
73+
$typesMap->setValue(null, $map);
74+
}
75+
76+
$this->assertFalse(
77+
Type::hasType(TuuidType::NAME) && Type::getType(TuuidType::NAME) instanceof TuuidType,
78+
'TuuidType should not be registered yet',
79+
);
80+
81+
$container = new ContainerBuilder();
82+
$extension = new TmiTranslationExtension();
83+
84+
$config = [
85+
[
86+
'locales' => ['en_US', 'de_DE'],
87+
'default_locale' => 'en_US',
88+
],
89+
];
90+
91+
$extension->load($config, $container);
92+
93+
$this->assertTrue(Type::hasType(TuuidType::NAME), 'TuuidType should be registered by the extension');
94+
95+
$typeInstance = Type::getType(TuuidType::NAME);
96+
$this->assertInstanceOf(TuuidType::class, $typeInstance, 'Registered type should be an instance of TuuidType');
97+
}
5498
}

0 commit comments

Comments
 (0)