Passed
Push — master ( 6aa01f...5961eb )
by Smoren
02:01
created

NestedAccessor::_append()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 14
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 8
c 1
b 0
f 0
nc 4
nop 4
dl 0
loc 14
rs 9.6111
1
<?php
2
3
namespace Smoren\NestedAccessor\Components;
4
5
use Smoren\NestedAccessor\Helpers\ArrayHelper;
6
use Smoren\NestedAccessor\Interfaces\NestedAccessorInterface;
7
use Smoren\NestedAccessor\Exceptions\NestedAccessorException;
8
use Smoren\TypeTools\MapAccess;
9
use Smoren\TypeTools\ObjectAccess;
10
use stdClass;
11
12
/**
13
 * Accessor class for getting and setting to source array or object with nested keys
14
 * @author Smoren <[email protected]>
15
 */
16
class NestedAccessor implements NestedAccessorInterface
17
{
18
    public const SET_MODE_SET = 1;
19
    public const SET_MODE_APPEND = 2;
20
    public const SET_MODE_DELETE = 3;
21
22
    /**
23
     * @var array<int|string, mixed>|object data source for accessing
24
     */
25
    protected $source;
26
    /**
27
     * @var non-empty-string path's separator of nesting
0 ignored issues
show
Documentation Bug introduced by
The doc comment non-empty-string at position 0 could not be parsed: Unknown type name 'non-empty-string' at position 0 in non-empty-string.
Loading history...
28
     */
29
    protected string $pathDelimiter;
30
31
    /**
32
     * ArrayNestedAccessor constructor.
33
     * @param array<scalar, mixed>|object $source
34
     * @param non-empty-string $pathDelimiter
0 ignored issues
show
Documentation Bug introduced by
The doc comment non-empty-string at position 0 could not be parsed: Unknown type name 'non-empty-string' at position 0 in non-empty-string.
Loading history...
35
     * @throws NestedAccessorException
36
     */
37
    public function __construct(&$source, string $pathDelimiter = '.')
38
    {
39
        $this->setSource($source);
40
        $this->pathDelimiter = $pathDelimiter;
41
    }
42
43
    /**
44
     * Setter for source
45
     * @param array<scalar, mixed>|object $source source setter
46
     * @return void
47
     * @throws NestedAccessorException
48
     */
49
    public function setSource(&$source): void
50
    {
51
        /** @var array<scalar, mixed>|object|mixed|null $source */
52
        if($source === null) {
53
            $source = [];
54
        }
55
56
        if(is_scalar($source)) {
57
            throw NestedAccessorException::createAsSourceIsScalar($source);
58
        }
59
60
        /** @var array<int|string, mixed>|object $source */
61
        $this->source = &$source;
62
    }
63
64
    /**
65
     * {@inheritDoc}
66
     */
67
    public function get($path = null, bool $strict = true)
68
    {
69
        // when path is not specified
70
        if($path === null || $path === '') {
71
            // let's return the full source
72
            return $this->source;
73
        }
74
75
        $path = $this->formatPath($path);
76
77
        // let result be null and there are no errors by default
78
        $result = null;
79
        $errorsCount = 0;
80
81
        // getting result with internal recursive method
82
        $this->_get(
83
            $this->source,
84
            array_reverse($path), // path stack
85
            $result,
86
            $errorsCount
87
        );
88
89
        // when strict mode is on and we got errors
90
        if($strict && $errorsCount) {
91
            throw NestedAccessorException::createAsCannotGetValue(
92
                implode($this->pathDelimiter, $path),
93
                $errorsCount
94
            );
95
        }
96
97
        return $result;
98
    }
99
100
    /**
101
     * {@inheritDoc}
102
     */
103
    public function set($path, $value, bool $strict = true): self
104
    {
105
        $path = $this->formatPath($path);
106
        return $this->_set($this->source, $path, $value, self::SET_MODE_SET, $strict);
107
    }
108
109
    /**
110
     * {@inheritDoc}
111
     */
112
    public function append($path, $value, bool $strict = true): self
113
    {
114
        $path = $this->formatPath($path);
115
        return $this->_set($this->source, $path, $value, self::SET_MODE_APPEND, $strict);
116
    }
117
118
    /**
119
     * {@inheritDoc}
120
     */
121
    public function delete($path, bool $strict = true): self
122
    {
123
        $path = $this->formatPath($path);
124
125
        if(!$this->exist($path)) {
126
            if($strict) {
127
                throw NestedAccessorException::createAsCannotSetValue(
128
                    self::SET_MODE_DELETE,
129
                    implode($this->pathDelimiter, $path)
130
                );
131
            }
132
            return $this;
133
        }
134
135
        return $this->_set($this->source, $path, null, self::SET_MODE_DELETE, $strict);
136
    }
137
138
    /**
139
     * {@inheritDoc}
140
     */
141
    public function exist($path): bool
142
    {
143
        try {
144
            $this->get($path);
145
            return true;
146
        } catch(NestedAccessorException $e) {
147
            return false;
148
        }
149
    }
150
151
    /**
152
     * {@inheritDoc}
153
     */
154
    public function isset($path): bool
155
    {
156
        try {
157
            return $this->get($path) !== null;
158
        } catch(NestedAccessorException $e) {
159
            return false;
160
        }
161
    }
162
163
    /**
164
     * Internal recursive method to get value from source by path stack
165
     * @param mixed $source source to get value from
166
     * @param array<string> $path nested path stack
167
     * @param array<scalar, mixed>|mixed $result place for result
168
     * @param int $errorsCount errors counter
169
     * @return void
170
     */
171
    protected function _get($source, array $path, &$result, int &$errorsCount): void
172
    {
173
        // let's iterate every path part from stack
174
        while(count($path)) {
175
            if(is_array($source) && !ArrayHelper::isAssoc($source)) {
176
                // the result will be multiple
177
                if(!is_array($result)) {
178
                    $result = [];
179
                }
180
                // and we need to use recursive call for each item of this array
181
                foreach($source as $item) {
182
                    $this->_get($item, $path, $result, $errorsCount);
183
                }
184
                // we don't need to do something in this recursive branch
185
                return;
186
            }
187
188
            $key = array_pop($path);
189
190
            if(MapAccess::exists($source, $key)) {
191
                // go to the next nested level
192
                $source = MapAccess::get($source, $key);
193
            } else {
194
                // path part key is missing in source object
195
                $errorsCount++;
196
                // we cannot go deeper
197
                return;
198
            }
199
200
            // when it's not the last iteration of the stack
201
            // and the source is non-associative array (list)
202
            /** @var mixed $source */
203
            if(count($path) && is_array($source) && !ArrayHelper::isAssoc($source)) {
204
                // the result will be multiple
205
                if(!is_array($result)) {
206
                    $result = [];
207
                }
208
                // and we need to use recursive call for each item of this array
209
                foreach($source as $item) {
210
                    $this->_get($item, $path, $result, $errorsCount);
211
                }
212
                // we don't need to do something in this recursive branch
213
                return;
214
            }
215
        }
216
217
        // now path stack is empty — we reached target value of given path in source argument
218
        // so if result is multiple
219
        if(is_array($result)) {
220
            // we append source to result
221
            $result[] = $source;
222
        } else {
223
            // result is single
224
            $result = $source;
225
        }
226
        // that's all folks!
227
    }
228
229
    /**
230
     * Internal recursive method to save value to source by path stack
231
     * @param array<scalar, mixed>|object $source source to save value to
232
     * @param array<string> $path nested path
233
     * @param mixed $value value to save to source
234
     * @param int $mode when true append or set
235
     * @param bool $strict when true throw exception if path not exist in source object
236
     * @return $this
237
     * @throws NestedAccessorException
238
     */
239
    protected function _set(&$source, array $path, $value, int $mode, bool $strict): self
240
    {
241
        $temp = &$source;
242
        $tempPrevSource = null;
243
        $tempPrevKey = null;
244
245
        // let's iterate every path part to go deeper into nesting
246
        foreach($path as $key) {
247
            if(isset($temp) && is_scalar($temp)) {
248
                // value in the middle of the path must be an array
249
                $temp = [];
250
            }
251
252
            $tempPrevSource = &$temp;
253
            $tempPrevKey = $key;
254
255
            // go to the next nested level
256
            if(is_object($temp)) {
257
                if($strict && !($temp instanceof stdClass) && !ObjectAccess::hasPublicProperty($temp, $key)) {
258
                    throw NestedAccessorException::createAsCannotSetValue($mode, implode($this->pathDelimiter, $path));
259
                }
260
                $temp = &$temp->{$key};
261
            } else {
262
                // TODO check PHPStan: "Cannot access offset string on mixed"
263
                /** @var array<string, mixed> $temp */
264
                $temp = &$temp[$key];
265
            }
266
        }
267
        // now we can save value to the source
268
        switch($mode) {
269
            case self::SET_MODE_SET:
270
                $temp = $value;
271
                break;
272
            case self::SET_MODE_APPEND:
273
                $this->_append($temp, $value, $path, $strict);
274
                break;
275
            case self::SET_MODE_DELETE:
276
                if(!$this->_delete($tempPrevSource, $tempPrevKey, $path, $strict)) {
277
                    return $this;
278
                }
279
                break;
280
        }
281
        unset($temp);
282
283
        return $this;
284
    }
285
286
    /**
287
     * Appends value to source.
288
     * @param mixed $source source to append value to
289
     * @param mixed $value value to append to source
290
     * @param array<string> $path nested path
291
     * @param bool $strict if true: throw exception when cannot append value
292
     * @return void
293
     * @throws NestedAccessorException if cannot append item to source (in strict mode only)
294
     */
295
    protected function _append(&$source, $value, array $path, bool $strict): void
296
    {
297
        if(!is_array($source) || ArrayHelper::isAssoc($source)) {
298
            if($strict) {
299
                throw NestedAccessorException::createAsCannotSetValue(
300
                    self::SET_MODE_APPEND,
301
                    implode($this->pathDelimiter, $path)
302
                );
303
            } elseif(!is_array($source)) {
304
                $source = [];
305
            }
306
        }
307
308
        $source[] = $value;
309
    }
310
311
    /**
312
     * Removes item from source.
313
     * @param mixed $source source to remove item from
314
     * @param string|null $key key to remove item from source by
315
     * @param array<string> $path nested path
316
     * @param bool $strict if true: throw exception when cannot remove item
317
     * @return bool true if removing is succeeded
318
     * @throws NestedAccessorException if item to remove is not exists (in strict mode only)
319
     */
320
    protected function _delete(&$source, $key, array $path, bool $strict): bool
321
    {
322
        if($key === null || (!is_array($source) && !($source instanceof stdClass))) {
323
            if($strict) {
324
                throw NestedAccessorException::createAsCannotSetValue(
325
                    self::SET_MODE_DELETE,
326
                    implode($this->pathDelimiter, $path)
327
                );
328
            } else {
329
                return false;
330
            }
331
        }
332
        if(is_array($source)) {
333
            unset($source[$key]);
334
        } else {
335
            unset($source->{$key});
336
        }
337
338
        return true;
339
    }
340
341
    /**
342
     * @param string|string[]|null $path
343
     * @return string[]
344
     */
345
    protected function formatPath($path): array
346
    {
347
        if(is_array($path)) {
348
            return $path;
349
        }
350
351
        if($path === null || $path === '') {
352
            return [];
353
        }
354
355
        return explode($this->pathDelimiter, $path);
356
    }
357
}
358