Passed
Push — master ( 829500...1175ab )
by Kevin
08:08
created

Instantiator::propertyForAttributeName()   A

Complexity

Conditions 6
Paths 6

Size

Total Lines 18
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 6

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 8
c 1
b 0
f 0
nc 6
nop 2
dl 0
loc 18
ccs 9
cts 9
cp 1
crap 6
rs 9.2222
1
<?php
2
3
namespace Zenstruck\Foundry;
4
5
/**
6
 * @author Kevin Bond <[email protected]>
7
 */
8
final class Instantiator
9
{
10
    private const MODE_CONSTRUCTOR_AND_PROPERTIES = 1;
11
    private const MODE_ONLY_CONSTRUCTOR = 2;
12
    private const MODE_ONLY_PROPERTIES = 3;
13
14
    private int $mode;
15
    private bool $strict = false;
16
17 64
    private function __construct(int $mode)
18
    {
19 64
        $this->mode = $mode;
20 64
    }
21
22 98
    public function __invoke(array $attributes, string $class): object
23
    {
24 98
        $object = $this->instantiate($class, $attributes);
25
26 94
        if (self::MODE_ONLY_CONSTRUCTOR === $this->mode) {
27 2
            return $object;
28
        }
29
30 92
        foreach ($attributes as $name => $value) {
31 60
            $this->forceSet($object, $name, $value);
32
        }
33
34 90
        return $object;
35
    }
36
37
    /**
38
     * This mode instantiates the object with the given attributes as constructor arguments, then
39
     * sets the remaining attributes to properties (public and private).
40
     */
41 58
    public static function default(): self
42
    {
43 58
        return new self(self::MODE_CONSTRUCTOR_AND_PROPERTIES);
44
    }
45
46
    /**
47
     * This mode only instantiates the object with the given attributes as constructor arguments.
48
     */
49 4
    public static function onlyConstructor(): self
50
    {
51 4
        return new self(self::MODE_ONLY_CONSTRUCTOR);
52
    }
53
54
    /**
55
     * This mode instantiates the object without calling the constructor, then sets the attributes to
56
     * properties (public and private).
57
     */
58 2
    public static function withoutConstructor(): self
59
    {
60 2
        return new self(self::MODE_ONLY_PROPERTIES);
61
    }
62
63
    /**
64
     * Throws \InvalidArgumentException for attributes passed that don't exist.
65
     */
66 10
    public function strict(): self
67
    {
68 10
        $this->strict = true;
69
70 10
        return $this;
71
    }
72
73 66
    public function forceSet(object $object, string $property, $value): void
74
    {
75 66
        $property = $this->propertyForAttributeName($object, $property);
76
77 62
        if (!$property) {
78 4
            return;
79
        }
80
81 60
        $property->setValue($object, $value);
82 60
    }
83
84
    /**
85
     * @return mixed|null
86
     *
87
     * @throws \InvalidArgumentException if $strict = true
88
     */
89 6
    public function forceGet(object $object, string $property)
90
    {
91 6
        $property = $this->propertyForAttributeName($object, $property);
92
93 4
        return $property ? $property->getValue($object) : null;
94
    }
95
96
    /**
97
     * Check if property exists for passed $name - if not, try camel-casing the name.
98
     */
99 70
    private function propertyForAttributeName(object $object, string $name): ?\ReflectionProperty
100
    {
101 70
        $class = new \ReflectionClass($object);
102
103
        // try fetching first by exact name, if not found, try camel-case
104 70
        if (!$property = self::getReflectionProperty($class, $name)) {
105 20
            $property = self::getReflectionProperty($class, self::camel($name));
106
        }
107
108 70
        if (!$property && $this->strict) {
109 6
            throw new \InvalidArgumentException(\sprintf('Class "%s" does not have property "%s".', $class->getName(), $name));
110
        }
111
112 64
        if ($property && !$property->isPublic()) {
113 62
            $property->setAccessible(true);
114
        }
115
116 64
        return $property;
117
    }
118
119 70
    private static function getReflectionProperty(\ReflectionClass $class, string $name): ?\ReflectionProperty
120
    {
121
        try {
122 70
            return $class->getProperty($name);
123 24
        } catch (\ReflectionException $e) {
124 24
            if ($class = $class->getParentClass()) {
125 20
                return self::getReflectionProperty($class, $name);
126
            }
127
        }
128
129 20
        return null;
130
    }
131
132 98
    private function instantiate(string $class, array &$attributes): object
133
    {
134 98
        $class = new \ReflectionClass($class);
135 98
        $constructor = $class->getConstructor();
136
137 98
        if (self::MODE_ONLY_PROPERTIES === $this->mode || !$constructor || !$constructor->isPublic()) {
138 50
            return $class->newInstanceWithoutConstructor();
139
        }
140
141 68
        $arguments = [];
142
143 68
        foreach ($constructor->getParameters() as $parameter) {
144 68
            $name = self::attributeNameForParameter($parameter, $attributes);
145
146 68
            if (\array_key_exists($name, $attributes)) {
147 68
                $arguments[] = $attributes[$name];
148 60
            } elseif ($parameter->isDefaultValueAvailable()) {
149 56
                $arguments[] = $parameter->getDefaultValue();
150
            } else {
151 4
                throw new \InvalidArgumentException(\sprintf('Missing constructor argument "%s" for "%s".', $parameter->getName(), $class->getName()));
152
            }
153
154
            // unset attribute so it isn't used when setting object properties
155 68
            unset($attributes[$name]);
156
        }
157
158 64
        return $class->newInstance(...$arguments);
159
    }
160
161
    /**
162
     * Check if parameter value was passed as the exact name - if not, try snake-cased, then kebab-cased versions.
163
     */
164 68
    private static function attributeNameForParameter(\ReflectionParameter $parameter, array $attributes): ?string
165
    {
166
        // try exact
167 68
        $name = $parameter->getName();
168
169 68
        if (\array_key_exists($name, $attributes)) {
170 68
            return $name;
171
        }
172
173
        // try snake case
174 64
        $name = self::snake($name);
175
176 64
        if (\array_key_exists($name, $attributes)) {
177 2
            return $name;
178
        }
179
180
        // try kebab case
181 62
        $name = \str_replace('_', '-', $name);
182
183 62
        if (\array_key_exists($name, $attributes)) {
184 2
            return $name;
185
        }
186
187 60
        return null;
188
    }
189
190
    /**
191
     * @see https://github.com/symfony/symfony/blob/a73523b065221b6b93cd45bf1cc7c59e7eb2dcdf/src/Symfony/Component/String/AbstractUnicodeString.php#L156
192
     *
193
     * @todo use Symfony/String once stable
194
     */
195 72
    private static function camel(string $string): string
196
    {
197
        return \str_replace(' ', '', \preg_replace_callback('/\b./u', static function($m) use (&$i) {
198 72
            return 1 === ++$i ? ('İ' === $m[0] ? 'i̇' : \mb_strtolower($m[0], 'UTF-8')) : \mb_convert_case($m[0], MB_CASE_TITLE, 'UTF-8');
199 72
        }, \preg_replace('/[^\pL0-9]++/u', ' ', $string)));
200
    }
201
202
    /**
203
     * @see https://github.com/symfony/symfony/blob/a73523b065221b6b93cd45bf1cc7c59e7eb2dcdf/src/Symfony/Component/String/AbstractUnicodeString.php#L361
204
     *
205
     * @todo use Symfony/String once stable
206
     */
207 64
    private static function snake(string $string): string
208
    {
209 64
        $string = self::camel($string);
210
211
        /**
212
         * @see https://github.com/symfony/symfony/blob/a73523b065221b6b93cd45bf1cc7c59e7eb2dcdf/src/Symfony/Component/String/AbstractUnicodeString.php#L369
213
         */
214
        $string = \preg_replace_callback('/\b./u', static function(array $m): string {
215 64
            return \mb_convert_case($m[0], MB_CASE_TITLE, 'UTF-8');
216 64
        }, $string, 1);
217
218 64
        return \mb_strtolower(\preg_replace(['/(\p{Lu}+)(\p{Lu}\p{Ll})/u', '/([\p{Ll}0-9])(\p{Lu})/u'], '\1_\2', $string), 'UTF-8');
219
    }
220
}
221