github CuyZ/Valinor 2.6.0

4 hours ago

Notable changes

This release brings a set of new features to the library:

  • Provided mapper configurators
  • Scalar value casting
  • Mapping a property from a specific key
  • New normalizer configurators
  • Generics of PHP internal classes
  • Default types for templates
  • Overriding an unparseable type

Enjoy! 🎉


Provided mapper configurators

A set of configurators is now available out-of-the-box for the mapper, mirroring the normalizer configurators introduced in the previous release. Each one can be used either globally through the configureWith() method or locally as an attribute targeting a specific property.

The MapToDateTimeFromFormat configurator parses the input string using the given date format, which must follow the syntax supported by DateTimeImmutable::createFromFormat():

use CuyZ\Valinor\Mapper\Configurator\MapToDateTimeFromFormat;
use CuyZ\Valinor\MapperBuilder;
use DateTimeInterface;

final readonly class Event
{
    public function __construct(
        public string $name,

        #[MapToDateTimeFromFormat('d/m/Y')]
        public DateTimeInterface $date,
    ) {}
}

$event = (new MapperBuilder())
    ->mapper()
    ->map(Event::class, [
        'name' => 'Release of legendary album',
        'date' => '08/11/1971', // mapped to a `DateTimeImmutable`
    ]);

The MapExplodedStringToList configurator explodes a string into a list using the given separator, which is useful when the input carries a list as a single delimited string, for instance a value coming from a CSV file or a query parameter:

use CuyZ\Valinor\Mapper\Configurator\MapExplodedStringToList;
use CuyZ\Valinor\MapperBuilder;

final readonly class Product
{
    public function __construct(
        public string $name,

        /** @var list<string> */
        #[MapExplodedStringToList(separator: ',')]
        public array $sizes,
    ) {}
}

$product = (new MapperBuilder())
    ->mapper()
    ->map(Product::class, [
        'name' => 'T-Shirt',
        'sizes' => 'XS,S,M,L,XL', // mapped to `['XS', 'S', 'M', 'L', 'XL']`
    ]);

The MapArrayToList configurator discards the keys of an array and maps its values to a list, for cases where the input is an associative array, or a sparse list with missing or out-of-order indices, that should be handled as a sequential list:

use CuyZ\Valinor\Mapper\Configurator\MapArrayToList;
use CuyZ\Valinor\MapperBuilder;

final readonly class Basket
{
    public function __construct(
        /** @var list<string> */
        #[MapArrayToList]
        public array $products,
    ) {}
}

$basket = (new MapperBuilder())
    ->mapper()
    ->map(Basket::class, [
        'a' => 'Coffee',
        'b' => 'Tea',
    ]); // mapped to `['Coffee', 'Tea']`

Finally, the MapFromJson configurator decodes a JSON string and hands the result over to the mapper, so that the usual validation and error reporting still apply to the decoded value:

use CuyZ\Valinor\Mapper\Configurator\MapFromJson;
use CuyZ\Valinor\MapperBuilder;

final readonly class User
{
    public function __construct(
        public string $name,

        /** @var list<string> */
        #[MapFromJson]
        public array $roles,
    ) {}
}

$user = (new MapperBuilder())
    ->mapper()
    ->map(User::class, [
        'name' => 'John Doe',
        'roles' => '["admin", "editor"]', // mapped to `['admin', 'editor']`
    ]);

Scalar value casting

Four configurators convert a scalar value to a specific type before mapping: MapAsBool, MapAsInt, MapAsFloat and MapAsString. They are useful when the input data carries values in a different representation than the targeted type, for instance numbers or booleans encoded as strings in a form submission, a CSV file or a JSON payload.

Used as an attribute, a single property is cast, leaving the strictness rules untouched for every other value:

use CuyZ\Valinor\Mapper\Configurator\MapAsBool;
use CuyZ\Valinor\Mapper\Configurator\MapAsInt;
use CuyZ\Valinor\MapperBuilder;

final readonly class User
{
    public function __construct(
        public string $name,

        #[MapAsInt]
        public int $age,

        #[MapAsBool(true: ['on', 'yes'], false: ['off', 'no'])]
        public bool $isActive,
    ) {}
}

$user = (new MapperBuilder())
    ->mapper()
    ->map(User::class, [
        'name' => 'John Doe',
        'age' => '42', // mapped to `42`
        'isActive' => 'on', // mapped to `true`
    ]);

Casting can also be enabled for every value of a given type with the new allowCastingToBoolean(), allowCastingToInteger(), allowCastingToFloat() and allowCastingToString() methods of the mapper builder. They offer a finer control than allowScalarValueCasting(), which relaxes strictness for all scalar types at once:

use CuyZ\Valinor\MapperBuilder;

$age = (new MapperBuilder())
    ->allowCastingToInteger()
    ->mapper()
    ->map('int', '42'); // mapped to `42`

Mapping a property from a specific key

The new MapFromKey attribute feeds a class property, or a constructor/method argument, from a specific source key instead of matching it against the property name:

use CuyZ\Valinor\Mapper\Configurator\MapFromKey;
use CuyZ\Valinor\MapperBuilder;

final readonly class Person
{
    public function __construct(
        public string $name,

        #[MapFromKey('zipCode')]
        public string $postalCode,
    ) {}
}

$person = (new MapperBuilder())
    ->mapper()
    ->map(Person::class, [
        'name' => 'John Doe',
        'zipCode' => '75001', // mapped to `$postalCode`
    ]);

This attribute is built on a lightweight protocol that is open to userland: any attribute class declaring a mapKey(string $key): string method and carrying the #[AsConverter] attribute can remap the key of the element it is placed on. This is handy to factor out a recurring transformation, such as a prefix shared by several properties:

#[\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,
    ) {}
}

New normalizer configurators

Three configurators join the ones introduced in the previous release.

The NormalizeKeyTo attribute renames the key of a property during normalization, when the name used in the data format differs from the one used in the PHP codebase:

use CuyZ\Valinor\Normalizer\Configurator\NormalizeKeyTo;
use CuyZ\Valinor\Normalizer\Format;
use CuyZ\Valinor\NormalizerBuilder;

final readonly class Address
{
    public function __construct(
        public string $street,

        #[NormalizeKeyTo('town')]
        public string $city,
    ) {}
}

$addressAsArray = (new NormalizerBuilder())
    ->normalizer(Format::array())
    ->normalize(new Address('221B Baker Street', 'London'));

// [
//     'street' => '221B Baker Street',
//     'town' => 'London',
// ]

The NormalizeToSingleValue class flattens an object holding a single property, so that instead of ['someProperty' => 'value'] the normalized result is simply 'value'. It can be used either as a configurator, applying to every object with a single property, or as an attribute targeting a specific class or property:

use CuyZ\Valinor\Normalizer\Configurator\NormalizeToSingleValue;
use CuyZ\Valinor\Normalizer\Format;
use CuyZ\Valinor\NormalizerBuilder;

final readonly class Email
{
    public function __construct(
        public string $email,
    ) {}
}

final readonly class User
{
    public function __construct(
        public string $name,

        #[NormalizeToSingleValue]
        public Email $email,
    ) {}
}

$userAsArray = (new NormalizerBuilder())
    ->normalizer(Format::array())
    ->normalize(new User('John Doe', new Email('john.doe@example.com')));

// [
//     'name' => 'John Doe',
//     'email' => 'john.doe@example.com',
// ]

The IgnoreOnNormalization attribute excludes a property from the normalized output, for instance to hide sensitive data such as a password. For the attribute to take effect, an instance of this class must also be registered on the builder via configureWith():

use CuyZ\Valinor\Normalizer\Configurator\IgnoreOnNormalization;
use CuyZ\Valinor\Normalizer\Format;
use CuyZ\Valinor\NormalizerBuilder;

final readonly class User
{
    public function __construct(
        public string $name,

        #[IgnoreOnNormalization]
        public string $password,
    ) {}
}

$userAsArray = (new NormalizerBuilder())
    ->configureWith(new IgnoreOnNormalization())
    ->normalizer(Format::array())
    ->normalize(new User('John Doe', 's3cr3t'));

// ['name' => 'John Doe']

Generics of PHP internal classes

Generics used to be limited to userland classes, because classes internal to PHP or provided by an extension cannot declare @template annotations in their own source code. The library now ships generic signatures for a wide range of them, including ArrayObject, ArrayIterator, the SPL data structures and the Ds collection classes, so they can be parameterized like any other class:

use CuyZ\Valinor\MapperBuilder;

$sizes = (new MapperBuilder())
    ->mapper()
    ->map('ArrayObject<string, int>', [
        'S' => 36,
        'M' => 38,
        'L' => 40,
    ]);

Every one of these templates declares a default type, so bare references like ArrayObject keep resolving as before.


Default types for templates

A @template annotation can now declare a default type with =. A template that declares a default type may be omitted when the class is referenced, in which case the default type is used:

/**
 * @template TValue
 * @template TMeta of array<string, mixed> = array<string, string>
 */
final readonly class Page
{
    public function __construct(
        /** @var list<TValue> */
        public array $items,

        /** @var TMeta */
        public array $meta,
    ) {}
}

final readonly class SomeClass
{
    public function __construct(
        // `TMeta` is not filled in, its default type is used
        /** @var Page<string> */
        public Page $pageWithDefaultMeta,

        // `TMeta` is filled in, overriding its default type
        /** @var Page<string, array{cursor: int}> */
        public Page $pageWithCursorMeta,
    ) {}
}

A default type is what makes it possible to add a template to a class that is already referenced elsewhere: the existing references, which do not fill the new template in, keep resolving to its default type and can be made more precise later on.


Overriding an unparseable type

When a property, parameter or return type uses a PHPStan or Psalm syntax that the library cannot parse yet, for instance a conditional type like ($a is 1 ? int : null), the dedicated @valinor-var, @valinor-param and @valinor-return annotations can be used to give the library a type it understands. They take precedence over every other annotation, so the static analysis tools keep using their own type while the library uses the override:

final class SomeClass
{
    /**
     * @phpstan-param ($a is 1 ? int : null) $b
     * @valinor-param int|null $b
     */
    public function __construct(
        public readonly int $a,
        public readonly ?int $b,
    ) {}
}

Features

  • Add @valinor-* annotations to override an unparseable type (11938c)
  • Add default value support for @template annotations (0d6efe)
  • Add mapper builder methods to cast to scalar types (cdca3f)
  • Add mapper configurator MapArrayToList (1f81fa)
  • Add mapper configurator MapAsBool (65dfed)
  • Add mapper configurator MapAsFloat (84eea9)
  • Add mapper configurator MapAsInt (ea28a7)
  • Add mapper configurator MapAsString (6b0528)
  • Add mapper configurator MapExplodedStringToList (beb4db)
  • Add mapper configurator MapFromJson (469863)
  • Add mapper configurator MapToDateTimeFromFormat (d6e53b)
  • Add normalizer configurator IgnoreOnNormalization (9769f2)
  • Add normalizer configurator NormalizeKeyTo (947127)
  • Add normalizer configurator NormalizeToSingleValue (7c5f13)
  • Allow mapping source keys with attributes (631f66)
  • Support generics of PHP internal classes (5419b4)

Bug Fixes

  • Bind the templates a constructor declares to the type being mapped (0629d8)

Internal

  • Refactor HTTP request mapping (578bd5)
  • Remove canCast() and cast() from scalar types (eae3f0)
  • Unify shaped array and HTTP request node building (3bb83b)

Don't miss a new Valinor release

NewReleases is sending notifications on new releases.