stratadox / hydration-mapping
Requires
- php: >=7.2
- ext-json: *
- stratadox/deserializer-contracts: ^0.3
- stratadox/hydration-mapping-contracts: ^0.4
- stratadox/hydrator-contracts: ^0.8
- stratadox/proxy-contracts: ^0.3.0
- stratadox/specification-interfaces: ^1.0
Requires (Dev)
- phpstan/phpstan: ^0.12
- phpunit/phpunit: ^7.1
- stratadox/deserializer: ^0.2.1
- stratadox/proxy: ^0.4
- stratadox/specification: ^1.0
Suggests
- stratadox/hydrator: For hydrating the objects
- stratadox/proxy: For lazy loading relationships
- stratadox/specification: For advanced input validation
README
Mappings for hydration purposes.
Maps array or array-like data structures to object properties, in order to assemble the objects that model a business domain.
Installation
Install using composer:
composer require stratadox/hydration-mapping
Purpose
These mapping objects define the relationship between an object property and the source of the data.
Typical Usage
Typically, hydration mappings are given to Mapped
Hydrator
instances.
Together they form a strong team that solves a single purpose: mapping data to an object graph.
For example:
use Stratadox\Hydration\Mapping\Simple\Type\IntegerValue; use Stratadox\Hydration\Mapping\Simple\Type\StringValue; use Stratadox\Hydrator\MappedHydrator; use Stratadox\Hydrator\ObjectHydrator; $hydrator = MappedHydrator::using( ObjectHydrator::default(), StringValue::inProperty('title'), IntegerValue::inProperty('rating'), StringValue::inPropertyWithDifferentKey('isbn', 'id') ); $book = new Book; $hydrator->writeTo($book, [ 'title' => 'This is a book.', 'rating' => 3, 'isbn' => '0000000001' ]);
More often, the mapped hydrator is given to a deserializer
:
use Stratadox\Deserializer\ObjectDeserializer; use Stratadox\Hydration\Mapping\Simple\Type\IntegerValue; use Stratadox\Hydration\Mapping\Simple\Type\StringValue; use Stratadox\Hydrator\MappedHydrator; use Stratadox\Hydrator\ObjectHydrator; use Stratadox\Instantiator\ObjectInstantiator; $deserialize = ObjectDeserializer::using( ObjectInstantiator::forThe(Book::class), MappedHydrator::using( ObjectHydrator::default(), StringValue::inProperty('title'), IntegerValue::inProperty('rating'), StringValue::inPropertyWithDifferentKey('isbn', 'id') ) ); $book = $deserialize->from([ 'title' => 'This is a book.', 'rating' => 3, 'isbn' => '0000000001' ]);
Mapping
Three types of property mappings are available:
- Scalar mappings
- Relationship mappings
- Extension points
Scalar Mapping
Scalar typed properties can be mapped using the *Value
classes.
The following scalar mappings are available:
BooleanValue
FloatValue
IntegerValue
StringValue
NullValue
Scalar mappings are created through the named constructors:
inProperty
- Usage:
IntegerValue::inProperty('amount')
- Use when the property name and data key are the same.
- Usage:
inPropertyWithDifferentKey
- Usage:
BooleanValue::inPropertyWithDifferentKey('isBlocked', 'is_blocked')
- Use when the data key differs from the property name.
- Usage:
Basic Validation
When appropriate, these mappings validate the input before producing a value.
For instance, the IntegerValue
mapping checks that:
- The input value is formatted as an integer number
- The value does not exceed the integer boundaries
This process can be skipped by using the Casted*
mappings instead.
They provide a minor speed bonus at the cost of decreased integrity.
Casted*
mappings are available as:
CastedFloat
CastedInteger
To skip the entire typecasting process, the OriginalValue
mapping can be used.
Input to a BooleanValue
must either be 0, 1 or already boolean typed.
Custom true/false values can be provided as optional parameters:
use Stratadox\Hydration\Mapping\Simple\Type\BooleanValue; $myProperty = BooleanValue::withCustomTruths('foo', ['yes', 'y'], ['no', 'n']);
Nullable- and Mixed values
Each of the above mappings can be made nullable by wrapping the mapping with
CanBeNull
.
For example, instead of IntegerValue::inProperty('foo')
, the foo
property
can be made nullable with: CanBeNull::or(IntegerValue::inProperty('foo'))
.
In the same style, mixed value types can be configured. To map a value that
could be either an int or a float, as numeric PHP values are often found,
CanBeInteger
can be used: CanBeInteger::or(FloatValue::inProperty('foo')))
.
This mapping will first check if the value can safely be transformed into an
integer, and fall back to a floating point value. Non-numeric values will result
in an exception, denoting where and why the input data could not be mapped.
These mixed mapping can be combined (as is customary for decorators) to produce, for instance, mapping configurations that first attempt to map the value to a boolean, otherwise as an integer, if that cannot be done to cast it to a floating point, and if all else fails, make it a string:
use Stratadox\Hydration\Mapping\Simple\Type\CanBeBoolean; use Stratadox\Hydration\Mapping\Simple\Type\CanBeInteger; use Stratadox\Hydration\Mapping\Simple\Type\CanBeFloat; use Stratadox\Hydration\Mapping\Simple\Type\StringValue; $theProperty = CanBeBoolean::orCustom( CanBeInteger::or( CanBeFloat::or( StringValue::inProperty('bar') ) ), ['TRUE'], ['FALSE'] );
Relationship Mapping
Relationships can be mapped with a monogamous HasOne*
or polygamist HasMany*
mapping.
Each of these are connected to the input data in one of three ways:
- As
*Embedded
values (for loading from tabular data) - As
*Nested
data structures (for loading from a json structure) - As
*Proxies
(for loading lazily)
This boils down to the following possibilities:
HasManyNested
HasManyProxies
HasOneEmbedded
HasOneNested
HasOneProxy
Relationship mappings are created through the named constructors:
inProperty
- Usage:
HasOneNested::inProperty('name', $deserializer)
- Use when the property name and data key are the same.
- Usage:
inPropertyWithDifferentKey
- Usage:
HasOneNested::inPropertyWithDifferentKey('friends', 'contacts', $deserializer)
- Use when the data key differs from the property name.
- Usage:
In this context, the term key
refers to the key of the associative array from
which the object data is mapped, also known as offset
, index
or position
.
Nested vs Embedded
For *Embedded
classes, there is no inPropertyWithDifferentKey
. Instead of
relying on an embedded array in the key, they are given the original input array
and compose their attributes from one or more of its values.
Has One
HasOne*
-type relationships are each given an object that Deserializes
the related instance.
A HasOneNested
receives the value that was found in the original input for the
given key
. This value must be an array, presumably associative.
HasOneEmbedded
mappings take a different approach: they produce a new object
from the data in the original input array. This approach is useful when mapping,
for example, embedded values.
Has Many
A HasMany*
relation requires one object that Deserializes
the collection,
and one that Deserializes
the items.
This approach allows for a lot of freedom in the way collections are mapped. The available deserializers can map the collection either as plain array or to a custom collection object.
These deserializers may in turn use mapped hydrator instances. The combination
is able to map entire structures of objects in all kinds and shapes.
Proxies
Proxies
are used to allow for lazy
loading. Rather than deserializers, they take a factory to create objects that,
in turn, load the "real" object in place of the proxy whenever called upon.
Lazy has-one relations can be mapped with the HasOneProxy
mapping.
Lazy has-many relationships have the option to be normally lazy, or extra lazy.
For extra lazy relations, the HasManyProxies
mapping is used. When the
relation is "regular" lazy, it is mapped as HasOneProxy
, where "one" refers to
one collection.
The latter only works when the collection is contained in a collection object.
In cases where objects that are contained in an array should be lazy-loaded, a
HasManyProxies
mapping should be used, where each proxy is configured to load
the entire array when called upon.
Using this mechanism, both lazy and extra-lazy loading is supported through any type of collection, whether it be an array or a collection object.
Bidirectional
Bidirectional one-to-many
and one-to-one
relationships can be mapped using
the HasBackReference
mapping.
This mapping acts as an observer to the hydrator for the owning side, assigning the reference of the "owner" object to the given property.
Advanced validation
Advanced input validation can be applied with a ConstrainedMapping
.
A ConstrainedMapping
will produce the value of the mapping if the
specification is satisfied with
it, or throw an exception otherwise.
For example, a check on whether a rating is between 1 and 5 might look like this:
use Stratadox\Hydration\Mapping\Composite\ConstrainedMapping; use Stratadox\Hydration\Mapping\Simple\Type\IntegerValue; use Your\Constraint\IsNotLess; use Your\Constraint\IsNotMore; ConstrainedMapping::checkThatIt( IsNotLess::than(1)->and(IsNotMore::than(5)), IntegerValue::inProperty('rating') );
The constraints themselves implement the (minimal) interface Satisfiable
,
which mandates only the method isSatisfiedBy($input)
.
The recommended way to implement custom constraints is by extending the abstract
Specification
class:
use Stratadox\Specification\Specification; class IsNotLess extends Specification { private $minimum; private function __construct(int $minimum) { $this->minimum = $minimum; } public static function than(int $minimum): self { return new self($minimum); } public function isSatisfiedBy($number): bool { return $number >= $this->minimum; } }
Or by using the Specifying
trait:
use Stratadox\Specification\Contract\Specifies; use Stratadox\Specification\Specifying; class IsNotMore implements Specifies { use Specifying; private $maximum; private function __construct(int $maximum) { $this->maximum = $maximum; } public static function than(int $maximum): self { return new self($maximum); } public function isSatisfiedBy($number): bool { return $number <= $this->maximum; } }
Default values
To honour the PHP spirit, a class is available that loads a default value rather
than propagating the exception: Defaults::to(-1, IntegerValue::inProperty('foo'))
Extension
The ClosureMapping
provides an easy extension point.
It takes in an anonymous function as constructor parameter.
This function is called with the input data to produce the mapped result.
For additional extension power, custom mapping can be produced by implementing
the Mapping
interface.