1<?php
2
3declare(strict_types=1);
4
5/*
6 * This file is a part of dflydev/dot-access-data.
7 *
8 * (c) Dragonfly Development Inc.
9 *
10 * For the full copyright and license information, please view the LICENSE
11 * file that was distributed with this source code.
12 */
13
14namespace Dflydev\DotAccessData;
15
16use ArrayAccess;
17use Dflydev\DotAccessData\Exception\DataException;
18use Dflydev\DotAccessData\Exception\InvalidPathException;
19use Dflydev\DotAccessData\Exception\MissingPathException;
20
21/**
22 * @implements ArrayAccess<string, mixed>
23 */
24class Data implements DataInterface, ArrayAccess
25{
26    private const DELIMITERS = ['.', '/'];
27
28    /**
29     * Internal representation of data data
30     *
31     * @var array<string, mixed>
32     */
33    protected $data;
34
35    /**
36     * Constructor
37     *
38     * @param array<string, mixed> $data
39     */
40    public function __construct(array $data = [])
41    {
42        $this->data = $data;
43    }
44
45    /**
46     * {@inheritdoc}
47     */
48    public function append(string $key, $value = null): void
49    {
50        $currentValue =& $this->data;
51        $keyPath = self::keyToPathArray($key);
52
53        $endKey = array_pop($keyPath);
54        foreach ($keyPath as $currentKey) {
55            if (! isset($currentValue[$currentKey])) {
56                $currentValue[$currentKey] = [];
57            }
58            $currentValue =& $currentValue[$currentKey];
59        }
60
61        if (!isset($currentValue[$endKey])) {
62            $currentValue[$endKey] = [];
63        }
64
65        if (!is_array($currentValue[$endKey])) {
66            // Promote this key to an array.
67            // TODO: Is this really what we want to do?
68            $currentValue[$endKey] = [$currentValue[$endKey]];
69        }
70
71        $currentValue[$endKey][] = $value;
72    }
73
74    /**
75     * {@inheritdoc}
76     */
77    public function set(string $key, $value = null): void
78    {
79        $currentValue =& $this->data;
80        $keyPath = self::keyToPathArray($key);
81
82        $endKey = array_pop($keyPath);
83        foreach ($keyPath as $currentKey) {
84            if (!isset($currentValue[$currentKey])) {
85                $currentValue[$currentKey] = [];
86            }
87            if (!is_array($currentValue[$currentKey])) {
88                throw new DataException(sprintf('Key path "%s" within "%s" cannot be indexed into (is not an array)', $currentKey, self::formatPath($key)));
89            }
90            $currentValue =& $currentValue[$currentKey];
91        }
92        $currentValue[$endKey] = $value;
93    }
94
95    /**
96     * {@inheritdoc}
97     */
98    public function remove(string $key): void
99    {
100        $currentValue =& $this->data;
101        $keyPath = self::keyToPathArray($key);
102
103        $endKey = array_pop($keyPath);
104        foreach ($keyPath as $currentKey) {
105            if (!isset($currentValue[$currentKey])) {
106                return;
107            }
108            $currentValue =& $currentValue[$currentKey];
109        }
110        unset($currentValue[$endKey]);
111    }
112
113    /**
114     * {@inheritdoc}
115     *
116     * @psalm-mutation-free
117     */
118    public function get(string $key, $default = null)
119    {
120        /** @psalm-suppress ImpureFunctionCall */
121        $hasDefault = \func_num_args() > 1;
122
123        $currentValue = $this->data;
124        $keyPath = self::keyToPathArray($key);
125
126        foreach ($keyPath as $currentKey) {
127            if (!is_array($currentValue) || !array_key_exists($currentKey, $currentValue)) {
128                if ($hasDefault) {
129                    return $default;
130                }
131
132                throw new MissingPathException($key, sprintf('No data exists at the given path: "%s"', self::formatPath($keyPath)));
133            }
134
135            $currentValue = $currentValue[$currentKey];
136        }
137
138        return $currentValue === null ? $default : $currentValue;
139    }
140
141    /**
142     * {@inheritdoc}
143     *
144     * @psalm-mutation-free
145     */
146    public function has(string $key): bool
147    {
148        $currentValue = $this->data;
149
150        foreach (self::keyToPathArray($key) as $currentKey) {
151            if (
152                !is_array($currentValue) ||
153                !array_key_exists($currentKey, $currentValue)
154            ) {
155                return false;
156            }
157            $currentValue = $currentValue[$currentKey];
158        }
159
160        return true;
161    }
162
163    /**
164     * {@inheritdoc}
165     *
166     * @psalm-mutation-free
167     */
168    public function getData(string $key): DataInterface
169    {
170        $value = $this->get($key);
171        if (is_array($value) && Util::isAssoc($value)) {
172            return new Data($value);
173        }
174
175        throw new DataException(sprintf('Value at "%s" could not be represented as a DataInterface', self::formatPath($key)));
176    }
177
178    /**
179     * {@inheritdoc}
180     */
181    public function import(array $data, int $mode = self::REPLACE): void
182    {
183        $this->data = Util::mergeAssocArray($this->data, $data, $mode);
184    }
185
186    /**
187     * {@inheritdoc}
188     */
189    public function importData(DataInterface $data, int $mode = self::REPLACE): void
190    {
191        $this->import($data->export(), $mode);
192    }
193
194    /**
195     * {@inheritdoc}
196     *
197     * @psalm-mutation-free
198     */
199    public function export(): array
200    {
201        return $this->data;
202    }
203
204    /**
205     * {@inheritdoc}
206     *
207     * @return bool
208     */
209    #[\ReturnTypeWillChange]
210    public function offsetExists($key)
211    {
212        return $this->has($key);
213    }
214
215    /**
216     * {@inheritdoc}
217     *
218     * @return mixed
219     */
220    #[\ReturnTypeWillChange]
221    public function offsetGet($key)
222    {
223        return $this->get($key, null);
224    }
225
226    /**
227     * {@inheritdoc}
228     *
229     * @param string $key
230     * @param mixed $value
231     *
232     * @return void
233     */
234    #[\ReturnTypeWillChange]
235    public function offsetSet($key, $value)
236    {
237        $this->set($key, $value);
238    }
239
240    /**
241     * {@inheritdoc}
242     *
243     * @return void
244     */
245    #[\ReturnTypeWillChange]
246    public function offsetUnset($key)
247    {
248        $this->remove($key);
249    }
250
251    /**
252     * @param string $path
253     *
254     * @return string[]
255     *
256     * @psalm-return non-empty-list<string>
257     *
258     * @psalm-pure
259     */
260    protected static function keyToPathArray(string $path): array
261    {
262        if (\strlen($path) === 0) {
263            throw new InvalidPathException('Path cannot be an empty string');
264        }
265
266        $path = \str_replace(self::DELIMITERS, '.', $path);
267
268        return \explode('.', $path);
269    }
270
271    /**
272     * @param string|string[] $path
273     *
274     * @return string
275     *
276     * @psalm-pure
277     */
278    protected static function formatPath($path): string
279    {
280        if (is_string($path)) {
281            $path = self::keyToPathArray($path);
282        }
283
284        return implode(' » ', $path);
285    }
286}
287