Passed
Pull Request — master (#127)
by Wouter
02:51
created

Instantiator::withProxyGenerator()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 2
c 0
b 0
f 0
dl 0
loc 5
rs 10
ccs 2
cts 2
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
    /** @var bool */
27
    private $alwaysForceProperties = false;
28
29
    /** @var array */
30
    private $forceProperties = [];
31
32 1033
    /** @var ProxyGenerator|null */
33
    private $proxyGenerator;
34 1033
35
    public function __invoke(array $attributes, string $class): object
36 1023
    {
37 796
        $object = $this->instantiate($class, $attributes);
38 20
39 20
        foreach ($attributes as $attribute => $value) {
40
            if (0 === \mb_strpos($attribute, 'optional:')) {
41
                trigger_deprecation('zenstruck\foundry', '1.5.0', 'Using "optional:" attribute prefixes is deprecated, use Instantiator::allowExtraAttributes() instead (https://github.com/zenstruck/foundry#instantiation).');
42 786
                continue;
43 20
            }
44
45
            if (\in_array($attribute, $this->extraAttributes, true)) {
46 776
                continue;
47
            }
48 80
49 20
            if ($this->alwaysForceProperties || \in_array($attribute, $this->forceProperties, true)) {
50 20
                try {
51 10
                    self::forceSet($object, $attribute, $value);
52
                } catch (\InvalidArgumentException $e) {
53
                    if (!$this->allowExtraAttributes) {
54
                        throw $e;
55 70
                    }
56
                }
57
58 706
                continue;
59 40
            }
60
61 40
            if (0 === \mb_strpos($attribute, 'force:')) {
62
                trigger_deprecation('zenstruck\foundry', '1.5.0', 'Using "force:" property prefixes is deprecated, use Instantiator::alwaysForceProperties() instead (https://github.com/zenstruck/foundry#instantiation).');
63 30
64
                self::forceSet($object, \mb_substr($attribute, 6), $value);
65
66
                continue;
67 696
            }
68 80
69
            try {
70
                self::propertyAccessor()->setValue($object, $attribute, $value);
71 70
            } catch (NoSuchPropertyException $e) {
72 40
                // see if attribute was snake/kebab cased
73 30
                try {
74 30
                    self::propertyAccessor()->setValue($object, self::camel($attribute), $value);
75 20
                    trigger_deprecation('zenstruck\foundry', '1.5.0', 'Using a differently cased attribute is deprecated, use the same case as the object property instead.');
76
                } catch (NoSuchPropertyException $e) {
77
                    if (!$this->allowExtraAttributes) {
78
                        throw new \InvalidArgumentException(\sprintf('Cannot set attribute "%s" for object "%s" (not public and no setter).', $attribute, $class), 0, $e);
79
                    }
80
                }
81 973
            }
82
        }
83
84
        return $object;
85
    }
86
87 20
    /**
88
     * Instantiate objects without calling the constructor.
89 20
     */
90
    public function withoutConstructor(): self
91 20
    {
92
        $this->withoutConstructor = true;
93
94
        return $this;
95
    }
96
97
    /**
98
     * Ignore attributes that can't be set to object.
99 50
     *
100
     * @param string[] $attributes The attributes you'd like the instantiator to ignore (if empty, ignore any extra)
101 50
     */
102 30
    public function allowExtraAttributes(array $attributes = []): self
103
    {
104
        if (empty($attributes)) {
105 50
            $this->allowExtraAttributes = true;
106
        }
107 50
108
        $this->extraAttributes = $attributes;
109
110
        return $this;
111
    }
112
113
    /**
114
     * Always force properties, never use setters (still uses constructor unless disabled).
115 80
     *
116
     * @param string[] $properties The properties you'd like the instantiator to "force set" (if empty, force set all)
117 80
     */
118 70
    public function alwaysForceProperties(array $properties = []): self
119
    {
120
        if (empty($properties)) {
121 80
            $this->alwaysForceProperties = true;
122
        }
123 80
124
        $this->forceProperties = $properties;
125
126
        return $this;
127
    }
128
129
    /**
130
     * Instead of returning an instance of {@see Proxy}, use a generator to create a real proxy for the object.
131 230
     */
132
    public function withProxyGenerator(): self
133 230
    {
134 200
        $this->proxyGenerator = new ProxyGenerator();
0 ignored issues
show
Bug introduced by
The call to Zenstruck\Foundry\ProxyGenerator::__construct() has too few arguments starting with configuration. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

134
        $this->proxyGenerator = /** @scrutinizer ignore-call */ new ProxyGenerator();

This check compares calls to functions or methods with their respective definitions. If the call has less arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
135
136
        return $this;
137
    }
138
139 50
    /**
140
     * @param mixed $value
141 50
     *
142
     * @throws \InvalidArgumentException if property does not exist for $object
143
     */
144 696
    public static function forceSet(object $object, string $property, $value): void
145
    {
146 696
        self::accessibleProperty($object, $property)->setValue($object, $value);
147
    }
148
149 240
    /**
150
     * @return mixed
151 240
     */
152
    public static function forceGet(object $object, string $property)
153
    {
154 240
        return self::accessibleProperty($object, $property)->getValue($object);
155
    }
156 240
157 50
    private static function propertyAccessor(): PropertyAccessor
158
    {
159
        return self::$propertyAccessor ?: self::$propertyAccessor = PropertyAccess::createPropertyAccessor();
160 240
    }
161 50
162
    private static function accessibleProperty(object $object, string $name): \ReflectionProperty
163
    {
164 200
        $class = new \ReflectionClass($object);
165 110
166
        // try fetching first by exact name, if not found, try camel-case
167
        $property = self::reflectionProperty($class, $name);
168 200
169
        if (!$property && $property = self::reflectionProperty($class, self::camel($name))) {
170
            trigger_deprecation('zenstruck\foundry', '1.5.0', 'Using a differently cased attribute is deprecated, use the same case as the object property instead.');
171 240
        }
172
173
        if (!$property) {
174 240
            throw new \InvalidArgumentException(\sprintf('Class "%s" does not have property "%s".', $class->getName(), $name));
175 110
        }
176 110
177 10
        if (!$property->isPublic()) {
178
            $property->setAccessible(true);
179
        }
180
181 100
        return $property;
182
    }
183
184
    private static function reflectionProperty(\ReflectionClass $class, string $name): ?\ReflectionProperty
185
    {
186
        try {
187 674
            return $class->getProperty($name);
188
        } catch (\ReflectionException $e) {
189
            if ($class = $class->getParentClass()) {
190 674
                return self::reflectionProperty($class, $name);
191
            }
192 674
        }
193 604
194
        return null;
195
    }
196
197 624
    /**
198
     * Check if parameter value was passed as the exact name - if not, try snake-cased, then kebab-cased versions.
199 624
     */
200 30
    private static function attributeNameForParameter(\ReflectionParameter $parameter, array $attributes): ?string
201
    {
202 30
        // try exact
203
        $name = $parameter->getName();
204
205
        if (\array_key_exists($name, $attributes)) {
206 594
            return $name;
207
        }
208 594
209 30
        // try snake case
210
        $name = self::snake($name);
211 30
212
        if (\array_key_exists($name, $attributes)) {
213
            trigger_deprecation('zenstruck\foundry', '1.5.0', 'Using a differently cased attribute is deprecated, use the same case as the object property instead.');
214 564
215
            return $name;
216
        }
217
218
        // try kebab case
219
        $name = \str_replace('_', '-', $name);
220
221
        if (\array_key_exists($name, $attributes)) {
222 654
            trigger_deprecation('zenstruck\foundry', '1.5.0', 'Using a differently cased attribute is deprecated, use the same case as the object property instead.');
223
224
            return $name;
225 654
        }
226 654
227
        return null;
228
    }
229
230
    /**
231
     * @see https://github.com/symfony/symfony/blob/a73523b065221b6b93cd45bf1cc7c59e7eb2dcdf/src/Symfony/Component/String/AbstractUnicodeString.php#L156
232
     *
233
     * @todo use Symfony/String once stable
234 624
     */
235
    private static function camel(string $string): string
236 624
    {
237
        return \str_replace(' ', '', \preg_replace_callback('/\b./u', static function($m) use (&$i): string {
238
            return 1 === ++$i ? ('İ' === $m[0] ? 'i̇' : \mb_strtolower($m[0], 'UTF-8')) : \mb_convert_case($m[0], \MB_CASE_TITLE, 'UTF-8');
239
        }, \preg_replace('/[^\pL0-9]++/u', ' ', $string)));
240
    }
241
242 624
    /**
243 624
     * @see https://github.com/symfony/symfony/blob/a73523b065221b6b93cd45bf1cc7c59e7eb2dcdf/src/Symfony/Component/String/AbstractUnicodeString.php#L361
244
     *
245 624
     * @todo use Symfony/String once stable
246
     */
247
    private static function snake(string $string): string
248 1033
    {
249
        $string = self::camel($string);
250 1033
251 1033
        /**
252
         * @see https://github.com/symfony/symfony/blob/a73523b065221b6b93cd45bf1cc7c59e7eb2dcdf/src/Symfony/Component/String/AbstractUnicodeString.php#L369
253 1033
         */
254 60
        $string = \preg_replace_callback('/\b./u', static function(array $m): string {
255
            return \mb_convert_case($m[0], \MB_CASE_TITLE, 'UTF-8');
256
        }, $string, 1);
257 1003
258
        return \mb_strtolower(\preg_replace(['/(\p{Lu}+)(\p{Lu}\p{Ll})/u', '/([\p{Ll}0-9])(\p{Lu})/u'], '\1_\2', $string), 'UTF-8');
259 1003
    }
260 674
261
    private function instantiate(string $class, array &$attributes): object
262 674
    {
263 664
        $class = new \ReflectionClass($class);
264 564
        $constructor = $class->getConstructor();
265 554
266
        if ($this->withoutConstructor || !$constructor || !$constructor->isPublic()) {
267 10
            return $class->newInstanceWithoutConstructor();
268
        }
269
270
        $arguments = [];
271 664
272
        foreach ($constructor->getParameters() as $parameter) {
273
            $name = self::attributeNameForParameter($parameter, $attributes);
274 993
275
            if (\array_key_exists($name, $attributes)) {
276
                $arguments[] = $attributes[$name];
277
            } elseif ($parameter->isDefaultValueAvailable()) {
278
                $arguments[] = $parameter->getDefaultValue();
279
            } else {
280
                throw new \InvalidArgumentException(\sprintf('Missing constructor argument "%s" for "%s".', $parameter->getName(), $class->getName()));
281
            }
282
283
            // unset attribute so it isn't used when setting object properties
284
            unset($attributes[$name]);
285
        }
286
287
        return $class->newInstance(...$arguments);
288
    }
289
}
290