zero-to-prod/data-model

Transform data into a class.

v81.4.0 2024-10-21 20:56 UTC

This package is auto-updated.

Last update: 2024-10-23 19:38:56 UTC


README

Repo GitHub Actions Workflow Status Packagist Downloads Packagist Version License

Simplify deserialization for your DTOs.

Use PHP Attributes to resolve and map values to properties on a class.

Transform data into hydrated objects by describing how to resolve values.

Features

Installation

You can install the package via Composer:

composer require zerotoprod/data-model

Examples

Additional Packages

Usage

Use the DataModel trait in a class.

class User
{
    use \Zerotoprod\DataModel\DataModel;

    public string $name;
    public int $age;
}

Hydrating from Data

Use the from method to instantiate your class, passing an associative array or object.

$user = User::from([
    'name' => 'John Doe',
    'age' => '30',
]);
echo $user->name; // 'John Doe'
echo $user->age; // 30

Recursive Hydration

The DataModel trait recursively instantiates classes based on their type declarations. If a property’s type hint is a class, its value is passed to that class’s from() method.

In this example, the address element is automatically converted into an Address object, allowing direct access to its properties: $user->address->city.

class Address
{
    use \Zerotoprod\DataModel\DataModel;

    public string $street;
    public string $city;
}

class User
{
    use \Zerotoprod\DataModel\DataModel;

    public string $username;
    public Address $address;
}

$user = User::from([
    'username' => 'John Doe',
    'address' => [
        'street' => '123 Main St',
        'city' => 'Hometown',
    ],
]);

echo $user->address->city; // Outputs: Hometown

Transformations

The DataModel trait provides a variety of ways to transform data before the value is assigned to a property.

The Describe attribute provides a declarative way describe how property values are resolved.

Describe Attribute

Resolve a value by adding the Describe attribute to a property.

The Describe attribute can accept these arguments.

#[\Zerotoprod\DataModel\Describe([
    // Re-map a key to a property of a different name
    'from' => 'key', 
    // Runs before 'cast'
    'pre' => [MyClass::class, 'preHook']
    // Targets the static method: `MyClass::methodName()`
    'cast' => [MyClass::class, 'castMethod'], 
    // 'cast' => 'my_func', // alternately target a function
    // Runs after 'cast' passing the resolved value as `$value`
    'post' => [MyClass::class, 'postHook']
    'required' => true,
    'default' => 'value',
    'missing_as_null' => true,
])]

Order of Precedence

There is an order of precedence when resolving a value for a property.

  1. Property-level Cast
  2. Method-level Cast
  3. Union Types
  4. Class-level Casts
  5. Types that have a concrete static method from().
  6. Native Types

Property-Level Cast

The using the Describe attribute directly on the property takes the highest precedence.

use Zerotoprod\DataModel\Describe;

class User
{
    use \Zerotoprod\DataModel\DataModel;

    #[Describe(['cast' => [self::class, 'firstName'], 'function' => 'strtoupper'])]
    public string $first_name;
    
    #[Describe(['cast' => 'uppercase'])]
    public string $last_name;

    #[Describe(['cast' => [self::class, 'fullName']])]
    public string $full_name;

    private static function firstName(mixed $value, array $context, ?\ReflectionAttribute $ReflectionAttribute, \ReflectionProperty $ReflectionProperty): string
    {
        return $ReflectionAttribute->getArguments()[0]['function']($value);
    }

    public static function fullName(mixed $value, array $context, ?\ReflectionAttribute $Attribute, \ReflectionProperty $Property): string
    {
        return "{$context['first_name']} {$context['last_name']}";
    }
}

function uppercase(mixed $value, array $context){
    return strtoupper($value);
}

$user = User::from([
    'first_name' => 'Jane',
    'last_name' => 'Doe',
]);

$user->first_name;  // 'JANE'
$user->last_name;   // 'DOE'
$user->full_name;   // 'Jane Doe'

Life-Cycle Hooks

You can run methods before and after a value is resolved.

pre Hook

You can use pre to run a void method before the value is resolved.

use Zerotoprod\DataModel\Describe;

class BaseClass
{
    use \Zerotoprod\DataModel\DataModel;

    #[Describe(['pre' => [self::class, 'pre'], 'message' => 'Value too large.'])]
    public int $int;

    public static function pre(mixed $value, array $context, ?\ReflectionAttribute $Attribute, \ReflectionProperty $Property): void
    {
        if ($value > 10) {
            throw new \RuntimeException($Attribute->getArguments()[0]['message']);
        }
    }
}

post Hook

You can use post to run a void method after the value is resolved.

use Zerotoprod\DataModel\Describe;

class BaseClass
{
    use \Zerotoprod\DataModel\DataModel;

    public const int = 'int';

    #[Describe(['post' => [self::class, 'post'], 'message' => 'Value too large.'])]
    public int $int;

    public static function post(mixed $value, array $context, ?\ReflectionAttribute $Attribute, \ReflectionProperty $Property): void
    {
        if ($value > 10) {
            throw new \RuntimeException($value.$Attribute->getArguments()[0]['message']);
        }
    }
}

Method-level Cast

Use the Describe attribute to resolve values with class methods. Methods receive $value and $context as parameters.

use Zerotoprod\DataModel\Describe;

class User
{
    use \Zerotoprod\DataModel\DataModel;

    public string $first_name;
    public string $last_name;
    public string $fullName;

    #[Describe('last_name')]
    public function lastName(mixed $value, array $context, ?\ReflectionAttribute $Attribute, \ReflectionProperty $Property): string
    {
        return strtoupper($value);
    }

    #[Describe('fullName')]
    public function fullName(mixed $value, array $context, ?\ReflectionAttribute $Attribute, \ReflectionProperty $Property): string
    {
        return "{$context['first_name']} {$context['last_name']}";
    }
}

$user = User::from([
    'first_name' => 'Jane',
    'last_name' => 'Doe',
]);

$user->first_name;  // 'Jane'
$user->last_name;   // 'DOE'
$user->fullName;    // 'Jane Doe'

Union Types

A value passed to property with a union type is directly assigned to the property. If you wish to resolve the value in a specific way, use a class method.

Class-Level Cast

You can define how to resolve different types at the class level.

use Zerotoprod\DataModel\Describe;

function uppercase(mixed $value, array $context){
    return strtoupper($value);
}

#[Describe([
    'cast' => [
        'string' => 'uppercase',
        \DateTimeImmutable::class => [self::class, 'toDateTimeImmutable'],
    ]
])]
class User
{
    use \Zerotoprod\DataModel\DataModel;

    public string $first_name;
    public DateTimeImmutable $registered;

    public static function toDateTimeImmutable(mixed $value, array $context): DateTimeImmutable
    {
        return new DateTimeImmutable($value);
    }
}

$user = User::from([
    'first_name' => 'Jane',
    'registered' => '2015-10-04 17:24:43.000000',
]);

$user->first_name;              // 'JANE'
$user->registered->format('l'); // 'Sunday'

Required Properties

Enforce that certain properties are required using the Describe attribute:

use Zerotoprod\DataModel\Describe;

class User
{
    use \Zerotoprod\DataModel\DataModel;

    #[Describe(['required' => true])]
    public string $username;

    public string $email;
}

$user = User::from(['email' => 'john@example.com']);
// Throws PropertyRequiredException exception: Property: username is required

Default Values

You can set a default value for a property like this:

use Zerotoprod\DataModel\Describe;

class User
{
    use \Zerotoprod\DataModel\DataModel;

    #[Describe(['default' => 'N/A'])]
    public string $username;
}

$user = User::from();

echo $user->username // 'N/A'

Nullable Missing Values

Set missing values to null by setting missing_as_null => true. This can be placed at the class or property level.

This prevents an Error when attempting to assess a property that has not been initialized.

Error: Typed property User::$age must not be accessed before initialization

use Zerotoprod\DataModel\Describe;

#[Describe(['missing_as_null' => true])]
class User
{
    use \Zerotoprod\DataModel\DataModel;

    public ?string $name;
    
    #[Describe(['missing_as_null' => true])]
    public ?int $age;
}

$User = User::from();

echo $User->name; // null
echo $User->age;  // null

Re-Mapping

You can map a key to a property of a different name like this:

use Zerotoprod\DataModel\Describe;

class User
{
    use \Zerotoprod\DataModel\DataModel;

    #[Describe(['from' => 'firstName'])]
    public string $first_name;
}

$User = User::from([
    'firstName' => 'John',
]);

echo $User->first_name; // John

Examples

Array of DataModels

This examples uses the DataModelHelper.

composer require zero-to-prod/data-model-helper
use Zerotoprod\DataModel\Describe;

class User
{
    use \Zerotoprod\DataModel\DataModel;
    use \Zerotoprod\DataModelHelper\DataModelHelper;
    
    /** @var Alias[] $Aliases */
    #[Describe([
        'cast' => [self::class, 'mapOf'],   // Use the mapOf helper method
        'type' => Alias::class,             // Target type for each item
    ])]
    public array $Aliases;
}

class Alias
{
    use \Zerotoprod\DataModel\DataModel;
    
    public string $name;
}

$User = User::from([
    'Aliases' => [
        ['name' => 'John Doe'],
        ['name' => 'John Smith'],
    ]
]);

echo $User->Aliases[0]->name; // Outputs: John Doe
echo $User->Aliases[1]->name; // Outputs: John Smith

Collection of DataModels

This examples uses the DataModelHelper and Laravel Collections.

composer require zero-to-prod/data-model-helper
composer require illuminate/collections
use Zerotoprod\DataModel\Describe;

class User
{
    use \Zerotoprod\DataModel\DataModel;
    use \Zerotoprod\DataModelHelper\DataModelHelper;
    
    /** @var Collection<int, Alias> $Aliases */
    #[Describe([
        'cast' => [self::class, 'mapOf'],
        'type' => Alias::class,
    ])]
    public \Illuminate\Support\Collection $Aliases;
}

class Alias
{
    use \Zerotoprod\DataModel\DataModel;
    
    public string $name;
}

$User = User::from([
    'Aliases' => [
        ['name' => 'John Doe'],
        ['name' => 'John Smith'],
    ]
]);

echo $User->Aliases->first()->name; // Outputs: John Doe

Laravel Validation

By leveraging the pre life-cycle hook, you run a validator before a value is resolved.

use Illuminate\Support\Facades\Validator;
use Zerotoprod\DataModel\Describe;

readonly class FullName
{
    use \Zerotoprod\DataModel\DataModel;

    #[Describe([
        'pre' => [self::class, 'validate'],
        'rule' => 'min:2'
    ])]
    public string $first_name;

    public static function validate(mixed $value, array $context, ?\ReflectionAttribute $Attribute): void
    {
        $validator = Validator::make(['value' => $value], ['value' => $Attribute?->getArguments()[0]['rule']]);
        if ($validator->fails()) {
            throw new \RuntimeException($validator->errors()->toJson());
        }
    }
}

Testing

./vendor/bin/phpunit