1<?php 2 3/* 4 * This file is part of the Symfony package. 5 * 6 * (c) Fabien Potencier <fabien@symfony.com> 7 * 8 * For the full copyright and license information, please view the LICENSE 9 * file that was distributed with this source code. 10 */ 11 12namespace Symfony\Component\Config\Definition; 13 14use Symfony\Component\Config\Definition\Exception\DuplicateKeyException; 15use Symfony\Component\Config\Definition\Exception\Exception; 16use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; 17use Symfony\Component\Config\Definition\Exception\UnsetKeyException; 18 19/** 20 * Represents a prototyped Array node in the config tree. 21 * 22 * @author Johannes M. Schmitt <schmittjoh@gmail.com> 23 */ 24class PrototypedArrayNode extends ArrayNode 25{ 26 protected $prototype; 27 protected $keyAttribute; 28 protected $removeKeyAttribute = false; 29 protected $minNumberOfElements = 0; 30 protected $defaultValue = []; 31 protected $defaultChildren; 32 /** 33 * @var NodeInterface[] An array of the prototypes of the simplified value children 34 */ 35 private $valuePrototypes = []; 36 37 /** 38 * Sets the minimum number of elements that a prototype based node must 39 * contain. By default this is zero, meaning no elements. 40 */ 41 public function setMinNumberOfElements(int $number) 42 { 43 $this->minNumberOfElements = $number; 44 } 45 46 /** 47 * Sets the attribute which value is to be used as key. 48 * 49 * This is useful when you have an indexed array that should be an 50 * associative array. You can select an item from within the array 51 * to be the key of the particular item. For example, if "id" is the 52 * "key", then: 53 * 54 * [ 55 * ['id' => 'my_name', 'foo' => 'bar'], 56 * ]; 57 * 58 * becomes 59 * 60 * [ 61 * 'my_name' => ['foo' => 'bar'], 62 * ]; 63 * 64 * If you'd like "'id' => 'my_name'" to still be present in the resulting 65 * array, then you can set the second argument of this method to false. 66 * 67 * @param string $attribute The name of the attribute which value is to be used as a key 68 * @param bool $remove Whether or not to remove the key 69 */ 70 public function setKeyAttribute(string $attribute, bool $remove = true) 71 { 72 $this->keyAttribute = $attribute; 73 $this->removeKeyAttribute = $remove; 74 } 75 76 /** 77 * Retrieves the name of the attribute which value should be used as key. 78 * 79 * @return string|null 80 */ 81 public function getKeyAttribute() 82 { 83 return $this->keyAttribute; 84 } 85 86 /** 87 * Sets the default value of this node. 88 */ 89 public function setDefaultValue(array $value) 90 { 91 $this->defaultValue = $value; 92 } 93 94 /** 95 * {@inheritdoc} 96 */ 97 public function hasDefaultValue() 98 { 99 return true; 100 } 101 102 /** 103 * Adds default children when none are set. 104 * 105 * @param int|string|array|null $children The number of children|The child name|The children names to be added 106 */ 107 public function setAddChildrenIfNoneSet($children = ['defaults']) 108 { 109 if (null === $children) { 110 $this->defaultChildren = ['defaults']; 111 } else { 112 $this->defaultChildren = \is_int($children) && $children > 0 ? range(1, $children) : (array) $children; 113 } 114 } 115 116 /** 117 * {@inheritdoc} 118 * 119 * The default value could be either explicited or derived from the prototype 120 * default value. 121 */ 122 public function getDefaultValue() 123 { 124 if (null !== $this->defaultChildren) { 125 $default = $this->prototype->hasDefaultValue() ? $this->prototype->getDefaultValue() : []; 126 $defaults = []; 127 foreach (array_values($this->defaultChildren) as $i => $name) { 128 $defaults[null === $this->keyAttribute ? $i : $name] = $default; 129 } 130 131 return $defaults; 132 } 133 134 return $this->defaultValue; 135 } 136 137 /** 138 * Sets the node prototype. 139 */ 140 public function setPrototype(PrototypeNodeInterface $node) 141 { 142 $this->prototype = $node; 143 } 144 145 /** 146 * Retrieves the prototype. 147 * 148 * @return PrototypeNodeInterface 149 */ 150 public function getPrototype() 151 { 152 return $this->prototype; 153 } 154 155 /** 156 * Disable adding concrete children for prototyped nodes. 157 * 158 * @throws Exception 159 */ 160 public function addChild(NodeInterface $node) 161 { 162 throw new Exception('A prototyped array node cannot have concrete children.'); 163 } 164 165 /** 166 * {@inheritdoc} 167 */ 168 protected function finalizeValue($value) 169 { 170 if (false === $value) { 171 throw new UnsetKeyException(sprintf('Unsetting key for path "%s", value: %s.', $this->getPath(), json_encode($value))); 172 } 173 174 foreach ($value as $k => $v) { 175 $prototype = $this->getPrototypeForChild($k); 176 try { 177 $value[$k] = $prototype->finalize($v); 178 } catch (UnsetKeyException $e) { 179 unset($value[$k]); 180 } 181 } 182 183 if (\count($value) < $this->minNumberOfElements) { 184 $ex = new InvalidConfigurationException(sprintf('The path "%s" should have at least %d element(s) defined.', $this->getPath(), $this->minNumberOfElements)); 185 $ex->setPath($this->getPath()); 186 187 throw $ex; 188 } 189 190 return $value; 191 } 192 193 /** 194 * {@inheritdoc} 195 * 196 * @throws DuplicateKeyException 197 */ 198 protected function normalizeValue($value) 199 { 200 if (false === $value) { 201 return $value; 202 } 203 204 $value = $this->remapXml($value); 205 206 $isList = array_is_list($value); 207 $normalized = []; 208 foreach ($value as $k => $v) { 209 if (null !== $this->keyAttribute && \is_array($v)) { 210 if (!isset($v[$this->keyAttribute]) && \is_int($k) && $isList) { 211 $ex = new InvalidConfigurationException(sprintf('The attribute "%s" must be set for path "%s".', $this->keyAttribute, $this->getPath())); 212 $ex->setPath($this->getPath()); 213 214 throw $ex; 215 } elseif (isset($v[$this->keyAttribute])) { 216 $k = $v[$this->keyAttribute]; 217 218 if (\is_float($k)) { 219 $k = var_export($k, true); 220 } 221 222 // remove the key attribute when required 223 if ($this->removeKeyAttribute) { 224 unset($v[$this->keyAttribute]); 225 } 226 227 // if only "value" is left 228 if (array_keys($v) === ['value']) { 229 $v = $v['value']; 230 if ($this->prototype instanceof ArrayNode && ($children = $this->prototype->getChildren()) && \array_key_exists('value', $children)) { 231 $valuePrototype = current($this->valuePrototypes) ?: clone $children['value']; 232 $valuePrototype->parent = $this; 233 $originalClosures = $this->prototype->normalizationClosures; 234 if (\is_array($originalClosures)) { 235 $valuePrototypeClosures = $valuePrototype->normalizationClosures; 236 $valuePrototype->normalizationClosures = \is_array($valuePrototypeClosures) ? array_merge($originalClosures, $valuePrototypeClosures) : $originalClosures; 237 } 238 $this->valuePrototypes[$k] = $valuePrototype; 239 } 240 } 241 } 242 243 if (\array_key_exists($k, $normalized)) { 244 $ex = new DuplicateKeyException(sprintf('Duplicate key "%s" for path "%s".', $k, $this->getPath())); 245 $ex->setPath($this->getPath()); 246 247 throw $ex; 248 } 249 } 250 251 $prototype = $this->getPrototypeForChild($k); 252 if (null !== $this->keyAttribute || !$isList) { 253 $normalized[$k] = $prototype->normalize($v); 254 } else { 255 $normalized[] = $prototype->normalize($v); 256 } 257 } 258 259 return $normalized; 260 } 261 262 /** 263 * {@inheritdoc} 264 */ 265 protected function mergeValues($leftSide, $rightSide) 266 { 267 if (false === $rightSide) { 268 // if this is still false after the last config has been merged the 269 // finalization pass will take care of removing this key entirely 270 return false; 271 } 272 273 if (false === $leftSide || !$this->performDeepMerging) { 274 return $rightSide; 275 } 276 277 $isList = array_is_list($rightSide); 278 foreach ($rightSide as $k => $v) { 279 // prototype, and key is irrelevant there are no named keys, append the element 280 if (null === $this->keyAttribute && $isList) { 281 $leftSide[] = $v; 282 continue; 283 } 284 285 // no conflict 286 if (!\array_key_exists($k, $leftSide)) { 287 if (!$this->allowNewKeys) { 288 $ex = new InvalidConfigurationException(sprintf('You are not allowed to define new elements for path "%s". Please define all elements for this path in one config file.', $this->getPath())); 289 $ex->setPath($this->getPath()); 290 291 throw $ex; 292 } 293 294 $leftSide[$k] = $v; 295 continue; 296 } 297 298 $prototype = $this->getPrototypeForChild($k); 299 $leftSide[$k] = $prototype->merge($leftSide[$k], $v); 300 } 301 302 return $leftSide; 303 } 304 305 /** 306 * Returns a prototype for the child node that is associated to $key in the value array. 307 * For general child nodes, this will be $this->prototype. 308 * But if $this->removeKeyAttribute is true and there are only two keys in the child node: 309 * one is same as this->keyAttribute and the other is 'value', then the prototype will be different. 310 * 311 * For example, assume $this->keyAttribute is 'name' and the value array is as follows: 312 * 313 * [ 314 * [ 315 * 'name' => 'name001', 316 * 'value' => 'value001' 317 * ] 318 * ] 319 * 320 * Now, the key is 0 and the child node is: 321 * 322 * [ 323 * 'name' => 'name001', 324 * 'value' => 'value001' 325 * ] 326 * 327 * When normalizing the value array, the 'name' element will removed from the child node 328 * and its value becomes the new key of the child node: 329 * 330 * [ 331 * 'name001' => ['value' => 'value001'] 332 * ] 333 * 334 * Now only 'value' element is left in the child node which can be further simplified into a string: 335 * 336 * ['name001' => 'value001'] 337 * 338 * Now, the key becomes 'name001' and the child node becomes 'value001' and 339 * the prototype of child node 'name001' should be a ScalarNode instead of an ArrayNode instance. 340 * 341 * @return mixed 342 */ 343 private function getPrototypeForChild(string $key) 344 { 345 $prototype = $this->valuePrototypes[$key] ?? $this->prototype; 346 $prototype->setName($key); 347 348 return $prototype; 349 } 350} 351