Passed
Pull Request — master (#81)
by Kevin
18:35
created

Instantiator   B

Complexity

Total Complexity 44

Size/Duplication

Total Lines 263
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
wmc 44
eloc 98
c 2
b 0
f 0
dl 0
loc 263
rs 8.8798
ccs 86
cts 86
cp 1

13 Methods

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