Failed Conditions
Push — master ( 6247ce...33cd17 )
by Arnold
02:45
created

DotKey::splitPath()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 4
nc 2
nop 2
dl 0
loc 10
ccs 5
cts 5
cp 1
crap 2
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Jasny\DotKey;
6
7
use Throwable;
8
9
/**
10
 * Access objects and arrays through dot notation.
11
 *
12
 * @template TSubject
13
 */
14
class DotKey
15
{
16
    /**
17
     * @var object|array<string,mixed>
18
     * @phpstan-var TSubject&(object|array<string,mixed>)
19
     */
20
    protected $subject;
21
22
    /**
23
     * Class constructor.
24
     *
25
     * @param object|array $subject
26
     *
27
     * @phpstan-param TSubject&(object|array<string,mixed>) $subject
28
     */
29 109
    public function __construct($subject)
30
    {
31 109
        if (!\is_object($subject) && !\is_array($subject)) {
1 ignored issue
show
introduced by
The condition is_array($subject) is always true.
Loading history...
32 1
            $type = \gettype($subject);
33 1
            throw new \InvalidArgumentException("Subject should be an array or object; $type given");
34
        }
35
        
36 108
        $this->subject = $subject;
37 108
    }
38
39
    
40
    /**
41
     * Check if path exists in subject.
42
     */
43 10
    public function exists(string $path, string $delimiter = '.'): bool
44
    {
45 10
        $subject = $this->subject;
46 10
        $index = $this->splitPath($path, $delimiter);
47
        ;
48
49 10
        foreach ($index as $key) {
50 10
            if (!\is_array($subject) && !\is_object($subject)) {
51 4
                return false;
52
            }
53
54 10
            $subject = $this->descend($subject, $key, $exists, true);
55
56 10
            if (!$exists) {
57 10
                return false;
58
            }
59
        }
60
        
61 10
        return true;
62
    }
63
    
64
    /**
65
     * Get a value from subject by path.
66
     *
67
     * @param string $path
68
     * @param string $delimiter
69
     * @return mixed
70
     * @throws ResolveException
71
     */
72 19
    public function get(string $path, string $delimiter = '.')
73
    {
74 19
        $subject = $this->subject;
75 19
        $index = $this->splitPath($path, $delimiter);
76
77 17
        while ($index !== []) {
78 17
            $key = \array_shift($index);
79
80 17
            if (!\is_array($subject) && !\is_object($subject)) {
81 5
                $msg = "Unable to get '$path': '%s' is of type " . \gettype($subject);
82 5
                throw $this->unresolved($msg, $path, $delimiter, $index, $key);
83
            }
84
85
            try {
86 17
                $subject = $this->descend($subject, $key, $exists);
87 3
            } catch (\Error $error) {
88 3
                $msg = "Unable to get '$path': error at '%s'";
89 3
                throw $this->unresolved($msg, $path, $delimiter, $index, null, $error);
90
            }
91
92 15
            if (!$exists) {
93 9
                return null;
94
            }
95
        }
96
97 9
        return $subject;
98
    }
99
100
    
101
    /**
102
     * Set a value within the subject by path.
103
     *
104
     * @param string $path
105
     * @param mixed  $value
106
     * @param string $delimiter
107
     * @return array|object
108
     * @throws ResolveException
109
     *
110
     * @phpstan-return TSubject&(object|array<string,mixed>)
111
     */
112 23
    public function set(string $path, $value, string $delimiter = '.')
113
    {
114 23
        $result = $this->subject;
115 23
        $subject =& $result;
116
117 23
        $index = $this->splitPath($path, $delimiter);
118
119 22
        while (count($index) > 1) {
120 21
            $key = \array_shift($index);
121
122 21
            if (!\is_array($subject) && !\is_object($subject)) {
123 5
                $msg = "Unable to set '$path': '%s' is of type " . \gettype($subject);
124 5
                throw $this->unresolved($msg, $path, $delimiter, $index, $key);
125
            }
126
127
            try {
128 21
                $subject =& $this->descend($subject, $key, $exists);
129 1
            } catch (\Error $error) {
130 1
                $msg = "Unable to set '$path': error at '%s'";
131 1
                throw $this->unresolved($msg, $path, $delimiter, $index, null, $error);
132
            }
133
134 20
            if (!$exists) {
135 5
                throw $this->unresolved("Unable to set '$path': '%s' doesn't exist", $path, $delimiter, $index);
136
            }
137
        }
138
139 11
        if (\is_array($subject) || $subject instanceof \ArrayAccess) {
140 8
            $subject[$index[0]] = $value;
141
        } else {
142
            try {
143 4
                $subject->{$index[0]} = $value;
144 2
            } catch (\Error $error) {
145 2
                throw new ResolveException("Unable to set '$path': error at '$path'", 0, $error);
146
            }
147
        }
148
149 9
        return $result;
150
    }
151
    
152
    /**
153
     * Set a value, creating a structure if needed.
154
     *
155
     * @param string    $path
156
     * @param mixed     $value
157
     * @param string    $delimiter
158
     * @param bool|null $assoc     Create new structure as array. Omit to base upon subject type.
159
     * @return array|object
160
     * @throws ResolveException
161
     *
162
     * @phpstan-return TSubject&(object|array<string,mixed>)
163
     */
164 30
    public function put(string $path, $value, string $delimiter = '.', ?bool $assoc = null)
165
    {
166 30
        $result = $this->subject;
167 30
        $subject =& $result;
168
169 30
        $index = $this->splitPath($path, $delimiter);
170
171 29
        while (count($index) > 1) {
172 28
            $key = \array_shift($index);
173
174
            try {
175 28
                $subject =& $this->descend($subject, $key, $exists);
176 1
            } catch (\Error $error) {
177 1
                $msg = "Unable to put '$path': error at '%s'";
178 1
                throw $this->unresolved($msg, $path, $delimiter, $index, null, $error);
179
            }
180
181 27
            if (!$exists) {
182 13
                array_unshift($index, $key);
183 13
                break;
184
            }
185
186 27
            if (!\is_array($subject) && !\is_object($subject)) {
187 4
                break;
188
            }
189
        }
190
191
        try {
192 28
            $this->setValueCreate($subject, $index, $value, $assoc);
193 2
        } catch (\Error $error) {
194 2
            $msg = "Unable to put '$path': error at '%s'";
195 2
            throw $this->unresolved($msg, $path, $delimiter, array_slice($index, 0, -1), null, $error);
196
        }
197
198 26
        return $result;
199
    }
200
201
202
    /**
203
     * Create property and set the value.
204
     *
205
     * @param mixed     $subject
206
     * @param string[]  $index    Part or the path that doesn't exist
207
     * @param mixed     $value
208
     * @param bool|null $assoc
209
     */
210 28
    protected function setValueCreate(&$subject, array $index, $value, ?bool $assoc = null): void
211
    {
212 28
        $assoc ??= is_array($this->subject) || $this->subject instanceof \ArrayAccess;
213
214 28
        if (is_array($subject) || $subject instanceof \ArrayAccess) {
215 15
            $key = \array_shift($index);
216 15
            $subject[$key] = null;
217 15
            $subject =& $subject[$key];
218 14
        } elseif (is_object($subject)) {
219 10
            $key = \array_shift($index);
220 10
            $subject->{$key} = null;
221 8
            $subject =& $subject->{$key};
222
        }
223
224 26
        while ($index !== []) {
225 17
            $key = \array_pop($index);
226 17
            $value = $assoc ? [$key => $value] : (object)[$key => $value];
227
        }
228
229 26
        $subject = $value;
230 26
    }
231
232
    /**
233
     * Get a particular value back from the config array
234
     *
235
     * @return object|array<string,mixed>
236
     *
237
     * @phpstan-return TSubject&(object|array<string,mixed>)
238
     */
239 26
    public function remove(string $path, string $delimiter = '.')
240
    {
241 26
        $result = $this->subject;
242 26
        $subject =& $result;
243
244 26
        $index = $this->splitPath($path, $delimiter);
245
246 25
        while (\count($index) > 1) {
247 24
            $key = \array_shift($index);
248
249
            try {
250 24
                $subject =& $this->descend($subject, $key, $exists);
251 1
            } catch (\Error $error) {
252 1
                $msg = "Unable to remove '$path': error at '%s'";
253 1
                throw $this->unresolved($msg, $path, $delimiter, $index, null, $error);
254
            }
255
256 23
            if (!$exists) {
257 4
                return $this->subject;
258
            }
259
260 23
            if (!\is_array($subject) && !\is_object($subject)) {
261 5
                $type = \gettype($subject);
262 5
                throw $this->unresolved("Unable to remove '$path': '%s' is of type $type", $path, $delimiter, $index);
263
            }
264
        }
265
266
        try {
267 15
            $this->removeChild($subject, $index[0]);
268 2
        } catch (\Error $error) {
269 2
            $msg = "Unable to remove '$path': error at '%s'";
270 2
            throw $this->unresolved($msg, $path, $delimiter, array_slice($index, 0, -1), null, $error);
271
        }
272
273 13
        return $result;
274
    }
275
276
    /**
277
     * Remove item or property from subject.
278
     *
279
     * @param object|array<string,mixed> $subject
280
     * @param string                     $key
281
     */
282 15
    protected function removeChild(&$subject, string $key): void
283
    {
284 15
        if (\is_array($subject)) {
285 8
            if (\array_key_exists($key, $subject)) {
286 8
                unset($subject[$key]);
287
            }
288 7
        } elseif ($subject instanceof \ArrayAccess) {
289 2
            if ($subject->offsetExists($key)) {
290 2
                unset($subject[$key]);
291
            }
292 5
        } elseif (\property_exists($subject, $key)) {
293 5
            unset($subject->{$key});
294
        }
295 13
    }
296
297
    /**
298
     * Create a ResolveException, filling out %s with invalid path.
299
     *
300
     * @noinspection PhpTooManyParametersInspection
301
     *
302
     * @param string   $msg
303
     * @param string   $path
304
     * @param string   $delimiter
305
     * @param string[] $index
306
     * @param string   $key
307
     * @throws ResolveException
308
     */
309 30
    protected function unresolved(
310
        string $msg,
311
        string $path,
312
        string $delimiter,
313
        array $index,
314
        ?string $key = null,
315
        ?Throwable $previous = null
316
    ): ResolveException {
317 30
        $len = ($index !== [] ? \strlen($delimiter) + \strlen(\join($delimiter, $index)) : 0)
318 30
            + ($key !== null ? \strlen($delimiter) + \strlen($key) : 0);
319 30
        $invalidPath = $len > 0 ? \substr(\rtrim($path, $delimiter), 0, -1 * $len) : $path;
320
321 30
        return new ResolveException(sprintf($msg, $invalidPath), 0, $previous);
322
    }
323
324
    /**
325
     * Make subject a child of the subject.
326
     * If `$exists` is false, it wasn't possible to decent and subject is returned.
327
     *
328
     * @param object|array<string,mixed> $subject
329
     * @param string                     $key
330
     * @param mixed                      $exists      output as bool
331
     * @param bool                       $accessible  Check not only if property exists, but also is accessible.
332
     * @return mixed
333
     */
334 100
    protected function &descend(&$subject, string $key, &$exists, bool $accessible = false)
335
    {
336 100
        if (!\is_array($subject) && !$subject instanceof \ArrayAccess) {
337 28
            $exists = $accessible
338 3
                ? $this->propertyIsAccessible($subject, $key)
339 28
                : \property_exists($subject, $key);
340
341 28
            if ($exists) {
342 28
                $subject =& $subject->{$key};
343
            }
344
        } else {
345 83
            $exists = \is_array($subject) ? \array_key_exists($key, $subject) : $subject->offsetExists($key);
1 ignored issue
show
introduced by
The condition is_array($subject) is always true.
Loading history...
346 83
            if ($exists) {
347 83
                $subject =& $subject[$key];
348
            }
349
        }
350
351 95
        return $subject;
352
    }
353
354
    /**
355
     * Check if property exists and is accessible.
356
     *
357
     * @param object $object
358
     * @param string $property
359
     * @return bool
360
     */
361 3
    protected function propertyIsAccessible(object $object, string $property): bool
362
    {
363 3
        $exists = \property_exists($object, $property);
364
365 3
        if (!$exists || isset($object->{$property})) {
366 2
            return $exists;
367
        }
368
369
        try {
370 2
            $reflection = new \ReflectionProperty($object, $property);
371
        } catch (\ReflectionException $exception) { // @codeCoverageIgnore
372
            return false;                           // @codeCoverageIgnore
373
        }
374
375 2
        return $reflection->isPublic() && !$reflection->isStatic();
376
    }
377
378
    /**
379
     * Explode with trimming and check.
380
     * @see explode()
381
     *
382
     * @param string $path
383
     * @param string $delimiter
384
     * @return string[]
385
     */
386 108
    protected function splitPath(string $path, string $delimiter): array
387
    {
388 108
        if ($delimiter === '') {
389 5
            throw new \InvalidArgumentException("Delimiter can't be an empty string");
390
        }
391
392
        /** @var array<int,string> $parts */
393 103
        $parts = \explode($delimiter, trim($path, $delimiter));
394
395 103
        return $parts;
396
    }
397
398
    
399
    /**
400
     * Factory method.
401
     *
402
     * @param object|array<string,mixed> $subject
403
     * @return static
404
     *
405
     * @phpstan-param TSubject&(object|array<string,mixed>) $subject
406
     * @phpstan-return static<TSubject>
407
     */
408 109
    public static function on($subject): self
409
    {
410 109
        return new static($subject);
411
    }
412
}
413