Completed
Pull Request — master (#119)
by
unknown
24:34
created

generatePropertyHydrateCall()   A

Complexity

Conditions 4
Paths 2

Size

Total Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 4

Importance

Changes 0
Metric Value
dl 0
loc 18
ccs 8
cts 8
cp 1
rs 9.6666
c 0
b 0
f 0
cc 4
nc 2
nop 2
crap 4
1
<?php
2
3
declare(strict_types=1);
4
5
namespace GeneratedHydrator\CodeGenerator\Visitor;
6
7
use PhpParser\Node;
8
use PhpParser\Node\Expr\Array_;
9
use PhpParser\Node\Param;
10
use PhpParser\Node\Stmt\Class_;
11
use PhpParser\Node\Stmt\ClassMethod;
12
use PhpParser\Node\Stmt\Property;
13
use PhpParser\Node\Stmt\PropertyProperty;
14
use PhpParser\NodeVisitorAbstract;
15
use PhpParser\ParserFactory;
16
use ReflectionClass;
17
use ReflectionProperty;
18
use function array_filter;
19
use function array_merge;
20
use function array_values;
21
use function implode;
22
use function reset;
23
use function var_export;
24
25
/**
26
 * Replaces methods `__construct`, `hydrate` and `extract` in the classes of the given AST
27
 *
28
 * @todo as per https://github.com/Ocramius/GeneratedHydrator/pull/59, using a visitor for this is ineffective.
29
 * @todo Instead, we can just create a code generator, since we are not modifying code, but creating it.
30
 */
31
class HydratorMethodsVisitor extends NodeVisitorAbstract
32
{
33
    /** @var string[] */
34
    private $visiblePropertyMap = [];
35
36
    /** @var string[][] */
37
    private $hiddenPropertyMap = [];
38
39
    public function __construct(ReflectionClass $reflectedClass)
40
    {
41
        foreach ($this->findAllInstanceProperties($reflectedClass) as $property) {
42
            $className = $property->getDeclaringClass()->getName();
0 ignored issues
show
introduced by
Consider using $property->class. There is an issue with getName() and APC-enabled PHP versions.
Loading history...
43 2
44
            if ($property->isPrivate() || $property->isProtected()) {
45 2
                $this->hiddenPropertyMap[$className][] = ObjectProperty::fromReflection($property);
46 2
            } else {
47
                $this->visiblePropertyMap[] = ObjectProperty::fromReflection($property);
48 2
            }
49 2
        }
50
    }
51 2
52
    public function leaveNode(Node $node) : ?Class_
53
    {
54 2
        if (! $node instanceof Class_) {
55
            return null;
56
        }
57
58
        $node->stmts[] = new Property(Class_::MODIFIER_PRIVATE, [
59
            new PropertyProperty('hydrateCallbacks', new Array_()),
60
            new PropertyProperty('extractCallbacks', new Array_()),
61 2
        ]);
62
63 2
        $this->replaceConstructor($this->findOrCreateMethod($node, '__construct'));
0 ignored issues
show
Deprecated Code introduced by
The method GeneratedHydrator\CodeGe...r::findOrCreateMethod() has been deprecated with message: not needed if we move away from code replacement

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
64
        $this->replaceHydrate($this->findOrCreateMethod($node, 'hydrate'));
0 ignored issues
show
Deprecated Code introduced by
The method GeneratedHydrator\CodeGe...r::findOrCreateMethod() has been deprecated with message: not needed if we move away from code replacement

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
65
        $this->replaceExtract($this->findOrCreateMethod($node, 'extract'));
0 ignored issues
show
Deprecated Code introduced by
The method GeneratedHydrator\CodeGe...r::findOrCreateMethod() has been deprecated with message: not needed if we move away from code replacement

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
66
67 2
        return $node;
68 2
    }
69 2
70
    /**
71
     * Find all class properties recursively using class hierarchy without
72 2
     * removing name redefinitions
73 2
     *
74 2
     * @return ReflectionProperty[]
75
     */
76 2
    private function findAllInstanceProperties(?ReflectionClass $class = null) : array
77
    {
78
        if (! $class) {
79
            return [];
80
        }
81
82
        return array_values(array_merge(
83
            $this->findAllInstanceProperties($class->getParentClass() ?: null), // of course PHP is shit.
84
            array_values(array_filter(
85
                $class->getProperties(),
86
                static function (ReflectionProperty $property) : bool {
87 2
                    return ! $property->isStatic();
88
                }
89 2
            ))
90 2
        ));
91
    }
92
93 2
    private function generatePropertyHydrateCall(ObjectProperty $property, string $input): array
94 2
    {
95 2
        $ret = [];
96 2
97
        $propertyName = $property->name;
98 2
        $escapedName = \addslashes($propertyName);
99 2
100
        if ($property->type && !$property->required && !$property->hasDefault) {
101
            $ret[] = "\$object->{$propertyName} = {$input}['{$escapedName}'] ?? null;";
102
        } else {
103
            $ret[] = "if (isset(\$values['{$escapedName}']) || \$object->{$propertyName} !== null "
104
                . "&& \\array_key_exists('{$escapedName}', {$input})) {";
105
            $ret[] = "    \$object->{$propertyName} = {$input}['{$escapedName}'];";
106
            $ret[] = '}';
107 2
        }
108
109 2
        return $ret;
110
    }
111 2
112
    private function replaceConstructor(ClassMethod $method) : void
113
    {
114
        $method->params = [];
115
116 2
        $bodyParts = [];
117
118 2
        // Create a set of closures that will be called to hydrate the object.
119 2
        // Array of closures in a naturally indexed array, ordered, which will
120 2
        // then be called in order in the hydrate() and extract() methods.
121 2
        foreach ($this->hiddenPropertyMap as $className => $properties) {
122 2
            // Hydrate closures
123 2
            $bodyParts[] = '$this->hydrateCallbacks[] = \\Closure::bind(static function ($object, $values) {';
124
            foreach ($properties as $property) {
125 2
                \assert($property instanceof ObjectProperty);
126
                $bodyParts = \array_merge($bodyParts, $this->generatePropertyHydrateCall($property, '$values'));
127
            }
128 2
            $bodyParts[] = '}, null, ' . var_export($className, true) . ');' . "\n";
129 2
130 2
            // Extract closures
131
            $bodyParts[] = '$this->extractCallbacks[] = \\Closure::bind(static function ($object, &$values) {';
132 2
            foreach ($properties as $property) {
133
                \assert($property instanceof ObjectProperty);
134
                $propertyName = $property->name;
135 2
                $bodyParts[] = "    \$values['" . $propertyName . "'] = \$object->" . $propertyName . ';';
136 2
            }
137 2
            $bodyParts[] = '}, null, ' . var_export($className, true) . ');' . "\n";
138 2
        }
139
140
        $method->stmts = (new ParserFactory())
141
            ->create(ParserFactory::ONLY_PHP7)
142
            ->parse('<?php ' . implode("\n", $bodyParts));
143 2
    }
144
145 2
    private function replaceHydrate(ClassMethod $method) : void
146 2
    {
147 2
        $method->params = [
148
            new Param(new Node\Expr\Variable('data'), null, 'array'),
149
            new Param(new Node\Expr\Variable('object')),
150 2
        ];
151 2
152 1
        $bodyParts = [];
153 1
        foreach ($this->visiblePropertyMap as $property) {
154 1
            \assert($property instanceof ObjectProperty);
155 1
            $bodyParts = \array_merge($bodyParts, $this->generatePropertyHydrateCall($property, '$data'));
156
        }
157 2
        $index = 0;
158 2
        foreach ($this->hiddenPropertyMap as $className => $propertyNames) {
159 2
            $bodyParts[] = '$this->hydrateCallbacks[' . ($index++) . ']->__invoke($object, $data);';
160
        }
161
162 2
        $bodyParts[] = 'return $object;';
163
164 2
        $method->stmts = (new ParserFactory())
165 2
            ->create(ParserFactory::ONLY_PHP7)
166 2
            ->parse('<?php ' . implode("\n", $bodyParts));
167 2
    }
168
169
    private function replaceExtract(ClassMethod $method) : void
170
    {
171
        $method->params = [new Param(new Node\Expr\Variable('object'))];
172
173
        $bodyParts   = [];
174 2
        $bodyParts[] = '$ret = array();';
175
        foreach ($this->visiblePropertyMap as $property) {
176 2
            \assert($property instanceof ObjectProperty);
177
            $propertyName = $property->name;
178 2
            $bodyParts[] = "\$ret['" . $propertyName . "'] = \$object->" . $propertyName . ';';
179 2
        }
180 2
        $index = 0;
181 1
        foreach ($this->hiddenPropertyMap as $className => $property) {
182
            $bodyParts[] = '$this->extractCallbacks[' . ($index++) . ']->__invoke($object, $ret);';
183 2
        }
184 2
185 2
        $bodyParts[] = 'return $ret;';
186
187
        $method->stmts = (new ParserFactory())
188 2
            ->create(ParserFactory::ONLY_PHP7)
189
            ->parse('<?php ' . implode("\n", $bodyParts));
190 2
    }
191 2
192 2
    /**
193 2
     * Finds or creates a class method (and eventually attaches it to the class itself)
194
     *
195
     * @deprecated not needed if we move away from code replacement
196
     */
197
    private function findOrCreateMethod(Class_ $class, string $name) : ClassMethod
198
    {
199
        $foundMethods = array_filter(
200
            $class->getMethods(),
201
            static function (ClassMethod $method) use ($name) : bool {
202
                return $name === $method->name;
203
            }
204
        );
205 2
206
        $method = reset($foundMethods);
207 2
208 2
        if (! $method) {
209
            $class->stmts[] = $method = new ClassMethod($name);
210 2
        }
211 2
212
        return $method;
213
    }
214 2
}
215
216 2
/**
217 2
 * @internal
218
 */
219
final class ObjectProperty
220 2
{
221
    /** @var ?string */
222
    public $type = null;
223
224
    /** @var bool */
225
    public $hasDefault = false;
226
227
    /** @var ?string */
228
    public $required = false;
229
230
    /** @var string  */
231
    public $name;
232
233
    private function __construct(string $name, ?string $type = null, bool $required = false, bool $hasDefault = false)
234
    {
235
        $this->name = $name;
236
        $this->type = $type;
237
        $this->required = $required;
238
        $this->hasDefault = $hasDefault;
239
    }
240
241
    /**
242
     * Create instance from reflection object
243
     */
244
    public static function fromReflection(\ReflectionProperty $property)
245
    {
246
        $propertyName = $property->getName();
247
248
        if (0 <= \version_compare(PHP_VERSION, '7.4.0') && ($type = $property->getType())) {
0 ignored issues
show
Bug introduced by
It seems like you code against a specific sub-type and not the parent class ReflectionProperty as the method getType() does only exist in the following sub-classes of ReflectionProperty: Roave\BetterReflection\R...pter\ReflectionProperty. Maybe you want to instanceof check for one of these explicitly?

Let’s take a look at an example:

abstract class User
{
    /** @return string */
    abstract public function getPassword();
}

class MyUser extends User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different sub-classes of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the parent class:

    abstract class User
    {
        /** @return string */
        abstract public function getPassword();
    
        /** @return string */
        abstract public function getDisplayName();
    }
    
Loading history...
249
            // Check if property have a default value. It seems there is no
250
            // other way, it probably will create a confusion between properties
251
            // defaulting to null and those who will remain unitilialized.
252
            $defaults = $property->getDeclaringClass()->getDefaultProperties();
253
254
            return new self(
255
                $propertyName,
256
                $type->getName(),
257
                !$type->allowsNull(),
258
                isset($defaults[$propertyName])
259
            );
260
        }
261
262
        return new self($propertyName);
263
    }
264
}
265