Passed
Pull Request — master (#1)
by Kevin
02:38
created

Instantiator::__invoke()   B

Complexity

Conditions 10
Paths 10

Size

Total Lines 42
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 10

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 10
eloc 23
nc 10
nop 2
dl 0
loc 42
rs 7.6666
c 1
b 0
f 0
ccs 21
cts 21
cp 1
crap 10

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace Zenstruck\Foundry;
4
5
use Symfony\Component\PropertyAccess\Exception\ExceptionInterface as PropertyAccessException;
6
use Symfony\Component\PropertyAccess\PropertyAccess;
7
use Symfony\Component\PropertyAccess\PropertyAccessor;
8
9
/**
10
 * @author Kevin Bond <[email protected]>
11
 */
12
final class Instantiator
13
{
14
    /** @var PropertyAccessor|null */
15
    private static $propertyAccessor;
16
17
    /** @var bool */
18
    private $withoutConstructor = false;
19
20
    /** @var bool */
21
    private $allowExtraAttributes = false;
22
23
    /** @var bool */
24
    private $alwaysForceProperties = false;
25
26 314
    public function __invoke(array $attributes, string $class): object
27
    {
28 314
        $object = $this->instantiate($class, $attributes);
29
30 310
        foreach ($attributes as $attribute => $value) {
31 254
            if (0 === \mb_strpos($attribute, 'optional:')) {
32 8
                continue;
33
            }
34
35 250
            if ($this->alwaysForceProperties) {
36
                try {
37 28
                    self::forceSet($object, $attribute, $value);
38 8
                } catch (\InvalidArgumentException $e) {
39 8
                    if (!$this->allowExtraAttributes) {
40 4
                        throw $e;
41
                    }
42
                }
43
44 24
                continue;
45
            }
46
47 222
            if (0 === \mb_strpos($attribute, 'force:')) {
48 16
                self::forceSet($object, \mb_substr($attribute, 6), $value);
49
50 12
                continue;
51
            }
52
53
            try {
54 218
                self::propertyAccessor()->setValue($object, $attribute, $value);
55 24
            } catch (PropertyAccessException $e) {
56
                // see if attribute was snake/kebab cased
57
                try {
58 24
                    self::propertyAccessor()->setValue($object, self::camel($attribute), $value);
59 8
                } catch (PropertyAccessException $e) {
60 8
                    if (!$this->allowExtraAttributes) {
61 4
                        throw new \InvalidArgumentException(\sprintf('Cannot set attribute "%s" for object "%s" (not public and no setter).', $attribute, $class), 0, $e);
62
                    }
63
                }
64
            }
65
        }
66
67 298
        return $object;
68
    }
69
70
    /**
71
     * Instantiate objects without calling the constructor.
72
     */
73 4
    public function withoutConstructor(): self
74
    {
75 4
        $this->withoutConstructor = true;
76
77 4
        return $this;
78
    }
79
80
    /**
81
     * Ignore attributes that can't be set to object.
82
     */
83 8
    public function allowExtraAttributes(): self
84
    {
85 8
        $this->allowExtraAttributes = true;
86
87 8
        return $this;
88
    }
89
90
    /**
91
     * Always force properties, never use setters (still uses constructor unless disabled).
92
     */
93 28
    public function alwaysForceProperties(): self
94
    {
95 28
        $this->alwaysForceProperties = true;
96
97 28
        return $this;
98
    }
99
100
    /**
101
     * @param mixed $value
102
     *
103
     * @throws \InvalidArgumentException if property does not exist for $object
104
     */
105 64
    public static function forceSet(object $object, string $property, $value): void
106
    {
107 64
        self::accessibleProperty($object, $property)->setValue($object, $value);
108 52
    }
109
110
    /**
111
     * @return mixed
112
     */
113 16
    public static function forceGet(object $object, string $property)
114
    {
115 16
        return self::accessibleProperty($object, $property)->getValue($object);
116
    }
117
118 218
    private static function propertyAccessor(): PropertyAccessor
119
    {
120 218
        return self::$propertyAccessor ?: self::$propertyAccessor = PropertyAccess::createPropertyAccessor();
121
    }
122
123 68
    private static function accessibleProperty(object $object, string $name): \ReflectionProperty
124
    {
125 68
        $class = new \ReflectionClass($object);
126
127
        // try fetching first by exact name, if not found, try camel-case
128 68
        if (!$property = self::reflectionProperty($class, $name)) {
129 40
            $property = self::reflectionProperty($class, self::camel($name));
130
        }
131
132 68
        if (!$property) {
133 20
            throw new \InvalidArgumentException(\sprintf('Class "%s" does not have property "%s".', $class->getName(), $name));
134
        }
135
136 52
        if (!$property->isPublic()) {
137 20
            $property->setAccessible(true);
138
        }
139
140 52
        return $property;
141
    }
142
143 68
    private static function reflectionProperty(\ReflectionClass $class, string $name): ?\ReflectionProperty
144
    {
145
        try {
146 68
            return $class->getProperty($name);
147 44
        } catch (\ReflectionException $e) {
148 44
            if ($class = $class->getParentClass()) {
149 4
                return self::reflectionProperty($class, $name);
150
            }
151
        }
152
153 40
        return null;
154
    }
155
156
    /**
157
     * Check if parameter value was passed as the exact name - if not, try snake-cased, then kebab-cased versions.
158
     */
159 194
    private static function attributeNameForParameter(\ReflectionParameter $parameter, array $attributes): ?string
160
    {
161
        // try exact
162 194
        $name = $parameter->getName();
163
164 194
        if (\array_key_exists($name, $attributes)) {
165 166
            return $name;
166
        }
167
168
        // try snake case
169 178
        $name = self::snake($name);
170
171 178
        if (\array_key_exists($name, $attributes)) {
172 12
            return $name;
173
        }
174
175
        // try kebab case
176 166
        $name = \str_replace('_', '-', $name);
177
178 166
        if (\array_key_exists($name, $attributes)) {
179 12
            return $name;
180
        }
181
182 154
        return null;
183
    }
184
185
    /**
186
     * @see https://github.com/symfony/symfony/blob/a73523b065221b6b93cd45bf1cc7c59e7eb2dcdf/src/Symfony/Component/String/AbstractUnicodeString.php#L156
187
     *
188
     * @todo use Symfony/String once stable
189
     */
190 190
    private static function camel(string $string): string
191
    {
192
        return \str_replace(' ', '', \preg_replace_callback('/\b./u', static function($m) use (&$i) {
193 190
            return 1 === ++$i ? ('İ' === $m[0] ? 'i̇' : \mb_strtolower($m[0], 'UTF-8')) : \mb_convert_case($m[0], MB_CASE_TITLE, 'UTF-8');
194 190
        }, \preg_replace('/[^\pL0-9]++/u', ' ', $string)));
195
    }
196
197
    /**
198
     * @see https://github.com/symfony/symfony/blob/a73523b065221b6b93cd45bf1cc7c59e7eb2dcdf/src/Symfony/Component/String/AbstractUnicodeString.php#L361
199
     *
200
     * @todo use Symfony/String once stable
201
     */
202 178
    private static function snake(string $string): string
203
    {
204 178
        $string = self::camel($string);
205
206
        /**
207
         * @see https://github.com/symfony/symfony/blob/a73523b065221b6b93cd45bf1cc7c59e7eb2dcdf/src/Symfony/Component/String/AbstractUnicodeString.php#L369
208
         */
209
        $string = \preg_replace_callback('/\b./u', static function(array $m): string {
210 178
            return \mb_convert_case($m[0], MB_CASE_TITLE, 'UTF-8');
211 178
        }, $string, 1);
212
213 178
        return \mb_strtolower(\preg_replace(['/(\p{Lu}+)(\p{Lu}\p{Ll})/u', '/([\p{Ll}0-9])(\p{Lu})/u'], '\1_\2', $string), 'UTF-8');
214
    }
215
216 314
    private function instantiate(string $class, array &$attributes): object
217
    {
218 314
        $class = new \ReflectionClass($class);
219 314
        $constructor = $class->getConstructor();
220
221 314
        if ($this->withoutConstructor || !$constructor || !$constructor->isPublic()) {
222 8
            return $class->newInstanceWithoutConstructor();
223
        }
224
225 306
        $arguments = [];
226
227 306
        foreach ($constructor->getParameters() as $parameter) {
228 194
            $name = self::attributeNameForParameter($parameter, $attributes);
229
230 194
            if (\array_key_exists($name, $attributes)) {
231 190
                $arguments[] = $attributes[$name];
232 154
            } elseif ($parameter->isDefaultValueAvailable()) {
233 150
                $arguments[] = $parameter->getDefaultValue();
234
            } else {
235 4
                throw new \InvalidArgumentException(\sprintf('Missing constructor argument "%s" for "%s".', $parameter->getName(), $class->getName()));
236
            }
237
238
            // unset attribute so it isn't used when setting object properties
239 190
            unset($attributes[$name]);
240
        }
241
242 302
        return $class->newInstance(...$arguments);
243
    }
244
}
245