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
@templateannotations (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)