ClosureConverter.php
PHP
Path: src/Converter/ClosureConverter.php
<?php
namespace mini\Converter;
/**
* Converter that wraps a typed closure
*
* Uses reflection to extract input/output types from closure signature.
* Supports union input types but not union output types or nullable types.
*/
class ClosureConverter implements ConverterInterface
{
private \ReflectionFunction $reflection;
private string $inputTypeString;
private string $outputType;
/** @var list<string> */
private array $inputTypes;
private bool $hasNamedSource = false;
/**
* @param \Closure $closure Must have exactly one typed parameter and typed return
* @param ?string $targetName Optional explicit target type (bypasses return type validation)
* @param ?string $sourceName Optional explicit source type (bypasses parameter type for lookup)
* @throws \InvalidArgumentException If closure signature is invalid
*/
public function __construct(private \Closure $closure, ?string $targetName = null, ?string $sourceName = null)
{
$this->reflection = new \ReflectionFunction($closure);
// Validate closure has exactly one parameter with type
$params = $this->reflection->getParameters();
if (count($params) !== 1) {
throw new \InvalidArgumentException(
'Converter closure must have exactly one parameter'
);
}
// Get and validate input type
$inputType = $params[0]->getType();
if (!$inputType) {
throw new \InvalidArgumentException(
'Converter closure parameter must have a type declaration'
);
}
// Reject nullable input types
if ($inputType->allowsNull()) {
throw new \InvalidArgumentException(
'Converter closure parameter cannot accept null'
);
}
// If sourceName is specified, use it directly (for named source types like 'sql-value')
if ($sourceName !== null) {
$this->inputTypeString = $sourceName;
$this->inputTypes = [$sourceName];
$this->hasNamedSource = true;
} else {
// Parse input type (handles union types)
$this->inputTypes = array_map('trim', explode('|', $inputType->__toString()));
// Normalize union string (sort alphabetically for canonical form)
// So "string|array" and "array|string" are treated as the same
sort($this->inputTypes);
$this->inputTypeString = implode('|', $this->inputTypes);
}
// Get and validate return type
// If targetName is specified, use it directly (bypasses return type validation)
if ($targetName !== null) {
$this->outputType = $targetName;
} else {
$outputType = $this->reflection->getReturnType();
if (!$outputType) {
throw new \InvalidArgumentException(
'Converter closure must have a return type declaration'
);
}
// Reject nullable return types - converters should always produce a value
if ($outputType->allowsNull()) {
throw new \InvalidArgumentException(
'Converter closure cannot return null'
);
}
$this->outputType = $outputType->__toString();
// Reject union output types
if (str_contains($this->outputType, '|')) {
throw new \InvalidArgumentException(
'Converter closure cannot have union return type'
);
}
}
}
public function getInputType(): string
{
return $this->inputTypeString;
}
public function getOutputType(): string
{
return $this->outputType;
}
public function supports(mixed $input, string $targetType): bool
{
// For named source types, skip value type checking - the caller is responsible
// for ensuring the source type is correct via the $sourceType parameter
if ($this->hasNamedSource) {
return $this->outputType === $targetType
|| is_subclass_of($this->outputType, $targetType);
}
// Check if input matches any of the accepted input types
foreach ($this->inputTypes as $acceptedType) {
if ($this->valueMatchesType($input, $acceptedType)) {
// Check if output type matches or is subclass of target
return $this->outputType === $targetType
|| is_subclass_of($this->outputType, $targetType);
}
}
return false;
}
public function convert(mixed $input, string $targetType): mixed
{
return ($this->closure)($input);
}
/**
* Check if a value matches a type name
*/
private function valueMatchesType(mixed $value, string $typeName): bool
{
// Handle built-in types
if (in_array($typeName, ['string', 'int', 'float', 'bool', 'array', 'object', 'resource', 'callable'])) {
$actualType = get_debug_type($value);
// Special handling for integers/floats
if ($typeName === 'int' && $actualType === 'int') return true;
if ($typeName === 'float' && in_array($actualType, ['float', 'double'])) return true;
return $actualType === $typeName;
}
// Handle 'mixed' type
if ($typeName === 'mixed') {
return true;
}
// Handle classes/interfaces
if (is_object($value)) {
return is_a($value, $typeName);
}
return false;
}
}