Passed
Push — master ( 675cc6...9d7ef7 )
by Andrea Marco
01:40 queued 12s
created

DtoPropertiesMapper::getPropertyKeyFromData()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 3
c 1
b 0
f 0
nc 2
nop 2
dl 0
loc 7
ccs 4
cts 4
cp 1
crap 2
rs 10
1
<?php
2
3
namespace Cerbero\Dto;
4
5
use Cerbero\Dto\Exceptions\DtoNotFoundException;
6
use Cerbero\Dto\Exceptions\InvalidDocCommentException;
7
use Cerbero\Dto\Exceptions\MissingValueException;
8
use Cerbero\Dto\Exceptions\UnknownDtoPropertyException;
9
use Cerbero\Dto\Manipulators\ArrayConverter;
10
use ReflectionClass;
11
use ReflectionException;
12
13
/**
14
 * The DTO properties mapper.
15
 *
16
 */
17
class DtoPropertiesMapper
18
{
19
    /**
20
     * The property doc comment pattern
21
     *
22
     * - Start with "@property" or "@property-read"
23
     * - Capture property type with possible "[]" suffix
24
     * - Capture variable name "$foo" or "foo"
25
     */
26
    protected const RE_PROPERTY = '/@property(?:-read)?\s+((?:[\w\\\_]+(?:\[])?\|?)+)\s+\$?([\w_]+)/';
27
28
    /**
29
     * The "use" statement pattern
30
     *
31
     * - Capture fully qualified class name
32
     * - Capture possible class alias after "as"
33
     */
34
    protected const RE_USE_STATEMENT = '/([\w\\\_]+)(?:\s+as\s+([\w_]+))?;/i';
35
36
    /**
37
     * The DTO properties mapper instances.
38
     *
39
     * @var DtoPropertiesMapper[]
40
     */
41
    protected static $instances = [];
42
43
    /**
44
     * The DTO class to map properties for.
45
     *
46
     * @var string
47
     */
48
    protected $dtoClass;
49
50
    /**
51
     * The reflection of the DTO class.
52
     *
53
     * @var ReflectionClass
54
     */
55
    protected $reflection;
56
57
    /**
58
     * The cached raw properties.
59
     *
60
     * @var array
61
     */
62
    protected $rawProperties;
63
64
    /**
65
     * The cached use statements.
66
     *
67
     * @var array
68
     */
69
    protected $useStatements;
70
71
    /**
72
     * The cached mapped properties.
73
     *
74
     * @var array
75
     */
76
    protected $mappedProperties;
77
78
    /**
79
     * Instantiate the class.
80
     *
81
     * @param string $dtoClass
82
     * @throws DtoNotFoundException
83
     */
84 39
    protected function __construct(string $dtoClass)
85
    {
86 39
        $this->dtoClass = $dtoClass;
87 39
        $this->reflection = $this->reflectDto();
88 36
    }
89
90
    /**
91
     * Retrieve the reflection of the given DTO class
92
     *
93
     * @return ReflectionClass
94
     * @throws DtoNotFoundException
95
     */
96 39
    protected function reflectDto(): ReflectionClass
97
    {
98
        try {
99 39
            return new ReflectionClass($this->dtoClass);
100 3
        } catch (ReflectionException $e) {
101 3
            throw new DtoNotFoundException($this->dtoClass);
102
        }
103
    }
104
105
    /**
106
     * Retrieve the mapper instance for the given DTO class
107
     *
108
     * @param string $dtoClass
109
     * @return DtoPropertiesMapper
110
     * @throws DtoNotFoundException
111
     */
112 228
    public static function for(string $dtoClass): DtoPropertiesMapper
113
    {
114 228
        return static::$instances[$dtoClass] = static::$instances[$dtoClass] ?? new static($dtoClass);
115
    }
116
117
    /**
118
     * Retrieve the mapped DTO properties
119
     *
120
     * @param array $data
121
     * @param int $flags
122
     * @return array
123
     * @throws InvalidDocCommentException
124
     * @throws MissingValueException
125
     * @throws UnexpectedValueException
126
     * @throws UnknownDtoPropertyException
127
     */
128 222
    public function map(array $data, int $flags): array
129
    {
130 222
        $mappedProperties = [];
131 222
        $rawProperties = $this->cacheRawProperties();
132 219
        $useStatements = $this->cacheUseStatements();
133
134 219
        foreach ($rawProperties as $name => $rawTypes) {
135 189
            $cachedProperty = $this->mappedProperties[$name] ?? null;
136 189
            $types = $cachedProperty ? $cachedProperty->getTypes() : $this->parseTypes($rawTypes, $useStatements);
137 189
            $key = $this->getPropertyKeyFromData($name, $data);
138
139 189
            if (!array_key_exists($key, $data)) {
140 189
                if ($types->haveDefaultValue($flags)) {
141 12
                    $data[$key] = $types->getDefaultValue($flags);
142 189
                } elseif ($flags & PARTIAL) {
143 186
                    continue;
144
                } else {
145 3
                    throw new MissingValueException($this->dtoClass, $name);
146
                }
147
            }
148
149 183
            $mappedProperties[$name] = DtoProperty::create($name, $data[$key], $types, $flags);
150 183
            unset($data[$key]);
151
        }
152
153 216
        $this->checkUnknownProperties($data, $flags);
154
155 216
        return $this->mappedProperties = $mappedProperties;
156
    }
157
158
    /**
159
     * Retrieve and cache the raw properties to map
160
     *
161
     * @return array
162
     * @throws InvalidDocCommentException
163
     */
164 222
    protected function cacheRawProperties(): array
165
    {
166 222
        if (isset($this->rawProperties)) {
167 195
            return $this->rawProperties;
168
        }
169
170 33
        if (false === $docComment = $this->reflection->getDocComment()) {
171 3
            throw new InvalidDocCommentException($this->dtoClass);
172
        }
173
174 30
        if (preg_match_all(static::RE_PROPERTY, $docComment, $matches, PREG_SET_ORDER) === 0) {
0 ignored issues
show
Unused Code introduced by
The call to preg_match_all() has too many arguments starting with Cerbero\Dto\PREG_SET_ORDER. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

174
        if (/** @scrutinizer ignore-call */ preg_match_all(static::RE_PROPERTY, $docComment, $matches, PREG_SET_ORDER) === 0) {

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
175 15
            return $this->rawProperties = [];
176
        }
177
178 24
        foreach ($matches as $match) {
179 24
            [, $rawTypes, $name] = $match;
180 24
            $this->rawProperties[$name] = $rawTypes;
181
        }
182
183 24
        return $this->rawProperties;
184
    }
185
186
    /**
187
     * Retrieve and cache the DTO "use" statements
188
     *
189
     * @return array
190
     */
191 219
    protected function cacheUseStatements(): array
192
    {
193 219
        if (isset($this->useStatements)) {
194 195
            return $this->useStatements;
195
        }
196
197 30
        $this->useStatements = [];
198 30
        $rawStatements = null;
199 30
        $handle = fopen($this->reflection->getFileName(), 'rb');
200
201
        do {
202 30
            $line = trim(fgets($handle, 120));
0 ignored issues
show
Bug introduced by
It seems like $handle can also be of type false; however, parameter $handle of fgets() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

202
            $line = trim(fgets(/** @scrutinizer ignore-type */ $handle, 120));
Loading history...
203 30
            $begin = substr($line, 0, 3);
204 30
            $rawStatements .= $begin == 'use' ? $line : null;
205 30
        } while ($begin != '/**');
206
207 30
        fclose($handle);
0 ignored issues
show
Bug introduced by
It seems like $handle can also be of type false; however, parameter $handle of fclose() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

207
        fclose(/** @scrutinizer ignore-type */ $handle);
Loading history...
208
209 30
        preg_match_all(static::RE_USE_STATEMENT, $rawStatements, $useMatches, PREG_SET_ORDER);
210
211 30
        foreach ($useMatches as $match) {
212 30
            $segments = explode('\\', $match[1]);
213 30
            $name = $match[2] ?? end($segments);
214 30
            $this->useStatements[$name] = $match[1];
215
        }
216
217 30
        return $this->useStatements;
218
    }
219
220
    /**
221
     * Parse the given raw property types
222
     *
223
     * @param string $rawTypes
224
     * @param array $useStatements
225
     * @return DtoPropertyTypes
226
     */
227 126
    protected function parseTypes(string $rawTypes, array $useStatements): DtoPropertyTypes
228
    {
229 63
        return array_reduce(explode('|', $rawTypes), function (DtoPropertyTypes $types, $rawType) use ($useStatements) {
230 189
            $name = str_replace('[]', '', $rawType, $count);
231 189
            $isCollection = $count > 0;
232
233
            // fully qualified class name exists
234 189
            if (strpos($rawType, '\\') === 0 && class_exists($name)) {
235 66
                return $types->addType(new DtoPropertyType(substr($name, 1), $isCollection));
236
            }
237
238 189
            if (isset($useStatements[$name])) {
239 66
                return $types->addType(new DtoPropertyType($useStatements[$name], $isCollection));
240
            }
241
242
            // class in DTO namespace exists
243 189
            if (class_exists($class = $this->reflection->getNamespaceName() . '\\' . $name)) {
244 171
                return $types->addType(new DtoPropertyType($class, $isCollection));
245
            }
246
247 189
            return $types->addType(new DtoPropertyType($name, $isCollection));
248 189
        }, new DtoPropertyTypes());
249
    }
250
251
    /**
252
     * Retrieve the key for the given property in the provided data
253
     *
254
     * @param string $property
255
     * @param array $data
256
     * @return string
257
     */
258 189
    protected function getPropertyKeyFromData(string $property, array $data): string
259
    {
260 189
        if (array_key_exists($property, $data)) {
261 183
            return $property;
262
        }
263
264 189
        return strtolower(preg_replace(ArrayConverter::RE_SNAKE_CASE, '_', $property));
265
    }
266
267
    /**
268
     * Check whether the given data contains unknown properties
269
     *
270
     * @param array $data
271
     * @param int $flags
272
     * @return void
273
     * @throws UnknownDtoPropertyException
274
     */
275 216
    protected function checkUnknownProperties(array $data, int $flags): void
276
    {
277 216
        if ($data && !($flags & IGNORE_UNKNOWN_PROPERTIES)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $data of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
278 6
            throw new UnknownDtoPropertyException($this->dtoClass, key($data));
279
        }
280 216
    }
281
}
282