Passed
Pull Request — master (#81)
by Kevin
17:06
created

Instantiator::withoutConstructor()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 2
c 1
b 0
f 0
dl 0
loc 5
rs 10
ccs 1
cts 1
cp 1
cc 1
nc 1
nop 0
crap 1
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 a differently cased attribute 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
        if (!$property && $property = self::reflectionProperty($class, self::camel($name))) {
157
            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 654
160
        if (!$property) {
161
            throw new \InvalidArgumentException(\sprintf('Class "%s" does not have property "%s".', $class->getName(), $name));
162 654
        }
163
164 654
        if (!$property->isPublic()) {
165 584
            $property->setAccessible(true);
166
        }
167
168
        return $property;
169 614
    }
170
171 614
    private static function reflectionProperty(\ReflectionClass $class, string $name): ?\ReflectionProperty
172 40
    {
173
        try {
174
            return $class->getProperty($name);
175
        } catch (\ReflectionException $e) {
176 574
            if ($class = $class->getParentClass()) {
177
                return self::reflectionProperty($class, $name);
178 574
            }
179 30
        }
180
181
        return null;
182 544
    }
183
184
    /**
185
     * Check if parameter value was passed as the exact name - if not, try snake-cased, then kebab-cased versions.
186
     */
187
    private static function attributeNameForParameter(\ReflectionParameter $parameter, array $attributes): ?string
188
    {
189
        // try exact
190 644
        $name = $parameter->getName();
191
192
        if (\array_key_exists($name, $attributes)) {
193 644
            return $name;
194 644
        }
195
196
        // try snake case
197
        $name = self::snake($name);
198
199
        if (\array_key_exists($name, $attributes)) {
200
            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 614
            return $name;
203
        }
204 614
205
        // try kebab case
206
        $name = \str_replace('_', '-', $name);
207
208
        if (\array_key_exists($name, $attributes)) {
209
            trigger_deprecation('zenstruck\foundry', '1.5.0', 'Using a differently cased attribute is deprecated, use the same case as the object property instead.');
210 614
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