diff --git a/docs/guide/custom-casters.md b/docs/guide/custom-casters.md index 4eb56d3..c65e381 100644 --- a/docs/guide/custom-casters.md +++ b/docs/guide/custom-casters.md @@ -384,3 +384,152 @@ Field::class('status', Status::class) ->factory('fromString') ->serialize('string') ``` + +## Global Type-Based Transformers + +The options above (`factory`, `serialize`) are declared **per field** in your DTO schema. +When the same value object type appears in dozens of fields across many DTOs, repeating +the factory on every field becomes tedious. The `TransformerRegistry` lets you register +a caster or serializer **once per type** and have it apply automatically to every field +whose declared type matches. + +### Registering Casters and Serializers + +```php +use PhpCollective\Dto\Transformer\TransformerRegistry; + +// Array -> Object (used during fromArray / createFromArray / new MyDto($data)) +TransformerRegistry::addCaster( + \DateTimeImmutable::class, + fn (string $value): \DateTimeImmutable => new \DateTimeImmutable($value), +); + +// Object -> scalar/array (used during toArray / touchedToArray / jsonSerialize) +TransformerRegistry::addSerializer( + \DateTimeInterface::class, + fn (\DateTimeInterface $value): string => $value->format(DATE_ATOM), +); +``` + +Register them once during application bootstrap. Every DTO field declared as +`\DateTimeImmutable` (or any subclass/implementor of `\DateTimeInterface` on output) +will now be transformed automatically — no per-field `factory` or `serialize` needed. + +### Precedence Rules + +The registry participates in a clear priority chain: + +**`fromArray` (array → value):** + +1. Nested DTO handling +2. Collection handling +3. `serialize: 'FromArrayToArray'` / `serialize: 'array'` per field +4. **Per-field `factory`** (wins over registry) +5. Enum handling +6. **`TransformerRegistry::findCaster()`** ← new +7. Default constructor fallback (`new $type($value)`) + +**`toArray` (value → array):** + +1. Nested DTO `->toArray()` +2. Collection `->toArray()` +3. **Per-field `serialize`** (wins over registry) +4. Unit enum handling +5. **`TransformerRegistry::findSerializer()`** ← new +6. Value passed through as-is + +This means explicit schema configuration always overrides global registry entries. + +### Inheritance Matching + +Lookups try an exact class-name match first, then walk registered types to find a parent +class or interface match: + +```php +TransformerRegistry::addSerializer( + \DateTimeInterface::class, + fn (\DateTimeInterface $value): string => $value->format(DATE_ATOM), +); + +// Both \DateTime and \DateTimeImmutable are serialized by the entry above. +``` + +Register the most specific type you care about; interface-level registrations are a +convenient catch-all. + +### Framework Integration Examples + +**CakePHP** (in `Application::bootstrap()`): + +```php +use PhpCollective\Dto\Transformer\TransformerRegistry; +use Cake\I18n\DateTime as CakeDateTime; + +TransformerRegistry::addCaster( + CakeDateTime::class, + fn (mixed $value): CakeDateTime => new CakeDateTime($value), +); +TransformerRegistry::addSerializer( + CakeDateTime::class, + fn (CakeDateTime $value): string => $value->toIso8601String(), +); +``` + +**Laravel** (in `AppServiceProvider::boot()`): + +```php +use PhpCollective\Dto\Transformer\TransformerRegistry; +use Illuminate\Support\Carbon; + +TransformerRegistry::addCaster( + Carbon::class, + fn (mixed $value): Carbon => Carbon::parse($value), +); +TransformerRegistry::addSerializer( + Carbon::class, + fn (Carbon $value): string => $value->toIso8601String(), +); +``` + +### Testing + +In tests, clear the registry between cases to avoid leakage across tests: + +```php +protected function tearDown(): void +{ + parent::tearDown(); + TransformerRegistry::clear(); +} +``` + +### API Reference + +| Method | Description | +|--------|-------------| +| `addCaster(string $type, callable $caster)` | Register a caster for a class/interface. | +| `addSerializer(string $type, callable $serializer)` | Register a serializer for a class/interface. | +| `removeCaster(string $type)` | Remove a registered caster. | +| `removeSerializer(string $type)` | Remove a registered serializer. | +| `hasCaster(string $type): bool` | Check if a caster is registered (exact match). | +| `hasSerializer(string $type): bool` | Check if a serializer is registered (exact match). | +| `findCaster(string $type): ?callable` | Look up a caster (exact then inheritance). | +| `findSerializer(object $value): ?callable` | Look up a serializer for an instance. | +| `hasAny(): bool` | Whether any entry is registered. | +| `clear()` | Remove all entries. | + +### When to Use What + +| Need | Use | +|------|-----| +| Transform a single one-off field | Per-field `factory` / `serialize` in the schema | +| Transform the same type across many fields / DTOs | `TransformerRegistry` | +| Override registry behaviour for a specific field | Per-field `factory` / `serialize` (wins over registry) | +| Roundtrip a custom value object | Implement `FromArrayToArrayInterface` | + +::: tip Performance +When any entry is registered, generated DTOs fall back from the optimized fast path +to the reflective code path to ensure the registry is consulted. If you rely on the +`HAS_FAST_PATH` optimization, prefer per-field `factory`/`serialize` over the registry +for hot-path DTOs. +::: diff --git a/docs/guide/runtime-api.md b/docs/guide/runtime-api.md index ace3c49..7e479a5 100644 --- a/docs/guide/runtime-api.md +++ b/docs/guide/runtime-api.md @@ -245,13 +245,43 @@ Dto::setCollectionFactory(fn (array $items) => collect($items)); This is useful for framework-native collection classes in Laravel, CakePHP, or Symfony integrations. +## Transformers + +### `TransformerRegistry` + +Register global, type-based casters (array → object) and serializers (object → array/scalar) +that apply to every DTO field whose declared type matches. This avoids repeating per-field +`factory` / `serialize` declarations for common value object types like `DateTimeImmutable`, +`Money`, or framework `DateTime` wrappers. + +```php +use PhpCollective\Dto\Transformer\TransformerRegistry; + +TransformerRegistry::addCaster( + \DateTimeImmutable::class, + fn (string $value): \DateTimeImmutable => new \DateTimeImmutable($value), +); + +TransformerRegistry::addSerializer( + \DateTimeInterface::class, + fn (\DateTimeInterface $value): string => $value->format(DATE_ATOM), +); +``` + +Per-field `factory` and `serialize` metadata always win over the registry. Lookups match +the exact type first, then walk parent classes and interfaces. See +[Custom Casters](./custom-casters#global-type-based-transformers) for the full precedence +rules and framework integration examples. + ### Resetting Global Runtime State -Because collection factories and default key types are static global settings, tests should reset them explicitly: +Because collection factories, default key types, and transformers are static global settings, +tests should reset them explicitly: ```php Dto::setCollectionFactory(null); Dto::setDefaultKeyType(null); +TransformerRegistry::clear(); ``` ## Runtime Exceptions Worth Knowing diff --git a/src/Dto/Dto.php b/src/Dto/Dto.php index d2d31e6..7e08afa 100644 --- a/src/Dto/Dto.php +++ b/src/Dto/Dto.php @@ -9,6 +9,7 @@ use Countable; use InvalidArgumentException; use JsonSerializable; +use PhpCollective\Dto\Transformer\TransformerRegistry; use PhpCollective\Dto\Utility\Json; use ReflectionProperty; use RuntimeException; @@ -246,7 +247,7 @@ public static function create(?array $data = null, bool $ignoreMissing = false, public function __construct(?array $data = null, bool $ignoreMissing = false, ?string $type = null) { if ($data) { - if ($type === null && static::HAS_FAST_PATH) { + if ($type === null && static::HAS_FAST_PATH && !TransformerRegistry::hasAny()) { if (!$ignoreMissing) { $this->validateFieldNames($data); } @@ -309,7 +310,11 @@ public function read(array $path, $default = null) */ protected function _toArrayInternal(?string $type = null, ?array $fields = null, bool $touched = false): array { - if (!$touched && $fields === null && static::HAS_FAST_PATH && ($type === null || $type === static::TYPE_CAMEL || $type === static::TYPE_DEFAULT)) { + if ( + !$touched && $fields === null && static::HAS_FAST_PATH + && ($type === null || $type === static::TYPE_CAMEL || $type === static::TYPE_DEFAULT) + && !TransformerRegistry::hasAny() + ) { return $this->toArrayFast(); } @@ -353,6 +358,11 @@ protected function _toArrayInternal(?string $type = null, ?array $fields = null, throw new InvalidArgumentException('Expected UnitEnum instance'); } $value = $this->transformEnum($value); + } else { + $serializer = TransformerRegistry::findSerializer($value); + if ($serializer !== null) { + $value = $serializer($value); + } } if ($transformTo !== null) { @@ -547,7 +557,12 @@ protected function setFromArray(array $data, bool $ignoreMissing, ?string $type } elseif (!empty($fieldMeta['isClass']) && !empty($fieldMeta['enum'])) { $value = $this->createEnum($field, $value); } elseif (!empty($fieldMeta['isClass']) && !is_object($value)) { - $value = $this->createWithConstructor($field, $value, $fieldMeta); + $caster = TransformerRegistry::findCaster($fieldMeta['type']); + if ($caster !== null) { + $value = $caster($value); + } else { + $value = $this->createWithConstructor($field, $value, $fieldMeta); + } } if (!$immutable) { diff --git a/src/Transformer/TransformerRegistry.php b/src/Transformer/TransformerRegistry.php new file mode 100644 index 0000000..31dc043 --- /dev/null +++ b/src/Transformer/TransformerRegistry.php @@ -0,0 +1,219 @@ + new \DateTimeImmutable($value), + * ); + * + * TransformerRegistry::addSerializer( + * \DateTimeInterface::class, + * fn(\DateTimeInterface $value) => $value->format(DATE_ATOM), + * ); + * ``` + */ +class TransformerRegistry +{ + /** + * @var array + */ + private static array $casters = []; + + /** + * @var array + */ + private static array $serializers = []; + + /** + * Register a caster for a type. The caster receives the raw value and must + * return an instance of the target type (or compatible). + * + * @param string $type Class or interface name. + * @param callable $caster Signature: `function(mixed $value): object` + * + * @return void + */ + public static function addCaster(string $type, callable $caster): void + { + self::$casters[self::normalize($type)] = $caster; + } + + /** + * Register a serializer for a type. The serializer receives the object and + * returns its array/scalar representation for `toArray()`. + * + * @param string $type Class or interface name. + * @param callable $serializer Signature: `function(object $value): mixed` + * + * @return void + */ + public static function addSerializer(string $type, callable $serializer): void + { + self::$serializers[self::normalize($type)] = $serializer; + } + + /** + * Remove a caster for a specific type. + * + * @param string $type + * + * @return void + */ + public static function removeCaster(string $type): void + { + unset(self::$casters[self::normalize($type)]); + } + + /** + * Remove a serializer for a specific type. + * + * @param string $type + * + * @return void + */ + public static function removeSerializer(string $type): void + { + unset(self::$serializers[self::normalize($type)]); + } + + /** + * Look up a caster for a declared field type. Exact match wins; otherwise + * the first registered parent class or interface that `$type` is an + * instance of is returned. + * + * @param string $type + * + * @return callable|null + */ + public static function findCaster(string $type): ?callable + { + if (self::$casters === []) { + return null; + } + + $type = self::normalize($type); + if (isset(self::$casters[$type])) { + return self::$casters[$type]; + } + + if (!class_exists($type) && !interface_exists($type)) { + return null; + } + + foreach (self::$casters as $registered => $caster) { + if ($registered === $type) { + continue; + } + if ((class_exists($registered) || interface_exists($registered)) && is_a($type, $registered, true)) { + return $caster; + } + } + + return null; + } + + /** + * Look up a serializer for an object instance. Exact match on the concrete + * class wins; otherwise the first registered parent class or interface the + * object is an instance of is returned. + * + * @param object $value + * + * @return callable|null + */ + public static function findSerializer(object $value): ?callable + { + if (self::$serializers === []) { + return null; + } + + $class = $value::class; + if (isset(self::$serializers[$class])) { + return self::$serializers[$class]; + } + + foreach (self::$serializers as $registered => $serializer) { + if ($registered === $class) { + continue; + } + if ($value instanceof $registered) { + return $serializer; + } + } + + return null; + } + + /** + * Check whether any caster or serializer is currently registered. + * Used to bypass generated fast paths that would skip transformation. + * + * @return bool + */ + public static function hasAny(): bool + { + return self::$casters !== [] || self::$serializers !== []; + } + + /** + * @param string $type + * + * @return bool + */ + public static function hasCaster(string $type): bool + { + return isset(self::$casters[self::normalize($type)]); + } + + /** + * @param string $type + * + * @return bool + */ + public static function hasSerializer(string $type): bool + { + return isset(self::$serializers[self::normalize($type)]); + } + + /** + * Clear all registered casters and serializers. Primarily useful in tests. + * + * @return void + */ + public static function clear(): void + { + self::$casters = []; + self::$serializers = []; + } + + /** + * Normalize a type name by stripping any leading backslash. + * + * @param string $type + * + * @return string + */ + private static function normalize(string $type): string + { + return ltrim($type, '\\'); + } +} diff --git a/tests/Dto/DtoTest.php b/tests/Dto/DtoTest.php index 2eeb12e..fd6e5fa 100644 --- a/tests/Dto/DtoTest.php +++ b/tests/Dto/DtoTest.php @@ -31,8 +31,10 @@ use PhpCollective\Dto\Test\TestDto\TraversableDto; use PhpCollective\Dto\Test\TestDto\ValidatedDto; use PhpCollective\Dto\Test\TestDto\WrongFactoryReturnDto; +use PhpCollective\Dto\Transformer\TransformerRegistry; use PHPUnit\Framework\TestCase; use RuntimeException; +use Stringable; use TypeError; class DtoTest extends TestCase @@ -42,6 +44,7 @@ protected function tearDown(): void parent::tearDown(); Dto::setDefaultKeyType(null); Dto::setCollectionFactory(null); + TransformerRegistry::clear(); } public function testCreate(): void @@ -1751,4 +1754,157 @@ public function testMultipleRequiredFieldsErrorMessageFormat(): void ); } } + + public function testGlobalCasterAppliesToClassField(): void + { + TransformerRegistry::addCaster( + PlainClass::class, + fn (mixed $value): PlainClass => new PlainClass('cast:' . (string)$value), + ); + + $dto = new AdvancedDto(['plainData' => 'hello']); + + $plain = $dto->getPlainData(); + $this->assertInstanceOf(PlainClass::class, $plain); + $this->assertSame('cast:hello', $plain->value); + } + + public function testGlobalSerializerAppliesToObjectValue(): void + { + TransformerRegistry::addSerializer( + PlainClass::class, + fn (PlainClass $value): string => 'serialized:' . $value->value, + ); + + $dto = new AdvancedDto(); + $dto->setPlainData(new PlainClass('abc')); + + $this->assertSame( + ['plainData' => 'serialized:abc'], + $dto->toArray(null, ['plainData']), + ); + } + + public function testPerFieldFactoryWinsOverGlobalCaster(): void + { + $registryCalled = false; + TransformerRegistry::addCaster( + FactoryClass::class, + function (mixed $value) use (&$registryCalled): FactoryClass { + $registryCalled = true; + + return new FactoryClass('registry:' . (string)$value); + }, + ); + + $dto = new AdvancedDto(['factoryData' => 'direct']); + + $this->assertFalse($registryCalled, 'Global caster must not run when per-field factory is set'); + $this->assertSame('direct', $dto->getFactoryData()?->value); + } + + public function testGlobalCasterMatchesParentClassByInheritance(): void + { + TransformerRegistry::addCaster( + Stringable::class, + fn (mixed $value): PlainClass => new PlainClass('parent:' . (string)$value), + ); + + // PlainClass is not Stringable, so no match expected here. + $dto = new AdvancedDto(['plainData' => 'x']); + $this->assertSame('x', $dto->getPlainData()?->value); + + TransformerRegistry::clear(); + + // Register caster for the exact concrete type: should apply. + TransformerRegistry::addCaster( + PlainClass::class, + fn (mixed $value): PlainClass => new PlainClass('exact:' . (string)$value), + ); + + $dto = new AdvancedDto(['plainData' => 'x']); + $this->assertSame('exact:x', $dto->getPlainData()?->value); + } + + public function testRemoveAndClearCaster(): void + { + TransformerRegistry::addCaster( + PlainClass::class, + fn (mixed $value): PlainClass => new PlainClass('cast:' . (string)$value), + ); + $this->assertTrue(TransformerRegistry::hasCaster(PlainClass::class)); + + TransformerRegistry::removeCaster(PlainClass::class); + $this->assertFalse(TransformerRegistry::hasCaster(PlainClass::class)); + + // After removal, default constructor fallback kicks in again. + $dto = new AdvancedDto(['plainData' => 'plain']); + $this->assertSame('plain', $dto->getPlainData()?->value); + } + + public function testNormalizesLeadingBackslash(): void + { + TransformerRegistry::addCaster( + '\\' . PlainClass::class, + fn (mixed $value): PlainClass => new PlainClass('normalized:' . (string)$value), + ); + + $this->assertTrue(TransformerRegistry::hasCaster(PlainClass::class)); + + $dto = new AdvancedDto(['plainData' => 'val']); + $this->assertSame('normalized:val', $dto->getPlainData()?->value); + } + + public function testSerializerMatchesByInheritance(): void + { + TransformerRegistry::addSerializer( + Stringable::class, + fn (Stringable $value): string => 'iface:' . (string)$value, + ); + + $dto = new AdvancedDto(); + $factoryData = new FactoryClass('x'); + $dto->setFactoryData($factoryData); + // FactoryClass is not Stringable, so the interface-based serializer must not fire. + $output = $dto->toArray(null, ['factoryData']); + $this->assertSame($factoryData, $output['factoryData']); + + // StringableClass fixture implements Stringable. + $stringable = new StringableClass('abc'); + TransformerRegistry::addSerializer(StringableClass::class, fn ($v): string => 'exact:' . (string)$v); + $this->assertSame( + 'exact:abc', + (TransformerRegistry::findSerializer($stringable))($stringable), + ); + } + + public function testRemoveSerializerAndHasSerializer(): void + { + $this->assertFalse(TransformerRegistry::hasSerializer(PlainClass::class)); + $this->assertFalse(TransformerRegistry::hasAny()); + + TransformerRegistry::addSerializer( + PlainClass::class, + fn (PlainClass $value): string => 'keep:' . $value->value, + ); + $this->assertTrue(TransformerRegistry::hasSerializer(PlainClass::class)); + $this->assertTrue(TransformerRegistry::hasAny()); + + TransformerRegistry::removeSerializer(PlainClass::class); + $this->assertFalse(TransformerRegistry::hasSerializer(PlainClass::class)); + $this->assertFalse(TransformerRegistry::hasAny()); + $this->assertNull(TransformerRegistry::findSerializer(new PlainClass('x'))); + } + + public function testFindCasterReturnsNullForUnknownType(): void + { + $this->assertNull(TransformerRegistry::findCaster(PlainClass::class)); + + TransformerRegistry::addCaster( + PlainClass::class, + fn (mixed $value): PlainClass => new PlainClass((string)$value), + ); + + $this->assertNull(TransformerRegistry::findCaster('Non\\Existent\\Class\\Name')); + } }