Passed
Pull Request — master (#77)
by Wouter
16:40
created

Instantiator   B

Complexity

Total Complexity 43

Size/Duplication

Total Lines 246
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
wmc 43
eloc 91
c 1
b 0
f 0
dl 0
loc 246
ccs 86
cts 86
cp 1
rs 8.96

14 Methods

Rating   Name   Duplication   Size   Complexity  
A alwaysForceProperties() 0 5 1
A withoutConstructor() 0 5 1
B __invoke() 0 42 10
A allowExtraAttributes() 0 5 1
A forceSet() 0 3 1
A forceGet() 0 3 1
A attributeNameForParameter() 0 24 4
A propertyAccessor() 0 3 2
A getFindCriteriaFromAttributes() 0 13 4
A accessibleProperty() 0 18 4
B instantiate() 0 27 7
A snake() 0 12 1
A reflectionProperty() 0 11 3
A camel() 0 5 3

How to fix   Complexity   

Complex Class

Complex classes like Instantiator often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Instantiator, and based on these observations, apply Extract Interface, too.

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