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