Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
9 / 9
CRAP
100.00% covered (success)
100.00%
1 / 1
DtoFactory
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
9 / 9
20
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 setDtoConstructorParamNames
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 makeDto
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 makePropertyMap
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 setPropertyMap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPropertyMap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getValueFromArray
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getValueFromEntity
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 makeArrayFromEntity
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * @author: Doug Wilbourne (dougwilbourne@gmail.com)
5 */
6
7declare(strict_types=1);
8
9namespace pvc\struct\dto;
10
11use pvc\interfaces\struct\dto\DtoFactoryInterface;
12use pvc\interfaces\struct\dto\DtoInterface;
13use pvc\struct\dto\err\DtoClassDefinitionException;
14use pvc\struct\dto\err\DtoInvalidArrayKeyException;
15use pvc\struct\dto\err\DtoInvalidEntityGetterException;
16use pvc\struct\dto\err\DtoInvalidPropertyNameException;
17use pvc\struct\dto\err\InvalidDtoReflection;
18use ReflectionClass;
19use ReflectionException;
20use ReflectionParameter;
21use ReflectionProperty;
22use Throwable;
23
24/**
25 * Class DtoFactory
26 *
27 * This class is a singleton.
28 *
29 * This is a dirt-simple mapper/factory, intended really to facilitate testing of data structures / objects such
30 * as those found in this library.  Large frameworks and other standalone ORMs obviously have much more
31 * sophisticated functionality to get data in and out of the model.
32 *
33 * a Data Transfer Object (DTO) is a small step up from an associative array, maybe best thought of as an array whose
34 * values have prescribed data types. There should not be any logic in the DTO, it should not have
35 * setters and getters and all its properties should be public readonly.  The property values should be assigned in
36 * the constructor.
37 *
38 * In order to isolate the data structure (model / entity) from the storage mechanism, this mapper provides the ability
39 * to move data from the model to a DTO and then from a DTO to an array, transposing property names and key names
40 * as necessary.
41 *
42 * usage:  using this class, you can make a DTO from an array.  The model can then suck the data from
43 * the (publicly accessible) properties and incorporate the DTO's data.  In reverse, this mapper/factory can make
44 * a dto from a model class, or it can move the data from the model directly to an array.
45 *
46 * If your name of the property in the dto, the model and the array are all the same, then it is not necessary
47 * to create a property map for that property, assuming the getter in the model is in the camel case format
48 * "getPropertyName" - the code guesses the name of the getter based on the property name.  On the other hand, if the
49 * getter does not conform to that practice or if the array key needs to be something different, then add a property
50 * map for that property.  So the normal usage is 1) create the factory, 2) add property maps as necessary, 3) make
51 * your dtos.
52 *
53 * @template T of ReflectionClass
54 */
55class DtoFactory implements DtoFactoryInterface
56{
57    /**
58     * @var ReflectionClass<DtoInterface>
59     */
60    protected ReflectionClass $dtoReflection;
61
62    /**
63     * @var ReflectionClass<object>
64     */
65    protected ReflectionClass $entityReflection;
66
67    /**
68     * @var array<string>
69     * the getParameters reflection method is guaranteed to return the parameters in the order in which they appear in
70     * the constructor.  We also ensure that all the public properties of the dto appear in the constructor args.  So we
71     * can use this array to create a new dto and populate its readonly properties.
72     */
73    protected array $dtoConstructorParamNames;
74
75    /**
76     * @var array<string, PropertyMap>
77     * string key is the property name of the dto that is being mapped to an entity property or array key
78     */
79    protected array $propertyMappings;
80
81    /**
82     * @param class-string<Dtointerface> $dtoClassName
83     * @param class-string<object> $entityClassName
84     * @throws InvalidDtoReflection
85     * @throws ReflectionException
86     */
87    public function __construct(string $dtoClassName, string $entityClassName) {
88        $this->dtoReflection = new ReflectionClass($dtoClassName);
89        /**
90         * ensure it is a dto
91         */
92        if (!$this->dtoReflection->implementsInterface(DtoInterface::class)) {
93            throw new InvalidDtoReflection($this->dtoReflection->getName());
94        }
95        $this->entityReflection = new ReflectionClass($entityClassName);
96        $this->setDtoConstructorParamNames();
97    }
98
99    /**
100     * @return void
101     * @throws InvalidDtoReflection
102     * @throws ReflectionException
103     * checks to ensure that all the public properties are in the list of constructor args.
104     */
105    protected function setDtoConstructorParamNames(): void
106    {
107
108        $dtoPublicProperties = array_map(
109            function (ReflectionProperty $value): string {
110                return $value->getName();
111            },
112            $this->dtoReflection->getProperties(ReflectionProperty::IS_PUBLIC),
113        );
114
115        $reflectionParams = $this->dtoReflection->getConstructor()?->getParameters() ?? [];
116        $callback = function (ReflectionParameter $param) { return $param->getName(); };
117        $constructorParams = array_map($callback, $reflectionParams);
118        if (array_diff($dtoPublicProperties, $constructorParams)) {
119            throw new DtoClassDefinitionException($this->dtoReflection->getName());
120        }
121
122        /**
123         * the getParameters method is guaranteed to return the parameters in the order in which they appear in the
124         * constructor.
125         */
126        $this->dtoConstructorParamNames = $constructorParams;
127    }
128
129    /**
130     * @param array<mixed>|object $source
131     * @return DtoInterface
132     * @throws DtoInvalidArrayKeyException
133     * @throws DtoInvalidEntityGetterException
134     * @throws ReflectionException
135     */
136    public function makeDto(array|object $source): DtoInterface
137    {
138        $args = [];
139        foreach ($this->dtoConstructorParamNames as $paramName) {
140            $propertyMap = $this->getPropertyMap($paramName);
141            $args[$paramName] = is_array($source) ?
142                $this->getValueFromArray($source, $propertyMap) :
143                $this->getValueFromEntity($source, $propertyMap);
144        }
145        /** @var DtoInterface $dto */
146        $dto = $this->dtoReflection->newInstanceArgs($args);
147        return $dto;
148    }
149
150    /**
151     * @throws ReflectionException
152     * @throws DtoInvalidArrayKeyException
153     * @throws DtoInvalidEntityGetterException
154     */
155    protected function makePropertyMap(
156        string $dtoPropertyName,
157        ?string $entityGetterMethodName = '',
158        string $arrayKeyName = '',
159    ): PropertyMap {
160
161        if (!in_array($dtoPropertyName, $this->dtoConstructorParamNames)) {
162            throw new DtoInvalidPropertyNameException($dtoPropertyName);
163        }
164
165        /**
166         * if the entityGetterClassName argument is empty, guess that the getter name follows the standard
167         * convention for naming getters.
168         */
169        $entityGetterMethodName = empty($entityGetterMethodName) ?
170            'get' . strtoupper(substr($dtoPropertyName, 0, 1)) . substr($dtoPropertyName, 1) :
171            $entityGetterMethodName;
172
173        if (!$this->entityReflection->hasMethod($entityGetterMethodName)) {
174            throw new DtoInvalidEntityGetterException($entityGetterMethodName, $this->entityReflection->getName());
175        }
176
177        $arrayKeyName = empty($arrayKeyName) ? $dtoPropertyName : $arrayKeyName;
178
179        return new PropertyMap($dtoPropertyName, $entityGetterMethodName, $arrayKeyName);
180    }
181
182    public function setPropertyMap(        string $dtoPropertyName,
183                                           ?string $entityGetterMethodName = '',
184                                           string $arrayKeyName = '',
185    ): void{
186        $this->propertyMappings[$dtoPropertyName] = $this->makePropertyMap($dtoPropertyName, $entityGetterMethodName, $arrayKeyName);
187    }
188
189    /**
190     * @param string $dtoPropertyName
191     * @return PropertyMap
192     * @throws DtoInvalidArrayKeyException
193     * @throws DtoInvalidEntityGetterException
194     * @throws ReflectionException
195     */
196    public function getPropertyMap(string $dtoPropertyName): PropertyMap
197    {
198        return $this->propertyMappings[$dtoPropertyName] ?? $this->makePropertyMap($dtoPropertyName);
199    }
200
201    /**
202     * @param array<mixed> $array
203     * @param PropertyMap $propertyMap
204     * @return mixed
205     * @throws DtoInvalidArrayKeyException
206     */
207    protected function getValueFromArray(array $array, PropertyMap $propertyMap): mixed
208    {
209        if (!array_key_exists($propertyMap->arrayKeyName, $array)) {
210            throw new DtoInvalidArrayKeyException($propertyMap->arrayKeyName);
211        } else {
212            return $array[$propertyMap->arrayKeyName];
213        }
214    }
215
216    /**
217     * @param object $entity
218     * @param PropertyMap $propertyMap
219     * @return mixed
220     * @throws DtoInvalidEntityGetterException
221     */
222    protected function getValueFromEntity(object $entity, PropertyMap $propertyMap): mixed
223    {
224        try {
225            $getterMethodName = $propertyMap->entityGetterMethodName;
226            return $entity->$getterMethodName();
227        } catch (Throwable $e) {
228            throw new DtoInvalidEntityGetterException($propertyMap->entityGetterMethodName, get_class($entity));
229        }
230    }
231
232    /**
233     * @param object $entity
234     * @return array<mixed>
235     * @throws DtoInvalidArrayKeyException
236     * @throws DtoInvalidEntityGetterException
237     * @throws ReflectionException
238     */
239    public function makeArrayFromEntity(object $entity): array
240    {
241        $array = [];
242        foreach ($this->dtoConstructorParamNames as $paramName) {
243            $propertyMap = $this->getPropertyMap($paramName);
244            $array[$propertyMap->arrayKeyName] = $this->getValueFromEntity($entity, $propertyMap);
245        }
246        return $array;
247    }
248}