Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
103 / 103
100.00% covered (success)
100.00%
21 / 21
CRAP
100.00% covered (success)
100.00%
1 / 1
Entity
100.00% covered (success)
100.00%
103 / 103
100.00% covered (success)
100.00%
21 / 21
49
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 __isset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __unset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __set
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 __get
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 propertyNotDefined
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 init
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renderMethodName
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 populate
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 setProperty
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 typeHint
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 typeHintCustom
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 typeHintNative
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
9
 typeHintAplus
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 jsonOptions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 timezone
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toModel
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 jsonSerialize
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getObjectVars
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getJsonVars
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setJsonVars
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php declare(strict_types=1);
2/*
3 * This file is part of Aplus Framework MVC Library.
4 *
5 * (c) Natan Felles <natanfelles@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10namespace Framework\MVC;
11
12use DateTime;
13use DateTimeInterface;
14use DateTimeZone;
15use Framework\Date\Date;
16use Framework\HTTP\URL;
17use JsonException;
18use OutOfBoundsException;
19use ReflectionProperty;
20use stdClass;
21
22/**
23 * Class Entity.
24 *
25 * @package mvc
26 */
27abstract class Entity implements \JsonSerializable //, \Stringable
28{
29    protected int $_jsonOptions = \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE
30    | \JSON_PRESERVE_ZERO_FRACTION | \JSON_THROW_ON_ERROR;
31    /**
32     * @var array<string>
33     */
34    protected array $_jsonVars = [];
35    protected string $_timezone = '+00:00';
36
37    /**
38     * @param array<string,mixed> $properties
39     */
40    public function __construct(array $properties)
41    {
42        $this->populate($properties);
43        $this->init();
44    }
45
46    public function __isset(string $property) : bool
47    {
48        return isset($this->{$property});
49    }
50
51    public function __unset(string $property) : void
52    {
53        unset($this->{$property});
54    }
55
56    /**
57     * @param string $property
58     * @param mixed $value
59     *
60     * @throws OutOfBoundsException If property is not defined
61     */
62    public function __set(string $property, mixed $value) : void
63    {
64        $method = $this->renderMethodName('set', $property);
65        if (\method_exists($this, $method)) {
66            $this->{$method}($value);
67            return;
68        }
69        if (\property_exists($this, $property)) {
70            $this->{$property} = $value;
71            return;
72        }
73        throw $this->propertyNotDefined($property);
74    }
75
76    /**
77     * @param string $property
78     *
79     * @throws OutOfBoundsException If property is not defined
80     *
81     * @return mixed
82     */
83    public function __get(string $property) : mixed
84    {
85        $method = $this->renderMethodName('get', $property);
86        if (\method_exists($this, $method)) {
87            return $this->{$method}();
88        }
89        if (\property_exists($this, $property)) {
90            return $this->{$property};
91        }
92        throw $this->propertyNotDefined($property);
93    }
94
95    protected function propertyNotDefined(string $property) : OutOfBoundsException
96    {
97        return new OutOfBoundsException('Property not defined: ' . $property);
98    }
99
100    /**
101     * Used to initialize settings, set custom properties, etc.
102     * Called in the constructor just after the properties be populated.
103     */
104    protected function init() : void
105    {
106    }
107
108    /**
109     * @param string $type get or set
110     * @param string $property Property name
111     *
112     * @return string
113     */
114    protected function renderMethodName(string $type, string $property) : string
115    {
116        static $properties;
117        if (isset($properties[$property])) {
118            return $type . $properties[$property];
119        }
120        $name = \ucwords($property, '_');
121        $name = \strtr($name, ['_' => '']);
122        $properties[$property] = $name;
123        return $type . $name;
124    }
125
126    /**
127     * @param array<string,mixed> $properties
128     */
129    protected function populate(array $properties) : void
130    {
131        foreach ($properties as $property => $value) {
132            $method = $this->renderMethodName('set', $property);
133            if (\method_exists($this, $method)) {
134                $this->{$method}($value);
135                continue;
136            }
137            $this->setProperty($property, $value);
138        }
139    }
140
141    protected function setProperty(string $name, mixed $value) : void
142    {
143        if ( ! \property_exists($this, $name)) {
144            throw $this->propertyNotDefined($name);
145        }
146        if ($value !== null) {
147            $rp = new ReflectionProperty($this, $name);
148            $propertyType = $rp->getType()?->getName(); // @phpstan-ignore-line
149            if ($propertyType !== null) {
150                $value = $this->typeHint($propertyType, $value);
151            }
152        }
153        $this->{$name} = $value;
154    }
155
156    protected function typeHint(string $propertyType, mixed $value) : mixed
157    {
158        $valueType = \get_debug_type($value);
159        $newValue = $this->typeHintCustom($propertyType, $valueType, $value);
160        if ($newValue === null) {
161            $newValue = $this->typeHintNative($propertyType, $valueType, $value);
162        }
163        if ($newValue === null) {
164            $newValue = $this->typeHintAplus($propertyType, $valueType, $value);
165        }
166        return $newValue ?? $value;
167    }
168
169    protected function typeHintCustom(string $propertyType, string $valueType, mixed $value) : mixed
170    {
171        return null;
172    }
173
174    protected function typeHintNative(string $propertyType, string $valueType, mixed $value) : mixed
175    {
176        if ($propertyType === 'array') {
177            return $valueType === 'string'
178                ? \json_decode($value, true, flags: $this->jsonOptions())
179                : (array) $value;
180        }
181        if ($propertyType === 'bool') {
182            return (bool) $value;
183        }
184        if ($propertyType === 'float') {
185            return (float) $value;
186        }
187        if ($propertyType === 'int') {
188            return (int) $value;
189        }
190        if ($propertyType === 'string') {
191            return (string) $value;
192        }
193        if ($propertyType === stdClass::class) {
194            return $valueType === 'string'
195                ? (object) \json_decode($value, flags: $this->jsonOptions())
196                : (object) $value;
197        }
198        return null;
199    }
200
201    protected function typeHintAplus(string $propertyType, string $valueType, mixed $value) : mixed
202    {
203        if ($propertyType === Date::class) {
204            return new Date((string) $value);
205        }
206        if ($propertyType === URL::class) {
207            return new URL((string) $value);
208        }
209        return null;
210    }
211
212    protected function jsonOptions() : int
213    {
214        return $this->_jsonOptions;
215    }
216
217    protected function timezone() : DateTimeZone
218    {
219        return new DateTimeZone($this->_timezone);
220    }
221
222    /**
223     * Convert the Entity to an associative array accepted by Model methods.
224     *
225     * @throws JsonException
226     *
227     * @return array<string,scalar>
228     */
229    public function toModel() : array
230    {
231        $jsonVars = $this->getJsonVars();
232        $this->setJsonVars(\array_keys($this->getObjectVars()));
233        // @phpstan-ignore-next-line
234        $data = \json_decode(\json_encode($this, $this->jsonOptions()), true, 512, $this->jsonOptions());
235        foreach ($data as $property => &$value) {
236            if (\is_array($value)) {
237                $value = \json_encode($value, $this->jsonOptions());
238                continue;
239            }
240            $type = \get_debug_type($this->{$property});
241            if (\is_subclass_of($type, DateTimeInterface::class)) {
242                $datetime = DateTime::createFromFormat(DateTimeInterface::ATOM, $value);
243                $datetime->setTimezone($this->timezone()); // @phpstan-ignore-line
244                $value = $datetime->format('Y-m-d H:i:s'); // @phpstan-ignore-line
245            }
246        }
247        unset($value);
248        $this->setJsonVars($jsonVars);
249        return $data;
250    }
251
252    public function jsonSerialize() : stdClass
253    {
254        if ( ! $this->getJsonVars()) {
255            return new stdClass();
256        }
257        $allowed = \array_flip($this->getJsonVars());
258        $filtered = \array_intersect_key($this->getObjectVars(), $allowed);
259        $allowed = \array_intersect_key($allowed, $filtered);
260        $ordered = \array_replace($allowed, $filtered);
261        return (object) $ordered;
262    }
263
264    /**
265     * @return array<string,mixed>
266     */
267    protected function getObjectVars() : array
268    {
269        $result = [];
270        foreach (\get_object_vars($this) as $key => $value) {
271            if ( ! \str_starts_with($key, '_')) {
272                $result[$key] = $value;
273            }
274        }
275        return $result;
276    }
277
278    /**
279     * @return array<string>
280     */
281    public function getJsonVars() : array
282    {
283        return $this->_jsonVars;
284    }
285
286    /**
287     * @param array<string> $vars
288     */
289    public function setJsonVars(array $vars) : static
290    {
291        $this->_jsonVars = $vars;
292        return $this;
293    }
294}