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