Passed
Pull Request — master (#81)
by Kevin
32:57 queued 13:01
created

Instantiator::alwaysForceProperties()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 2

Importance

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