ConverterRegistry.php
PHP
Path: src/Converter/ConverterRegistry.php
<?php
namespace mini\Converter;
use mini\Hooks\Handler;
/**
* Registry for type converters
*
* Manages converters that transform values from one type to another.
* Supports union input types and automatic resolution to most specific converter.
*
* Features:
* - Union input types (e.g., string|int)
* - Single-type converters can override union members (more specific wins)
* - Detects conflicting registrations (overlapping unions, duplicates)
* - Type hierarchy resolution for objects (class → interfaces → parent → parent interfaces)
* - Fallback handler for type families (e.g., all BackedEnums)
*
* Resolution order:
* - Direct single-type converter (most specific)
* - Union type converter via alias (less specific)
* - Parent class converters
* - Interface converters
* - Fallback handler (if registered)
*
* @see ConverterRegistryInterface For the public API documentation
*/
class ConverterRegistry implements ConverterRegistryInterface
{
/**
* Converters indexed by target type, then input type
*
* Structure: targetType => [
* 'A|B' => ConverterInterface, // union converter
* 'A' => 'A|B', // alias → union key
* 'B' => 'A|B', // alias → union key
* 'C' => ConverterInterface, // direct single-type converter
* ]
*
* @var array<string, array<string, ConverterInterface|string>>
*/
private array $converters = [];
/**
* Fallback handler for conversions without registered converters
*
* Listeners receive (mixed $input, string $targetType, ?string $sourceType)
* and should return the converted value or null if they can't handle it.
*
* @var Handler<mixed, mixed>
*/
public readonly Handler $fallback;
public function __construct()
{
$this->fallback = new Handler('converter-fallback');
}
/**
* Register a converter
*
* @param ConverterInterface|\Closure $converter Converter instance or typed closure
* @param ?string $targetName Optional explicit target name (bypasses return type validation for closures)
* @param ?string $sourceName Optional explicit source name (for named source types like 'sql-value')
* @throws \InvalidArgumentException If converter conflicts with existing registration
*/
public function register(ConverterInterface|\Closure $converter, ?string $targetName = null, ?string $sourceName = null): void
{
$this->doRegister($converter, false, $targetName, $sourceName);
}
/**
* Replace an existing converter
*
* @param ConverterInterface|\Closure $converter Converter instance or typed closure
* @param ?string $targetName Optional explicit target name (bypasses return type validation for closures)
* @param ?string $sourceName Optional explicit source name (for named source types like 'sql-value')
* @throws \InvalidArgumentException If closure signature is invalid
*/
public function replace(ConverterInterface|\Closure $converter, ?string $targetName = null, ?string $sourceName = null): void
{
$this->doRegister($converter, true, $targetName, $sourceName);
}
/**
* Internal registration logic
*
* @param ConverterInterface|\Closure $converter Converter instance or typed closure
* @param bool $allowReplace Whether to allow replacing existing converters
* @param ?string $targetName Optional explicit target name (bypasses return type validation for closures)
* @param ?string $sourceName Optional explicit source name (for named source types like 'sql-value')
* @throws \InvalidArgumentException If converter conflicts with existing registration
*/
private function doRegister(ConverterInterface|\Closure $converter, bool $allowReplace, ?string $targetName = null, ?string $sourceName = null): void
{
if ($converter instanceof \Closure) {
$converter = new ClosureConverter($converter, $targetName, $sourceName);
}
$targetType = $converter->getOutputType();
[$inputKey, $inputTypes] = $this->normalizeInputTypes($converter->getInputType());
if (!isset($this->converters[$targetType])) {
$this->converters[$targetType] = [];
}
$byInput = &$this->converters[$targetType];
// Single-type converter
if (count($inputTypes) === 1) {
$single = $inputTypes[0];
// Exact key (single or union key) already registered
if (isset($byInput[$single]) && $byInput[$single] instanceof ConverterInterface) {
if (!$allowReplace) {
// direct converter already exists for this type → conflict
throw new \InvalidArgumentException(
sprintf('Converter already exists from %s to %s', $single, $targetType)
);
}
// replace mode: just overwrite
}
// If this type currently aliases to a union, we are more specific → override allowed
// (We deliberately ignore/overwrite string alias here.)
$byInput[$single] = $converter;
return;
}
// Union converter
// Prevent exact same union key duplicate
if (isset($byInput[$inputKey]) && $byInput[$inputKey] instanceof ConverterInterface) {
if (!$allowReplace) {
throw new \InvalidArgumentException(
sprintf('Converter already exists from %s to %s', $inputKey, $targetType)
);
}
// replace mode: just overwrite below
}
// For each member type, ensure no conflicting converter/alias exists.
foreach ($inputTypes as $single) {
if (!isset($byInput[$single])) {
continue;
}
$existing = $byInput[$single];
// If there's already a direct converter for this single type,
// union is less specific → conflict (even in replace mode).
if ($existing instanceof ConverterInterface && !$allowReplace) {
throw new \InvalidArgumentException(
sprintf(
'Cannot register union converter %s→%s: single-type converter for %s already exists',
$inputKey,
$targetType,
$single
)
);
}
// If there is already an alias for this single type, it points to another union.
// Two overlapping unions for the same single type → conflict (even in replace mode).
if (is_string($existing) && !$allowReplace) {
throw new \InvalidArgumentException(
sprintf(
'Cannot register union converter %s→%s: %s already part of union %s',
$inputKey,
$targetType,
$single,
$existing
)
);
}
}
// No conflicts (or replace mode): register union converter and alias each member type to it
$byInput[$inputKey] = $converter;
foreach ($inputTypes as $single) {
$byInput[$single] = $inputKey;
}
}
/**
* Check if a converter exists for input to target type
*
* @param mixed $input The value to convert
* @param class-string $targetType The desired output type
* @param ?string $sourceType Optional named source type instead of inferring from $input
* @return bool
*/
public function has(mixed $input, string $targetType, ?string $sourceType = null): bool
{
return $this->findConverter($input, $targetType, $sourceType) !== null;
}
/**
* Get the converter for input to target type
*
* @param mixed $input The value to convert
* @param class-string $targetType The desired output type
* @param ?string $sourceType Optional named source type instead of inferring from $input
* @return ConverterInterface|null
*/
public function get(mixed $input, string $targetType, ?string $sourceType = null): ?ConverterInterface
{
return $this->findConverter($input, $targetType, $sourceType);
}
/**
* Convert a value to target type
*
* @template O
* @param mixed $input The value to convert
* @param class-string<O> $targetType The desired output type
* @param ?string $sourceType Optional named source type instead of inferring from $input
* @return O The converted value
* @throws \RuntimeException If no converter is registered for this input→target combination
*/
public function convert(mixed $input, string $targetType, ?string $sourceType = null): mixed
{
$found = false;
$result = $this->tryConvert($input, $targetType, $sourceType, $found);
if ($found) {
return $result;
}
throw new \RuntimeException(sprintf(
'No converter registered for %s → %s',
$sourceType ?? get_debug_type($input),
$targetType
));
}
/**
* Try to convert a value, returning null if no converter handles it
*
* Unlike convert(), this method doesn't throw - it returns null and sets
* $found to false if no converter (including fallbacks) can handle the conversion.
*
* @template O
* @param mixed $input The value to convert
* @param class-string<O> $targetType The desired output type
* @param ?string $sourceType Optional named source type instead of inferring from $input
* @param bool $found Set to true if conversion succeeded, false otherwise
* @return O|null The converted value, or null if no converter found
*/
public function tryConvert(mixed $input, string $targetType, ?string $sourceType = null, bool &$found = false): mixed
{
$converter = $this->findConverter($input, $targetType, $sourceType);
if ($converter !== null) {
$found = true;
return $converter->convert($input, $targetType);
}
// Try fallback handler
$result = $this->fallback->trigger($input, $targetType, $sourceType);
if ($result !== null) {
$found = true;
return $result;
}
$found = false;
return null;
}
/**
* Find most specific converter for input to target type
*
* @param mixed $input
* @param class-string $targetType
* @param ?string $sourceType Optional named source type instead of inferring from $input
* @return ConverterInterface|null
*/
private function findConverter(mixed $input, string $targetType, ?string $sourceType = null): ?ConverterInterface
{
if (!isset($this->converters[$targetType])) {
return null;
}
// Named source type takes precedence
if ($sourceType !== null) {
return $this->lookupConverterForType($sourceType, $targetType, $input);
}
// Scalars: only one "type"
if (!is_object($input)) {
$inputType = get_debug_type($input);
return $this->lookupConverterForType($inputType, $targetType, $input);
}
// Objects: walk class + interfaces + parents in specificity order
foreach ($this->walkInputTypes($input) as $inputType) {
$converter = $this->lookupConverterForType($inputType, $targetType, $input);
if ($converter !== null) {
return $converter;
}
}
return null;
}
/**
* Look up a converter for specific input type to target type
*
* @param string $inputType The type to look up
* @param string $targetType The target type
* @param mixed $input The actual input value (for supports() check)
* @return ConverterInterface|null
*/
private function lookupConverterForType(string $inputType, string $targetType, mixed $input): ?ConverterInterface
{
if (!isset($this->converters[$targetType][$inputType])) {
return null;
}
$entry = $this->converters[$targetType][$inputType];
// Direct converter
if ($entry instanceof ConverterInterface) {
return $entry->supports($input, $targetType) ? $entry : null;
}
// Alias → resolve union key
if (is_string($entry) && isset($this->converters[$targetType][$entry])) {
$conv = $this->converters[$targetType][$entry];
if ($conv instanceof ConverterInterface && $conv->supports($input, $targetType)) {
return $conv;
}
}
return null;
}
/**
* Normalize input type string into a canonical key and list of member types.
*
* Examples:
* "string" → ["string", ["string"]]
* "int|string" → ["int|string", ["int", "string"]]
* " B | A | A " → ["A|B", ["A", "B"]]
*
* @param string $inputTypeString
* @return array{0: string, 1: list<string>}
*/
private function normalizeInputTypes(string $inputTypeString): array
{
// Expecting something like "A" or "A|B|C" from our own converters
$parts = explode('|', $inputTypeString);
$parts = array_values(array_unique($parts));
if ($parts === []) {
throw new \InvalidArgumentException('Converter input type cannot be empty');
}
if (count($parts) === 1) {
// Single type: key == type
return [$parts[0], $parts];
}
sort($parts, SORT_STRING);
$key = implode('|', $parts);
return [$key, $parts];
}
/**
* Walk the type hierarchy for an object
*
* Yields types in specificity order: class, its direct interfaces, parent class, parent's direct interfaces, etc.
*
* @param object $input Must be an object (not scalar)
* @return \Generator<string>
*/
private function walkInputTypes(object $input): \Generator
{
$rc = new \ReflectionClass($input);
while ($rc !== false) {
// Yield the class itself
yield $rc->getName();
// Yield direct interfaces (not inherited from parent)
$parent = $rc->getParentClass();
foreach ($rc->getInterfaceNames() as $interfaceName) {
// Skip if parent already implements this interface
if ($parent === false || !$parent->implementsInterface($interfaceName)) {
yield $interfaceName;
}
}
// Move to parent
$rc = $parent;
}
}
}