跳至正文

在映射过程中转换输入

该库可以自动将大多数输入映射到预期类型,但有时这还不够,必须将自定义逻辑应用于数据。

这就是映射器转换器的用武之地:它们允许用户挂钩映射过程并将自定义逻辑应用于输入,方法是定义一个可调用签名来正确描述何时应该调用它:

  • 第一个参数的类型与映射的预期输入相匹配
  • 表示目标映射类型的返回类型

这两种类型足以让库知道何时调用转换器,并且可以包含针对更具体用例的高级类型注释。

下面是将字符串输入转换为大写的转换器的基本示例:

(new \CuyZ\Valinor\MapperBuilder())
    ->registerConverter(
        fn (string $value): string => strtoupper($value)
    )
    ->mapper()
    ->map('string', 'hello world'); // 'HELLO WORLD'

串联转换器

转换器可以链接起来,允许对一个值应用多个转换。可以声明第二个callable参数,允许当前转换器调用链中的下一个。

可以给转换器赋予优先级以控制转换器的应用顺序。优先级越高,转换器越早执行。默认优先级为 0。

(new \CuyZ\Valinor\MapperBuilder())
    ->registerConverter(
        fn (string $value, callable $next): string => $next(strtoupper($value))
    )
    ->registerConverter(
        fn (string $value, callable $next): string => $next($value . '!'),
        priority: -10,
    )
    ->registerConverter(
        fn (string $value, callable $next): string => $next($value . '?'),
        priority: 10,
    )
    ->mapper()
    ->map('string', 'hello world'); // 'HELLO WORLD?!'

特性转换器

可调用转换器允许在映射期间定位任何值,而属性转换器允许定位特定类或属性以进行更精细的控制。

为了被映射器检测到,必须首先通过添加AsConverter特性来注册特性类。

特性必须声明一个名为map方法,该方法遵循与可调用转换器相同的规则:强制的第一个参数和可选的第二个callable参数。

下面是一个特性转换器的示例,它根据特定的字符串输入将字符串输入转换为布尔值:

namespace My\App;

#[\CuyZ\Valinor\Mapper\AsConverter]
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final class CastToBool
{
    /**
     * @param callable(mixed): bool $next
     */
    public function map(string $value, callable $next): bool
    {
        $value = match ($value) {
            'yes', 'on' => true,
            'no', 'off' => false,
            default => $value,
        };

        return $next($value);
    }
}

final readonly class User
{
    public string $name;

    #[\My\App\CastToBool]
    public bool $isActive;
}

$user = (new \CuyZ\Valinor\MapperBuilder())
    ->mapper()
    ->map(User::class, [
        'name' => 'John Doe',
        'isActive' => 'yes',
    ]);

$user->name === 'John Doe';
$user->isActive === true;

映射参数时,特性转换器也可以用在函数参数上:

function someFunction(string $name, #[\My\App\CastToBool] bool $isActive) {
    // …
};

$arguments = (new \CuyZ\Valinor\MapperBuilder())
    ->argumentsMapper()
    ->mapArguments(someFunction(...), [
        'name' => 'John Doe',
        'isActive' => 'yes',
    ]);

$arguments['name'] === 'John Doe';
$arguments['isActive'] === true;

当无法控制转换器特性类时,可以使用registerConverter方法来注册它。

(new \CuyZ\Valinor\MapperBuilder())
    ->registerConverter(\Some\External\ConverterAttribute::class)
    ->mapper()
    ->map(…);

还可以通过向注册方法提供接口名称来注册共享公共接口的特性。

namespace My\App;

interface SomeAttributeInterface {}

#[\Attribute]
final class SomeAttribute implements \My\App\SomeAttributeInterface {}

#[\Attribute]
final class SomeOtherAttribute implements \My\App\SomeAttributeInterface {}

(new \CuyZ\Valinor\MapperBuilder())
    // Registers both `SomeAttribute` and `SomeOtherAttribute` attributes
    ->registerConverter(\My\App\SomeAttributeInterface::class)
    ->mapper()
    ->map(…);

转换源键

当输入数据使用与 PHP 代码库不同的键名称时,可以使用键转换器告诉映射器如何将源键与对象属性或指定结构的数组元素进行匹配。

与值转换器不同,键转换器本身不转换输入数据,它们仅重新映射键。

注意

错误消息将引用原始源键名称,以便最终用户看到实际发送的键。

$mapper = (new \CuyZ\Valinor\MapperBuilder())
    ->registerKeyConverter(static function (string $key): string {
        // Strips the `billing_` prefix from source keys
        if (str_starts_with($key, 'billing_')) {
            return substr($key, 8);
        }

        return $key;
    })
    ->mapper();

final readonly class BillingAddress
{
    public function __construct(
        public string $street,
        public string $city,
        public string $country,
    ) {}
}

$source = [
    'billing_street' => '221B Baker Street',
    'billing_city' => 'London',
    'billing_country' => 'UK',
];

// Works with classes
$mapper->map(BillingAddress::class, $source);

// Also works with shaped arrays
$mapper->map('array{street: string, city: string, country: string}', $source);

串联键转换器

可以注册多个键转换器并作为管道应用;每一个都按照注册顺序转换前一个的结果:

(new \CuyZ\Valinor\MapperBuilder())
    ->registerKeyConverter(static function (string $key): string {
        // Strip the "billing_" prefix
        if (str_starts_with($key, 'billing_')) {
            return substr($key, 8);
        }

        return $key;
    })
    ->registerKeyConverter(
        // Replace hyphens with underscores
        static fn (string $key): string => str_replace('-', '_', $key),
    )
    ->mapper()
    ->map('array{zip_code: string, country_name: string}', [
        'billing_zip-code' => '62701',
        'billing_country-name' => 'United Kingdom',
    ]);

从特定键映射单个属性

当只有一个属性需要来自不同的键时, #[MapFromKey]属性可以直接放置在该属性上,或者放置在构造函数/方法参数上,而不是注册全局键转换器。请参阅特定关键配置器章节中的映射属性。

自定义键映射特性

#[MapFromKey]建立在轻量级属性协议之上:任何声明mapKey(string $key): string方法并携带#[AsConverter]属性的属性类都可以重新映射其所在元素的键。该方法接收属性名称并返回要读取的源键。

这可以方便地分解出重复的转换,例如由多个属性共享的前缀:

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_PARAMETER)]
#[\CuyZ\Valinor\Mapper\AsConverter]
final class MapWithPrefix
{
    public function __construct(private string $prefix) {}

    public function mapKey(string $key): string
    {
        return $this->prefix . $key;
    }
}

final readonly class Configuration
{
    public function __construct(
        #[MapWithPrefix('app_')] // reads from `app_host`
        public string $host,
        #[MapWithPrefix('app_')] // reads from `app_port`
        public int $port,
    ) {}
}

与全局键转换器一样,同一属性上的多个mapKey属性按声明顺序应用为管道。

转换器错误处理

当值转换器或键转换器抛出异常时,只有遵循验证和错误处理部分中定义的规则,映射器才会正确处理它。

值转换器错误

namespace My\App;

final class InvalidPriceException extends \RuntimeException { }

#[\CuyZ\Valinor\Mapper\AsConverter]
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final class PriceInCents
{
    public function map(string $value): int
    {
        if (preg_match('/^\d+(\.\d{2})?$/', $value) !== 1) {
            throw new \My\App\InvalidPriceException(
                "Invalid price format `$value`"
            );
        }

        return (int)round((float)$value * 100);
    }
}

final readonly class Product
{
    public string $name;

    #[\My\App\PriceInCents]
    public int $priceInCents;
}

(new \CuyZ\Valinor\MapperBuilder())
    ->filterExceptions(function (\Throwable $error) {
        if ($error instanceof \My\App\InvalidPriceException) {
            return \CuyZ\Valinor\Mapper\Tree\Message\MessageBuilder::from($error);
        }

        throw $error;
    })
    ->mapper()
    ->map(\My\App\Product::class, [
        'name' => 'Widget',
        'priceInCents' => 'not-a-price',
    ]);

// Invalid price format `not-a-price`

键转换器错误

键转换器遵循相同的错误处理规则。当键转换器抛出异常时,系统会根据原始源键捕获并报告该异常。

namespace My\App;

final class ForbiddenKeyException extends \RuntimeException { }

(new \CuyZ\Valinor\MapperBuilder())
    ->registerKeyConverter(static function (string $key): string {
        if (str_starts_with($key, '__')) {
            throw new \My\App\ForbiddenKeyException(
                "Key `$key` is not allowed"
            );
        }

        return strtolower($key);
    })
    ->filterExceptions(function (\Throwable $error) {
        if ($error instanceof \My\App\ForbiddenKeyException) {
            return \CuyZ\Valinor\Mapper\Tree\Message\MessageBuilder::from($error);
        }

        throw $error;
    })
    ->mapper()
    ->map('array{name: string}', [
        '__forbidden' => 'hello',
    ]);

// Key `__forbidden` is not allowed