1: <?php
2: /*
3: * SimpleID
4: *
5: * Copyright (C) Kelvin Mo 2014-2025
6: *
7: * This program is free software; you can redistribute it and/or
8: * modify it under the terms of the GNU General Public
9: * License as published by the Free Software Foundation; either
10: * version 2 of the License, or (at your option) any later version.
11: *
12: * This program is distributed in the hope that it will be useful,
13: * but WITHOUT ANY WARRANTY; without even the implied warranty of
14: * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
15: * General Public License for more details.
16: *
17: * You should have received a copy of the GNU General Public
18: * License along with this program; if not, write to the Free
19: * Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
20: *
21: */
22:
23: namespace SimpleID\Util;
24:
25: use \InvalidArgumentException;
26: use \ArrayAccess;
27: use \Countable;
28: use \IteratorAggregate;
29: use \Traversable;
30: use \ArrayIterator;
31:
32: /**
33: * A class that wraps around an array while providing array-like and
34: * JS-like access notations. Subclasses of this class are used extensively
35: * throughout SimpleID to decorate arrays with additional methods.
36: *
37: * Two types of access methods are made available with this class.
38: *
39: * **{@link ArrayAccess} interface.** This class implements the `ArrayAccess`
40: * interface. This means that elements of the underlying array can be
41: * accessed directly using PHP's array syntax.
42: *
43: * It should be noted that when using the ArrayAccess interface, the values
44: * returned are *copies* of the values of the underlying array. This is
45: * particularly important if the value itself is an array. Changes to these
46: * lower-dimension values will not be reflected in the original underlying
47: * array. For example, the following will not work.
48: *
49: * <code>
50: * $array_wrapper = new ArrayWrapper(['dim1' => ['foo' => 1, 'bar' => 2]]);
51: * print $array_wrapper['dim1']['foo']; # Prints 1
52: * $array_wrapper['dim']['foo'] = 3; # Will not work
53: * print $array_wrapper['dim1']['foo']; # Still prints 1
54: * </code>
55: *
56: * In order to alter these values in the underlying array, use dot-notation
57: * (explained below).
58: *
59: * **Dot-notation.** Dot notation can be used to traverse through arrays and
60: * objects. Dot notation is similar to JavaScript - use `.` to traverse
61: * through arrays (with either string or numeric keys) and `->` to traverse
62: * through object properties. The entire expression in dot-notation is called
63: * a *path*.
64: *
65: * If a segment in the path contains a dot, this can be escaped using brackets.
66: * For example, the expression `a.[b.c].d` is split into `a`, `b.c` and `d`.
67: *
68: * Dot-notation can be used in {@link get()}, {@link exists()}, {@link set()},
69: * and {@link ref()}. Thus in the example above:
70: *
71: * <code>
72: * $array_wrapper = new ArrayWrapper(['dim1' => ['foo' => 1, 'bar' => 2]]);
73: * print $array_wrapper->get('dim1.foo'); # Prints 1
74: * $array_wrapper->set('dim.foo', 3); # Works!
75: * print $array_wrapper->get('dim1.foo'); # Now prints 3
76: * </code>
77: *
78: * @implements ArrayAccess<string, mixed>
79: * @implements IteratorAggregate<string, mixed>
80: */
81: class ArrayWrapper implements ArrayAccess, Countable, IteratorAggregate {
82: /** @var array<mixed> the underlying array */
83: protected $container = [];
84:
85: /**
86: * Creates a new ArrayWrapper over an underlying array
87: *
88: * @param array<mixed> $container the underlying array
89: */
90: public function __construct($container = []) {
91: if (is_array($container)) $this->container = $container;
92: }
93:
94: /**
95: * Loads data from an array or another ArrayWrapper, replacing existing data.
96: *
97: * This data is typically read from another source
98: *
99: * @param array<mixed>|ArrayWrapper $data the data
100: * @return void
101: */
102: public function loadData($data) {
103: if ($data instanceof ArrayWrapper) {
104: $this->container = array_replace_recursive($this->container, $data->container);
105: } elseif (is_array($data)) {
106: $this->container = array_replace_recursive($this->container, $data);
107: }
108: }
109:
110: /**
111: * Returns this object as an array.
112: *
113: * @return array<mixed>
114: */
115: public function toArray() {
116: return $this->container;
117: }
118:
119: /**
120: * Implementation of ArrayAccess
121: */
122: public function offsetSet($offset, $value): void {
123: if (is_null($offset)) {
124: $this->container[] = $value;
125: } else {
126: $this->container[$offset] = $value;
127: }
128: }
129:
130: /**
131: * Implementation of ArrayAccess
132: */
133: public function offsetExists($offset): bool {
134: return isset($this->container[$offset]);
135: }
136:
137: /**
138: * Implementation of ArrayAccess
139: */
140: public function offsetUnset($offset): void {
141: unset($this->container[$offset]);
142: }
143:
144: /**
145: * Implementation of ArrayAccess
146: *
147: * Ideally we should provide a mixed return type here, but for PHP7 compatibility,
148: * we add a ReturnTypeWillChange attribute instead.
149: */
150: #[\ReturnTypeWillChange]
151: public function offsetGet($offset) {
152: return isset($this->container[$offset]) ? $this->container[$offset] : null;
153: }
154:
155: /**
156: * Fallback function when attempt to write to non-existing properties.
157: *
158: * A fallback function is required to to work around Fat-Free Framework's
159: * behaviour when escaping the contents in the hive for template rendering.
160: * The Fat-Free code causes a dynamic property to be set, which is
161: * deprecated in PHP 8.2. Having an explicit __set() function allows
162: * PHP to call something rather than setting the property dynamically.
163: *
164: * This function should not be called by anything other than the Fat-Free
165: * Framework. As a result, the corresponding __get() and __exists() functions
166: * are not defined.
167: *
168: * @link https://github.com/simpleid/simpleid/pull/132 Further details
169: * @link https://www.php.net/manual/en/language.oop5.overloading.php#object.set PHP documention on __set()
170: * @deprecated 2.0 This should not be used where possible
171: */
172: public function __set(string $name, mixed $value): void {
173: // Only allow keys already existing in $container to be set
174: if (array_key_exists($name, $this->container)) {
175: $this->container[$name] = $value;
176: } else {
177: throw new InvalidArgumentException('Attempt to set a non-existent key');
178: }
179: }
180:
181:
182: /**
183: * Implementation of Countable
184: */
185: public function count(): int {
186: return count($this->container);
187: }
188:
189: /**
190: * Implementation of IteratorAggregate
191: */
192: public function getIterator(): Traversable {
193: return new ArrayIterator($this->container);
194: }
195:
196: /**
197: * Retrieve contents of the container based on a FatFree-like path
198: * expression
199: *
200: * @param string $path the path
201: * @return mixed the contents of the container matching the path
202: * @throws \InvalidArgumentException if $path is empty
203: */
204: public function get(string $path) {
205: $value = $this->ref($path);
206: return $value;
207: }
208:
209: /**
210: * Determines whether a FatFree-like path
211: * expression can be resolved
212: *
213: * @param string $path the path
214: * @return bool true if the path can be resolved
215: * @throws \InvalidArgumentException if $path is empty
216: */
217: public function exists(string $path) {
218: $value = $this->ref($path, false);
219: return isset($value);
220: }
221:
222: /**
223: * Sets the value of the element specified by a FatFree-like path
224: * expression.
225: *
226: * Note that this function will overwrite intermediate parts
227: * of the path with array declarations. For example, the following
228: * code:
229: *
230: * <code>
231: * $wrapper->set('a', 'foo');
232: * $wrapper->set('a.b', 'bar');
233: * </code>
234: *
235: * Will result in `foo` being overwritten.
236: *
237: * @param string $path the path
238: * @param mixed $value the value to set
239: * @return void
240: * @throws \InvalidArgumentException if $path is empty
241: */
242: public function set(string $path, $value) {
243: $ref = &$this->ref($path);
244: $ref = $value;
245: }
246:
247: /**
248: * Appends a value to an array element specified by a FatFree-like path
249: * expression.
250: *
251: * @param string $path the path to the array
252: * @param mixed $value the value to append
253: * @return void
254: * @throws \InvalidArgumentException if $path is empty or does not point
255: * to an array
256: */
257: public function append(string $path, $value) {
258: $ref = &$this->ref($path);
259: if (is_null($ref)) $ref = [];
260: if (!is_array($ref)) throw new \InvalidArgumentException('Not an array: ' . $path);
261: $ref[] = $value;
262: }
263:
264: /**
265: * Removes the element specified by a FatFree-like path
266: * expression
267: *
268: * @param string $path the path
269: * @return void
270: * @throws \InvalidArgumentException if $path is empty
271: */
272: public function unset(string $path) {
273: if (!$this->exists($path)) return;
274:
275: $parts = $this->splitPath($path);
276:
277: if (count($parts) == 1) {
278: unset($this->container[$path]);
279: return;
280: }
281:
282: $key = array_pop($parts);
283: if (array_pop($parts) == '->') {
284: $ref = &$this->refLimit($path, true, 2);
285: unset($ref->$key);
286: } else {
287: $ref = &$this->refLimit($path, true, 1);
288: unset($ref[$key]);
289: }
290: }
291:
292: /**
293: * Retrieve contents of the container based on a FatFree-like path
294: * expression, as a reference.
295: *
296: * If $add is set to true, adds non-existent keys,
297: * array elements, and object properties
298: *
299: * If the path cannot be traversed (for example `a.b.c` but `a.b` is not
300: * an array), this function returns null.
301: *
302: * @param string $path the path
303: * @param bool $add adds non-existent keys, array elements, and object properties
304: * @return mixed the contents of the container matching the path
305: * @throws \InvalidArgumentException if $path is empty
306: */
307: public function &ref(string $path, bool $add = TRUE) {
308: return $this->refLimit($path, $add);
309: }
310:
311: /**
312: * Retrieve contents of the container based on a FatFree-like path
313: * expression, as a reference.
314: *
315: * If $add is set to true, adds non-existent keys,
316: * array elements, and object properties.
317: *
318: * If the path cannot be traversed (for example `a.b.c` but `a.b` is not
319: * an array), this function returns null.
320: *
321: * @param string $path the path
322: * @param bool $add adds non-existent keys, array elements, and object properties
323: * @param int|null $limit the number of path elements to traverse, or null for
324: * unlimited
325: * @return mixed the contents of the container matching the path
326: * @throws \InvalidArgumentException if $path is empty
327: */
328: protected function &refLimit(string $path, bool $add = TRUE, $limit = NULL) {
329: $null = NULL;
330:
331: $parts = $this->splitPath($path);
332: if ($limit != null) $parts = array_slice($parts, 0, -$limit);
333:
334: if (!$parts || !preg_match('/^\w+$/', $parts[0])) throw new InvalidArgumentException('$path is empty or invalid');
335:
336: if ($add) {
337: $var = &$this->container;
338: } else {
339: $var = $this->container;
340: }
341:
342: $in_object = FALSE;
343:
344: foreach ($parts as $part) {
345: if ($part == '->') {
346: $in_object = TRUE;
347: } elseif ($in_object) {
348: $in_object = FALSE;
349: if (!is_object($var)) $var = new \stdClass;
350: if ($add || property_exists($var, $part)) {
351: $var = &$var->$part;
352: } else {
353: $var = &$null;
354: break;
355: }
356: } else {
357: if (!is_array($var)) $var=[];
358: if ($add || array_key_exists($part,$var)) {
359: $var = &$var[$part];
360: } else {
361: $var = &$null;
362: break;
363: }
364: }
365: }
366: return $var;
367: }
368:
369: /**
370: *
371: *
372: * @param string $path
373: * @return array<string>
374: */
375: protected function splitPath(string $path) {
376: $split = preg_split('/\[\h*[\'"]?(.+?)[\'"]?\h*\]|(->)|\./', $path, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE);
377: if ($split === false) {
378: return [ $path ];
379: } else {
380: return $split;
381: }
382: }
383: }
384: ?>