1: <?php
2:
3: namespace Charcoal\Config;
4:
5: use ArrayIterator;
6: use IteratorAggregate;
7: use Traversable;
8: use InvalidArgumentException;
9:
10: // From PSR-11
11: use Psr\Container\ContainerInterface;
12:
13: /**
14: * Default configuration container / registry.
15: *
16: * ### Notes on {@see SeparatorAwareTrait}:
17: *
18: * - Provides the ability for a store to fetch data that is nested in a tree-like structure,
19: * often referred to as "dot" notation.
20: *
21: * ### Notes on {@see DelegatesAwareTrait}:
22: *
23: * - Provides the ability for a store to fetch data in another store.
24: * - Provides this store with a way to register one or more delegate stores.
25: */
26: abstract class AbstractConfig extends AbstractEntity implements
27: ConfigInterface,
28: ContainerInterface,
29: IteratorAggregate
30: {
31: use DelegatesAwareTrait;
32: use FileAwareTrait;
33: use SeparatorAwareTrait;
34:
35: const DEFAULT_SEPARATOR = '.';
36:
37: /**
38: * Create the configuration.
39: *
40: * @param mixed $data Initial data. Either a filepath,
41: * an associative array, or an {@see Traversable iterable object}.
42: * @param EntityInterface[] $delegates An array of delegates (config) to set.
43: * @throws InvalidArgumentException If $data is invalid.
44: */
45: final public function __construct($data = null, array $delegates = null)
46: {
47: // Always set the default chaining notation
48: $this->setSeparator(self::DEFAULT_SEPARATOR);
49:
50: // Always set the default data first.
51: $this->setData($this->defaults());
52:
53: // Set the delegates, if necessary.
54: if (isset($delegates)) {
55: $this->setDelegates($delegates);
56: }
57:
58: if ($data === null) {
59: return;
60: }
61:
62: if (is_string($data)) {
63: // Treat the parameter as a filepath
64: $this->addFile($data);
65: } elseif (is_array($data)) {
66: $this->merge($data);
67: } elseif ($data instanceof Traversable) {
68: $this->merge($data);
69: } else {
70: throw new InvalidArgumentException(sprintf(
71: 'Data must be a config file, an associative array, or an object implementing %s',
72: Traversable::class
73: ));
74: }
75: }
76:
77: /**
78: * Gets all default data from this store.
79: *
80: * Pre-populates new stores.
81: *
82: * May be reimplemented in inherited classes if any default values should be defined.
83: *
84: * @return array Key-value array of data
85: */
86: public function defaults()
87: {
88: return [];
89: }
90:
91: /**
92: * Adds new data, replacing / merging existing data with the same key.
93: *
94: * @uses self::offsetReplace()
95: * @param array|Traversable $data Key-value dataset to merge.
96: * Either an associative array or an {@see Traversable iterable object}
97: * (such as {@see ConfigInterface}).
98: * @return self
99: */
100: public function merge($data)
101: {
102: foreach ($data as $key => $value) {
103: $this->offsetReplace($key, $value);
104: }
105: return $this;
106: }
107:
108: /**
109: * Create a new iterator from the configuration instance.
110: *
111: * @see IteratorAggregate
112: * @return ArrayIterator
113: */
114: public function getIterator()
115: {
116: return new ArrayIterator($this->data());
117: }
118:
119: /**
120: * Determines if this store contains the specified key and if its value is not NULL.
121: *
122: * Routine:
123: * - If the data key is {@see SeparatorAwareTrait::$separator nested},
124: * the data-tree is traversed until the endpoint is found, if any;
125: * - If the data key does NOT exist on the store, a lookup is performed
126: * on each delegate store until a key is found, if any.
127: *
128: * @see \ArrayAccess
129: * @uses SeparatorAwareTrait::hasWithSeparator()
130: * @uses DelegatesAwareTrait::hasInDelegates()
131: * @param string $key The data key to check.
132: * @throws InvalidArgumentException If the $key is not a string or is a numeric value.
133: * @return boolean TRUE if $key exists and has a value other than NULL, FALSE otherwise.
134: */
135: public function offsetExists($key)
136: {
137: if (is_numeric($key)) {
138: throw new InvalidArgumentException(
139: 'Entity array access only supports non-numeric keys'
140: );
141: }
142:
143: if ($this->separator && strstr($key, $this->separator)) {
144: return $this->hasWithSeparator($key);
145: }
146:
147: $key = $this->camelize($key);
148:
149: /** @internal Edge Case: "_" → "" */
150: if ($key === '') {
151: return false;
152: }
153:
154: if (is_callable([ $this, $key ])) {
155: $value = $this->{$key}();
156: } else {
157: if (!isset($this->{$key})) {
158: return $this->hasInDelegates($key);
159: }
160: $value = $this->{$key};
161: }
162:
163: return ($value !== null);
164: }
165:
166: /**
167: * Returns the value from the specified key on this entity.
168: *
169: * Routine:
170: * - If the data key is {@see SeparatorAwareTrait::$separator nested},
171: * the data-tree is traversed until the endpoint to return its value, if any;
172: * - If the data key does NOT exist on the store, a lookup is performed
173: * on each delegate store until a value is found, if any.
174: *
175: * @see \ArrayAccess
176: * @uses SeparatorAwareTrait::getWithSeparator()
177: * @uses DelegatesAwareTrait::getInDelegates()
178: * @param string $key The data key to retrieve.
179: * @throws InvalidArgumentException If the $key is not a string or is a numeric value.
180: * @return mixed Value of the requested $key on success, NULL if the $key is not set.
181: */
182: public function offsetGet($key)
183: {
184: if (is_numeric($key)) {
185: throw new InvalidArgumentException(
186: 'Entity array access only supports non-numeric keys'
187: );
188: }
189:
190: if ($this->separator && strstr($key, $this->separator)) {
191: return $this->getWithSeparator($key);
192: }
193:
194: $key = $this->camelize($key);
195:
196: /** @internal Edge Case: "_" → "" */
197: if ($key === '') {
198: return null;
199: }
200:
201: if (is_callable([ $this, $key ])) {
202: return $this->{$key}();
203: } else {
204: if (isset($this->{$key})) {
205: return $this->{$key};
206: } else {
207: return $this->getInDelegates($key);
208: }
209: }
210: }
211:
212: /**
213: * Assigns the value to the specified key on this entity.
214: *
215: * Routine:
216: * - If the data key is {@see SeparatorAwareTrait::$separator nested},
217: * the data-tree is traversed until the endpoint to assign its value;
218: *
219: * @see \ArrayAccess
220: * @uses SeparatorAwareTrait::setWithSeparator()
221: * @param string $key The data key to assign $value to.
222: * @param mixed $value The data value to assign to $key.
223: * @throws InvalidArgumentException If the $key is not a string or is a numeric value.
224: * @return void
225: */
226: public function offsetSet($key, $value)
227: {
228: if (is_numeric($key)) {
229: throw new InvalidArgumentException(
230: 'Entity array access only supports non-numeric keys'
231: );
232: }
233:
234: if ($this->separator && strstr($key, $this->separator)) {
235: $this->setWithSeparator($key, $value);
236: return;
237: }
238:
239: $key = $this->camelize($key);
240:
241: /** @internal Edge Case: "_" → "" */
242: if ($key === '') {
243: return;
244: }
245:
246: $setter = 'set'.ucfirst($key);
247: if (is_callable([ $this, $setter ])) {
248: $this->{$setter}($value);
249: } else {
250: $this->{$key} = $value;
251: }
252:
253: $this->keys[$key] = true;
254: }
255:
256: /**
257: * Replaces the value from the specified key.
258: *
259: * Routine:
260: * - When the value in the Config and the new value are both arrays,
261: * the method will replace their respective value recursively.
262: * - Then or otherwise, the new value is {@see self::offsetSet() assigned} to the Config.
263: *
264: * @uses self::offsetSet()
265: * @uses array_replace_recursive()
266: * @param string $key The data key to assign or merge $value to.
267: * @param mixed $value The data value to assign to or merge with $key.
268: * @throws InvalidArgumentException If the $key is not a string or is a numeric value.
269: * @return void
270: */
271: public function offsetReplace($key, $value)
272: {
273: if (is_numeric($key)) {
274: throw new InvalidArgumentException(
275: 'Entity array access only supports non-numeric keys'
276: );
277: }
278:
279: $key = $this->camelize($key);
280:
281: /** @internal Edge Case: "_" → "" */
282: if ($key === '') {
283: return;
284: }
285:
286: if (is_array($value) && isset($this[$key])) {
287: $data = $this[$key];
288: if (is_array($data)) {
289: $value = array_replace_recursive($data, $value);
290: }
291: }
292:
293: $this[$key] = $value;
294: }
295:
296: /**
297: * Adds a configuration file to the configset.
298: *
299: * Natively supported file formats: INI, JSON, PHP.
300: *
301: * @uses FileAwareTrait::loadFile()
302: * @param string $path The file to load and add.
303: * @return self
304: */
305: public function addFile($path)
306: {
307: $config = $this->loadFile($path);
308: if (is_array($config)) {
309: $this->merge($config);
310: }
311: return $this;
312: }
313: }
314: