MajoraNormalizer::createWrittingDelegate()   B
last analyzed

Complexity

Conditions 5
Paths 2

Size

Total Lines 21
Code Lines 11

Duplication

Lines 21
Ratio 100 %

Code Coverage

Tests 12
CRAP Score 5.0113

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 21
loc 21
ccs 12
cts 13
cp 0.9231
rs 8.7624
cc 5
eloc 11
nc 2
nop 0
crap 5.0113
1
<?php
2
3
namespace Majora\Framework\Normalizer;
4
5
use Majora\Framework\Inflector\Inflector;
6
use Majora\Framework\Model\EntityCollection;
7
use Majora\Framework\Normalizer\Exception\InvalidScopeException;
8
use Majora\Framework\Normalizer\Exception\ScopeNotFoundException;
9
use Majora\Framework\Normalizer\Model\NormalizableInterface;
10
use Symfony\Component\PropertyAccess\PropertyAccess;
11
use Symfony\Component\PropertyAccess\PropertyAccessor;
12
use Symfony\Component\PropertyAccess\PropertyPathInterface;
13
use Symfony\Component\PropertyAccess\PropertyPath;
14
15
/**
16
 * Normalizer class implementing scoping compilation and object normalization construction.
17
 *
18
 * @see NormalizableInterface
19
 */
20
class MajoraNormalizer
21
{
22
    /**
23
     * @var MajoraNormalizer[]
24
     */
25
    private static $instancePool;
26
27
    /**
28
     * @var \ReflectionClass[]
29
     */
30
    private static $reflectionPool;
31
32
    /**
33
     * @var PropertyPath[]
34
     */
35
    private $propertiesPathPool;
36
37
    /**
38
     * @var \Closure
39
     */
40
    private $extractorDelegate;
41
42
    /**
43
     * @var \Closure
44
     */
45
    private $readDelegate;
46
47
    /**
48
     * @var \Closure
49
     */
50
    private $writeDelegate;
51
52
    /**
53
     * @var PropertyAccessor
54
     */
55
    protected $propertyAccessor;
56
57
    /**
58
     * @var Inflector
59
     */
60
    protected $inflector;
61
62
    /**
63
     * Create and return an instantiated normalizer, returns always the same throught this call.
64
     *
65
     * @param string $key optionnal normalizer key
66
     *
67
     * @return MajoraNormalizer
68
     */
69 36
    public static function createNormalizer($key = 'default')
70
    {
71 36
        return isset(self::$instancePool[$key]) ?
72 36
            self::$instancePool[$key] :
73 2
            self::$instancePool[$key] = new static(
74 19
            PropertyAccess::createPropertyAccessor()
75 18
        );
76
    }
77
78
    /**
79
     * Construct.
80
     *
81
     * @param PropertyAccessor $propertyAccessor
82
     */
83 2
    public function __construct(PropertyAccessor $propertyAccessor)
84
    {
85 2
        $this->propertyAccessor = $propertyAccessor;
86 2
        $this->inflector = new Inflector();
87 2
        $this->propertiesPathPool = array();
88 2
    }
89
90
    /**
91
     * Create and return a Closure which can read all properties from an object.
92
     *
93
     * @return \Closure
94
     */
95
    private function createExtractorDelegate()
96
    {
97
        return $this->extractorDelegate ?: $this->extractorDelegate = function () {
98
            return get_object_vars($this);
99
        };
100
    }
101
102
    /**
103
     * Create and return a Closure available to read an object property through a property path or a private property.
104
     *
105
     * @return \Closure
106
     */
107 14 View Code Duplication
    private function createReadingDelegate()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
108
    {
109
        return $this->readDelegate ?: $this->readDelegate = function ($property, PropertyAccessor $propertyAccessor) {
110 7
            switch (true) {
111
112
                // Public property / accessor case
113 14
                case $propertyAccessor->isReadable($this, $property) :
0 ignored issues
show
Coding Style introduced by
There must be no space before the colon in a CASE statement

As per the PSR-2 coding standard, there must not be a space in front of the colon in case statements.

switch ($selector) {
    case "A": //right
        doSomething();
        break;
    case "B" : //wrong
        doSomethingElse();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
114 14
                    return $propertyAccessor->getValue($this, $property);
115
116
                // Private property / StdClass
117 8
                case property_exists($this, $property) || $this instanceof \StdClass:
118 8
                    return $this->$property;
119
            }
120
121
            throw new InvalidScopeException(sprintf(
122
                'Unable to read "%s" property from a "%s" object, any existing property path to read it in.',
123
                $property,
124
                get_class($this)
125
            ));
126 14
        };
127
    }
128
129
    /**
130
     * Normalize given object using given scope.
131
     *
132
     * @param mixed  $object
133
     * @param string $scope
134
     *
135
     * @return array|string
136
     *
137
     * @throws ScopeNotFoundException If given scope not defined into given normalizable
138
     * @throws InvalidScopeException  If given scope requires an unaccessible field
139
     */
140 12
    public function normalize($object, $scope = 'default')
141
    {
142 6
        switch (true) {
143
144
            // Cannot normalized anything which already are
145 12
            case !is_object($object) :
0 ignored issues
show
Coding Style introduced by
There must be no space before the colon in a CASE statement

As per the PSR-2 coding standard, there must not be a space in front of the colon in case statements.

switch ($selector) {
    case "A": //right
        doSomething();
        break;
    case "B" : //wrong
        doSomethingElse();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
146 10
                return $object;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return $object; (integer|double|string|null|array|boolean|resource) is incompatible with the return type documented by Majora\Framework\Normali...raNormalizer::normalize of type array|string.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
147
148
            // StdClass can be cast as array
149 10
            case $object instanceof StdClass :
0 ignored issues
show
Bug introduced by
The class Majora\Framework\Normalizer\StdClass does not exist. Did you forget a USE statement, or did you not list all dependencies?

This error could be the result of:

1. Missing dependencies

PHP Analyzer uses your composer.json file (if available) to determine the dependencies of your project and to determine all the available classes and functions. It expects the composer.json to be in the root folder of your repository.

Are you sure this class is defined by one of your dependencies, or did you maybe not list a dependency in either the require or require-dev section?

2. Missing use statement

PHP does not complain about undefined classes in ìnstanceof checks. For example, the following PHP code will work perfectly fine:

if ($x instanceof DoesNotExist) {
    // Do something.
}

If you have not tested against this specific condition, such errors might go unnoticed.

Loading history...
Coding Style introduced by
There must be no space before the colon in a CASE statement

As per the PSR-2 coding standard, there must not be a space in front of the colon in case statements.

switch ($selector) {
    case "A": //right
        doSomething();
        break;
    case "B" : //wrong
        doSomethingElse();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
150
                return (array) $object;
151
152
            // DateTime : ISO format
153 5
            case $object instanceof \DateTime:
154 4
                return $object->format(\DateTime::ISO8601);
155
156
            // Other objects : we use a closure hack to read data
157 10
            case !$object instanceof NormalizableInterface :
0 ignored issues
show
Coding Style introduced by
There must be no space before the colon in a CASE statement

As per the PSR-2 coding standard, there must not be a space in front of the colon in case statements.

switch ($selector) {
    case "A": //right
        doSomething();
        break;
    case "B" : //wrong
        doSomethingElse();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
158
                $extractor = \Closure::bind($this->createExtractorDelegate(), $object, get_class($object));
159
160
                return $extractor($object);
161
162
            // At this point, we always got a Normalizable
163 5
            default:
164 10
                return $object->normalize($scope);
165 5
        }
166
    }
167
168
    /**
169
     * Normalize given normalizable following given scope.
170
     *
171
     * @param NormalizableInterface $object
172
     * @param string                $scope
173
     *
174
     * @return array
175
     */
176 16
    public function scopify(NormalizableInterface $object, $scope)
177
    {
178 16
        $scopes = $object->getScopes();
179 16
        if (!isset($scopes[$scope])) {
180 2
            throw new ScopeNotFoundException(sprintf(
181 2
                'Invalid scope for %s object, only ["%s"] supported, "%s" given.',
182 1
                get_class($object),
183 2
                implode('", "', array_keys($scopes)),
184
                $scope
185 1
            ));
186
        }
187 14
        if (empty($scopes) || empty($scopes[$scope])) {
188
            return array();
189
        }
190
191 14
        $read = \Closure::bind(
192 14
            $this->createReadingDelegate(),
193 7
            $object,
194 7
            get_class($object)
195 7
        );
196
197
        // simple value scope
198 14
        if (is_string($scopes[$scope])) {
199 6
            return $read($scopes[$scope], $this->propertyAccessor);
200
        }
201
202
        // flatten fields
203 10
        $fields = array();
204 10
        $stack = array($scopes[$scope]);
205
        do {
206 10
            $stackedField = array_shift($stack);
207 10
            foreach ($stackedField as $fieldConfig) {
208 10
                if (strpos($fieldConfig, '@') === 0) {
209 6
                    if (!array_key_exists(
210 6
                        $inheritedScope = str_replace('@', '', $fieldConfig),
211
                        $scopes
212 3
                    )) {
213
                        throw new ScopeNotFoundException(sprintf(
214
                            'Invalid inherited scope for %s object at %s scope, only ["%s"] supported, "%s" given.',
215
                            get_class($object),
216
                            $scope,
217
                            implode(', ', array_keys($scopes)),
218
                            $inheritedScope
219
                        ));
220
                    }
221
222 6
                    array_unshift($stack, $scopes[$inheritedScope]);
223 6
                    continue;
224
                }
225
226 10
                $fields[] = $fieldConfig;
227 5
            }
228 10
        } while (!empty($stack));
229
230
        // begin normalization
231 10
        $data = array();
232 10
        foreach ($fields as $field) {
233
            // optionnal field detection
234 10
            $optionnal = false;
235 10
            if (strpos($field, '?') !== false) {
236 4
                $field = str_replace('?', '', $field);
237 4
                $optionnal = true;
238 2
            }
239
240
            // external scopes : first in, last in
241 10
            $subScope = 'default';
242 10
            if (strpos($field, '@') !== false) {
243 4
                list($field, $subScope) = explode('@', $field);
244 2
            }
245 10
            if (isset($data[$field])) {
246 2
                continue;
247
            }
248
249 10
            $value = $this->normalize(
250 10
                $read($field, $this->propertyAccessor),
251
                $subScope
252 5
            );
253
254
            // nullable ?
255 10
            if (!(is_null($value) && $optionnal)) {
256 10
                $data[$field] = $value;
257 5
            }
258 5
        }
259
260 10
        return $data;
261
    }
262
263
    /**
264
     * Create and return a Closure available to write an object property through a property path or a private property.
265
     *
266
     * @return \Closure
267
     */
268 View Code Duplication
    private function createWrittingDelegate()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
269
    {
270 18
        return $this->writeDelegate ?: $this->writeDelegate = function (PropertyPathInterface $property, $value, PropertyAccessor $propertyAccessor) {
271 9
            switch (true) {
272
273
                // Public property / accessor case
274 18
                case $propertyAccessor->isWritable($this, $property) :
0 ignored issues
show
Coding Style introduced by
There must be no space before the colon in a CASE statement

As per the PSR-2 coding standard, there must not be a space in front of the colon in case statements.

switch ($selector) {
    case "A": //right
        doSomething();
        break;
    case "B" : //wrong
        doSomethingElse();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
275 16
                    return $propertyAccessor->setValue($this, $property, $value);
276
277
                // Private property / StdClass
278 4
                case property_exists($this, $property) || $this instanceof \StdClass :
0 ignored issues
show
Coding Style introduced by
There must be no space before the colon in a CASE statement

As per the PSR-2 coding standard, there must not be a space in front of the colon in case statements.

switch ($selector) {
    case "A": //right
        doSomething();
        break;
    case "B" : //wrong
        doSomethingElse();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
279 2
                    return $this->$property = $value;
280
            }
281
282 2
            throw new InvalidScopeException(sprintf(
283 2
                'Unable to set "%s" property into a "%s" object, any existing property path to write it in.',
284 1
                $property,
285 1
                get_class($this)
286 1
            ));
287 18
        };
288
    }
289
290
    /**
291
     * Denormalize given object data into given normalizable object or class
292
     * If class given, normalizer will try to inject data into constructor if class is not a NormalizableInterface.
293
     *
294
     * @param mixed         $data
295
     * @param object|string $normalizable normalizable object to denormalize in or an object class name
296
     *
297
     * @return NormalizableInterface
298
     */
299 20
    public function denormalize($data, $normalizable)
300
    {
301 20
        $class = is_string($normalizable) ?
302 12
            $normalizable : (
303 8
                $normalizable instanceof \ReflectionClass ?
304 11
                    $normalizable->name :
305 18
                    get_class($normalizable)
306
            )
307 10
        ;
308 20
        $reflection = isset(self::$reflectionPool[$class]) ?
309 17
            self::$reflectionPool[$class] :
310 10
            self::$reflectionPool[$class] = $normalizable instanceof \ReflectionClass ?
311 7
                $normalizable :
312 13
                new \ReflectionClass($class)
313 10
        ;
314
315 20
        $object = $normalizable;
316
317
        // Already got a denormalized object ?
318 20
        if (is_object($data) && is_a($data, $class)) {
319
            return $data;
320
        }
321
322
        // Got reflection ? so build a new object
323 20
        if (is_string($object) || $object instanceof \ReflectionClass) {
324 10
            if (empty($data)) { // no data ? no worries !
325
                return $reflection->newInstance();
326
            }
327
328 10
            $arguments = array();
329
330
            // Construct with parameters ? we will try to hydrate arguments from their names
331 10
            if ($reflection->hasMethod('__construct')
332 10
                && count($parameters = $reflection->getMethod('__construct')->getParameters())
333 5
            ) {
334
                // String as items cases like \DateTime
335 6
                if (!is_array($data)) {
336 4
                    $arguments = array($data);
337 4
                    unset($data);
338 2
                } else {
339
                    // Hydrate constructor args from data keys
340 2
                    foreach ($parameters as $parameter) {
341 2
                        $argKey = $this->inflector->snakelize($parameter->getName());
342 2
                        if (isset($data[$argKey])) {
343 2
                            $arguments[] = $parameter->getClass() ?
344 1
                                $this->normalize($data[$argKey], $parameter->getClass()) :
345 2
                                $data[$argKey]
346
                            ;
347 2
                            unset($data[$argKey]);
348
349 2
                            continue;
350
                        }
351
352 2
                        $arguments[] = $parameter->isOptional() ?
353 2
                            $parameter->getDefaultValue() :
354 1
                            null
355
                        ;
356 1
                    }
357
                }
358 3
            }
359
360 10
            $object = empty($arguments) ?
361 7
                $reflection->newInstance() :
362 10
                $reflection->newInstanceArgs($arguments);
363 5
        }
364
365
        // BAD !
366 20
        if ($object instanceof EntityCollection) {
367
            return $object->denormalize($data);
368
        }
369
370 20
        if (empty($data)) {
371 4
            return $object;
372
        }
373
374 18
        $write = \Closure::bind(
375 18
            $this->createWrittingDelegate(),
376 9
            $object,
377 9
            get_class($object)
378 9
        );
379
380 18
        foreach ($data as $property => $value) {
0 ignored issues
show
Bug introduced by
The expression $data of type integer|double|string|bo...n|resource|object|array is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
381
            // Instanciate propertyPath before usage, to improve performance.
382 18
            if (!isset($this->propertiesPathPool[$property])) {
383 18
                $this->propertiesPathPool[$property] = new PropertyPath($property);
384 9
            }
385 18
            $propertyPath = $this->propertiesPathPool[$property];
386
387
            // simple case : access property
388 18
            if (!$reflection->hasMethod($setter = sprintf('set%s', ucfirst($property)))) {
389 8
                $write($propertyPath, $value, $this->propertyAccessor);
390 6
                continue;
391
            }
392
393
            // extract setter class from type hinting
394 16
            $reflectionMethod = $reflection->getMethod($setter);
395 16
            $parameters = $reflectionMethod->getParameters();
396 16
            $setParameter = $parameters[0];
397
398
            // scalar or array ?
0 ignored issues
show
Unused Code Comprehensibility introduced by
38% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
399 16
            if (!$setParameter->getClass() || $setParameter->isArray()) {
400 16
                $write($propertyPath, $value, $this->propertyAccessor);
401
402 16
                continue;
403
            }
404
405
            // nullable object ?
406 6
            if (empty($value)) {
407
                if ($setParameter->allowsNull()) {
408
                    $write($propertyPath, null, $this->propertyAccessor);
409
                }
410
411
                continue;
412
            }
413
414
            // callable ?
415 6
            if (is_callable($value)) {
416
                if ($setParameter->isCallable()) {
417
                    $write($propertyPath, $value, $this->propertyAccessor);
418
                }
419
            }
420
421 6
            $write(
422 3
                $propertyPath,
423 6
                $this->denormalize($value, $setParameter->getClass()),
424 6
                $this->propertyAccessor
425 3
            );
426 8
        }
427
428 16
        return $object;
429
    }
430
}
431