Library for mapping and transforming data structures.
Mapper configuration is a description of output structure defined as association:
[Key1 => Getter1, Key2 => Getter2 ...]
Key defines property name in output structure and Getter is a function that extracts value from input.
useDataMap\Getter\GetInteger;
useDataMap\Mapper;
useDataMap\Input\Input;
// Input structure is:$input = [
'name' => 'John',
'surname' => 'Doe',
'date_birth' => '1970-01-01',
'address' => [
'street' => 'Foo Street',
'city' => [
'name' => 'Bar Town',
'country' => 'Neverland',
],
],
'age' => '47',
];
// Required output structore is:$output = [
'firstName' => 'John',
'fullName' => 'John Doe',
'street' => 'Foo Street',
'city' => 'Bar Town',
'age' => 47,
'birth' => new \DateTimeImmutable('1970-01-01'),
];
// Then mapping definition is:$mapper = newMapper([
'firstName' => 'name', // simply get `name` from input and assign to `firstName` property'fullName' => function (Input$input): string {
return$input->get('name') . '' . $input->get('surname');
}, // use Closure as Getter function'street' => 'address.street', // get values from nested structures'city' => 'address.city.name',
'age' => newGetInteger('age'), // use one of predefined getters'birth' => newGetDate('date_birth'), // get date as `\DateTimeImmutable` object
]);
// Map $input to $output:$output = $mapper->map($input);
// Map collection of entries:$outputCollection = array_map($mapper, $inputCollection);
// Extend mapper definition:$newMapper = $mapper->withAddedMap(['country' => 'address.city.country']);Getter generally can be described as interface:
useDataMap\Input\Input;
interface Getter
{
/** * @return mixed */publicfunction__invoke(Input$input);
}There are 2 forms of defining map:
Gettercan be string which is shorthand fornew GetRaw('key').Gettercan also be a closure or any other callable. It will receiveDataMap\Input\Inputas first argument and original input as second argument.Getterinterface is not required, it's just a hint.
Get value by property path without additional transformation.
$mapper = newMapper([
'name' => newGetRaw('first_name'),
// same as: 'name' => 'first_name', ]);Gets value and casts to string (if possible) or returns $default.
$mapper = newMapper([
'name' => newGetString('username', 'anonymous'),
]);Gets value and casts to integer (if possible) or $default.
$mapper = newMapper([
'age' => newGetInteger('user.age', null),
]);Gets value and casts to float (if possible) or $default.
Gets value and casts to boolean (true, false, 0, 1, '0', '1') or $default.
Gets value and transform to \DateTimeImmutable (if possible) or $default.
Gets string value for given keys an join it using $glue.
$mapper = newMapper([
'fullname' => newGetJoinedStrings('', 'user.name', 'user.surname'),
]);Gets collection under given $key and maps it with $callback or return [] if entry cannot be mapped.
$characterMapper = newMapper([
'fullname' => newGetJoinedStrings('', 'name', 'surname'),
] );
$movieMapper = newMapper([
'movie' => 'name',
'characters' => newGetMappedCollection('characters', $characterMapper),
]);
$mapper->map([
'name' => 'Lucky Luke',
'characters' => [
['name' => 'Lucky', 'surname' => 'Luke'],
['name' => 'Joe', 'surname' => 'Dalton'],
['name' => 'William', 'surname' => 'Dalton'],
['name' => 'Jack', 'surname' => 'Dalton'],
['name' => 'Averell', 'surname' => 'Dalton'],
],
]);
// result:
[
'movie' => 'Lucky Luke',
'characters' => [
['fullname' => 'Lucky Luke'],
['fullname' => 'Joe Dalton'],
['fullname' => 'William Dalton'],
['fullname' => 'Jack Dalton'],
['fullname' => 'Averell Dalton'],
],
];Similar to GetMappedCollection but result is flattened.
Gets value and translates it using provided associative array ($map) or $default when translation for value is not available.
$mapper = newMapper([
'agree' => newGetTranslated('agree', ['yes' => true, 'no' => false], false), ]);
$mapper->map(['agree' => 'yes']) === ['agree' => true];
$mapper->map(['agree' => 'no']) === ['agree' => false];
$mapper->map(['agree' => 'maybe']) === ['agree' => false];Gets value and transforms it through filters pipeline.
$mapper = newMapper([
'text' => GetFiltered::from('html')->string()->stripTags()->trim()->ifNull('[empty]'),
'time' => GetFiltered::from('datetime')->dateFormat('H:i:s'),
'date' => GetFiltered::from('time_string')->date(),
'amount' => GetFiltered::from('amount_string')->float()->round(2),
'amount_int' => GetFiltered::from('amount_string')->round()->int()->ifNull(0),
]);Using function as filter:
$greeting = function (string$name): string {
return"Hello {$name}!";
};
$mapper = newMapper([
'greet' => GetFiltered::from('name')->string()->with($greeting),
]);
$mapper->map(['name' => 'John']); // result: ['greet' => 'Hello John!']Regular filters will not be called when value becomes null, with exceptions of ifNull, ifEmpty and withNullable.
Custom null handling filter:
$requireInt = function ($value): int {
if (!is_int($value)) {
thrownewInvalidArgumentException('I require int!');
}
return$value;
};
$mapper = newMapper([
'must_be_int' => GetFiltered::from('number')->int()->withNullable($requireInt),
]);
$mapper->map(['number' => 'x']); // throws InvalidArgumentException$mapper->map(['number' => 1]); // returns ['required_int' => 1]GetFiltered has set of built-in filters similar to FilteredInput.
with(callable $filter): add to pipeline custom filter functionswithNullable(callable $filter): add to pipeline custom filter functions that will be called even when value has become nullstring(): try cast to stringint(): try cast to intfloat(): try cast to floatbool(): try cast to boolarray(): try cast to arrayexplode(string $delimiter)implode(string $glue)upper()lower()trim()format(string $template): format value withsprintftemplatereplace(string $search, string $replace)stripTags()numberFormat(int $decimals = 0, string $decimalPoint = '.', string $thousandsSeparator = ',')round(int $precision = 0)floor()ceil()date(): try cast toDateTimeImmutabledateFormat(string $format)count()ifNull($default)ifEmpty($default)
Input interface defines common abstraction for accessing data from different data structures,
so mapping and getters must not depend of underlying data type.
It also allows to create input decorators for additional input processing, like data filtering, transformation, traversing etc.
Wraps associative arrays and ArrayAccess objects.
$array = ['one' => 1];
$input = newArrayInput($array);
$input->get('key'); // is translated to: $array['key'] ?? null$input->get('one'); // 1$input->get('two'); // null$input->get('two', 'default'); // 'default'$input->has('one'); // true$input->has('two'); // falseWraps generic object and fetches data using object public interface: public properties or getters (a public method without parameters that returns some value).
Access method for key example name is resolved in the following order:
- check for public property
name - check for getter
name() - check for getter
getName() - check for getter
isName()
class Example
{
public$one = 1;
private$two = 2;
private$three = 3;
publicfunctiontwo(): int
{
return$this->two;
}
publicfunctiongetThree(): int
{
return$this->three;
}
}
$object = newExample();
$input = newObjectInput($object);
$input->get('one'); // 1 (public property $object->one)$input->get('two'); // 2 (getter $object->())$input->get('three'); // 3 (getter $object->getThree())$input->get('four'); // null (no property, no getter)$input->get('four', 'default'); // 'default'$input->has('one'); // true$input->has('four'); // falseRecursiveInput allows to traverse trees od data using dot notation ($input->get('root.branch.leaf')).
It decorates Input (current leaf) and requires Wrapper to wrap with proper Input next visited leafs (which can be arrays or objects).
class Example
{
public$one = ['nested' => 'nested one'];
publicfunctiontwo(): object
{
return (object)['nested' => 'nested two'];
}
};
$innerInput = newObjectInput(newExample());
$input = newRecursiveInput($innerInput, MixedWrapper::default());
$input->get('one'); // ['nested' => 'nested one']$input->get('one.nested'); // 'nested one'$input->get('one.other'); // null$input->get('two.nested'); // 'nested two'$input->has('one'); // true$input->has('one.nested'); // true$input->has('one.other'); // falseFilteredInput is another Input decorator that allows to transform data after it is extracted from inner structure.
$innerInput = newArrayInput([
'amount' => 123,
'description' => ' string ',
'price' => 123.1234,
]);
$input = newFilteredInput($innerInput, InputFilterParser::default());
$input->get('amount | string'); // '123'$input->get('description | trim | upper'); // 'STRING'$input->get('description | integer'); // null$input->get('price | round'); // 123.0$input->get('description | round'); // null$input->get('price | round 2'); // 123.12$input->get('price | ceil | integer'); // 124Default input parser supports given filters:
string: cast value to string if possible or return null |int,integer: cast to integer or return nullfloat: cast to float or return nullbool,boolean: resolve value as boolean or return nullarray: cast value to array if possible (from array or iterable) or return nullexplode [delimiter=","]: explode string using delimiter (,by default)implode [delimiter=","]: implode array of strings using delimiter (,by default)upper: upper case stringlower: lower case stringtrim,ltrim,rtrim: trim stringformat: format value as string usingsprintfreplace [search] [replace=""]: replace substring in string likestr_replacefunctionstrip_tags: same asstrip_tagsfunctionnumber_format [decimals=2] [decimal_point="."] [thousands_separator=","]: same asnumber_formatfunctionround [precision=0]: same asroundfunctionfloor: floor value, returnsfloat|nullceil: floor value, returnsfloat|nulldatetime: try to transform value toDateTimeImmutableor return nulldate_format [format="Y-m-d H:i:s"]: try to transform value to datetime and format as string or return null when value cannot be transformeddate_modify [modifier]: try to transform value toDateTimeImmutableand then transform it using modifier$datetime->modify($modifier)timestamp: try to transform value to datetime and then to timestamp or return nulljson_encode: encode value to json or return nulljson_decode: decode array from json string or return null when failedcount: return count for array orCountableor null when not countableif_null [then]: return default value when mapped value is nullif_empty [then]: return default value when mapped value is empty
Examples
- default explode by comma:
string | explode - explode by custom string:
string | explode "-" - default implode by comma:
array | implode - implode by custom string:
array | implode "-" - format string like
sprintf:string | format "string: %s" - format money from float:
float | format "price: $%01.2f"- transforms12.3499to'price: $12.35' - cast to string with default value:
maybe_string | string | if_null "default" - cast to date and modify:
date_string | date_modify "+1 day" - calculate md5 of mapped value:
key | string | md5 - wrap string after 20 characters:
key | string | wordwrap 20 - using native function with custom argument position of mapped value
key | string | preg_replace "/\s+/" " " $$
Default configuration of InputFilterParser allows use any PHP function as transformation.
By default mapped value is passed as first argument to that function optionally followed by other arguments defined in filter config.
It is also possible to define different argument position of mapped value using $$ as a placeholder.
Mapping output type depends on Formatter used by Mapper.
Built-in formatters:
Returns associative array which is raw result of Mapper transformation.
$mapper = newMapper($map);
// same as:$mapper = newMapper($map, newArrayFormatter());Tries to create new instance of object using regular constructor. Keys are matched with constructor parameters by variable name.
There is no value type and correctness checking, so you will get TypeError when mapped types does not match.
It also fallback to null value when object constructor has parameter that is not in the mapping.
// by class constructor:$mapper = newMapper($map, newObjectConstructor(SomeClass::class));
// by static method:$mapper = newMapper($map, newObjectConstructor(SomeClass::class, 'method'));Tries to hydrate instance of object using his public interface, that is:
- by setting public properties values
- by using setters (
setSomethingorwithSomethingassuming immutability)
// hydrate instance clone$mapper = newMapper($map, newObjectHydrator(newSomeClass()));
// new instance from class name$mapper = newMapper($map, newObjectHydrator(SomeClass::class));Mapper consists of 3 components:
GetterMapthat describes mapping asstring => Getterassociation,Wrapperthat wraps input mixed structure with properInputimplementation,Formatterthat formats raw mapping result (associative array) to array, object, XML, JSON and so on.
$mapper = newMapper($getterMap);
// which is equivalent of:$mapper = newMapper(
$getterMap, ArrayFormatter::default(), FilteredWrapper::default()
);It is possible to define data extracting for some object type explicitly.
interface Attributes
{
publicfunctiongetAttribute($key, $default = null);
}
class AttributesInput implements Input
{
/** @var Attribiutes */private$attributes;
publicfunctionget(string$key, $default = null)
{
return$this->attributes->getAttribute($key, $default);
}
// ...
}
class AttributesWrapper implements Wrapper
{
publicfunctionsupportedTypes(): array
{
return [Attributes::class]
}
publicfunctionwrap($data): Input
{
returnnewAttributesInput($data);
}
}
$mapper = newMapper(
$getterMap, ArrayFormatter::default(), FilteredWrapper::default()->withWrappers(newAttributesWrapper())
);By default Mapper supports nested structure fetching and value filters, which is nice but has some expense in performance (see BENCHMARK.md). But it is possible to create Mapper only with MixedWrapper when these feature are not needed.
$mapper = newMapper(
$getterMap, ArrayFormatter::default(), MixedWrapper::default()
);Filter functions list can be extended or overwritten with own implementation.
$mapper = newMapper(
[
'slug' => 'title | my_replace "/[\PL]+/u" "-" | trim "-"'
], ArrayFormatter::default(), FilteredWrapper::default()->withFilters([
'my_replace' => newFilter('preg_replace', ['//', '', '$$'])
])
);Custom formatter can be used to achieve better object construction performance than generic object formatters. It is also possible to create formatters creating different result types like XML, JSON etc.
class Person
{
/** @var string */private$name;
/** @var string */private$surname;
publicfunction__construct(string$name, string$surname)
{
$this->name = $name;
$this->surname = $surname;
}
// ...
}
class PersonFormatter implements Formatter
{
publicfunctionformat(array$output): Person
{
returnnewPerson($output['name'], $output['surname']);
}
}
class JsonFormatter implements Formatter
{
publicfunctionformat(array$output): string
{
returnjson_encode($output);
}
}
$map = [
'name' => 'person.name | string',
'surname' => 'person.surname | string',
];
$toPerson = newMapper($map, newPersonFormatter());
$toPerson->map(['person' => ['name' => 'John', 'surname' => 'Doe']]); // result: new Person('John', 'Doe');$toJson = newMapper($map, newJsonFormatter());
$toJson->map(['person' => ['name' => 'John', 'surname' => 'Doe']]); // result: {"name":"John","surname":"Doe"};