Instantiator::attributeNameForParameter()   A
last analyzed

Complexity

Conditions 4
Paths 4

Size

Total Lines 28
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 4

Importance

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