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\Exception; 15use Symfony\Component\Config\Definition\Exception\ForbiddenOverwriteException; 16use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; 17use Symfony\Component\Config\Definition\Exception\InvalidTypeException; 18use Symfony\Component\Config\Definition\Exception\UnsetKeyException; 19 20/** 21 * The base node class. 22 * 23 * @author Johannes M. Schmitt <schmittjoh@gmail.com> 24 */ 25abstract class BaseNode implements NodeInterface 26{ 27 public const DEFAULT_PATH_SEPARATOR = '.'; 28 29 private static $placeholderUniquePrefixes = []; 30 private static $placeholders = []; 31 32 protected $name; 33 protected $parent; 34 protected $normalizationClosures = []; 35 protected $finalValidationClosures = []; 36 protected $allowOverwrite = true; 37 protected $required = false; 38 protected $deprecation = []; 39 protected $equivalentValues = []; 40 protected $attributes = []; 41 protected $pathSeparator; 42 43 private $handlingPlaceholder; 44 45 /** 46 * @throws \InvalidArgumentException if the name contains a period 47 */ 48 public function __construct(?string $name, NodeInterface $parent = null, string $pathSeparator = self::DEFAULT_PATH_SEPARATOR) 49 { 50 if (str_contains($name = (string) $name, $pathSeparator)) { 51 throw new \InvalidArgumentException('The name must not contain ".'.$pathSeparator.'".'); 52 } 53 54 $this->name = $name; 55 $this->parent = $parent; 56 $this->pathSeparator = $pathSeparator; 57 } 58 59 /** 60 * Register possible (dummy) values for a dynamic placeholder value. 61 * 62 * Matching configuration values will be processed with a provided value, one by one. After a provided value is 63 * successfully processed the configuration value is returned as is, thus preserving the placeholder. 64 * 65 * @internal 66 */ 67 public static function setPlaceholder(string $placeholder, array $values): void 68 { 69 if (!$values) { 70 throw new \InvalidArgumentException('At least one value must be provided.'); 71 } 72 73 self::$placeholders[$placeholder] = $values; 74 } 75 76 /** 77 * Adds a common prefix for dynamic placeholder values. 78 * 79 * Matching configuration values will be skipped from being processed and are returned as is, thus preserving the 80 * placeholder. An exact match provided by {@see setPlaceholder()} might take precedence. 81 * 82 * @internal 83 */ 84 public static function setPlaceholderUniquePrefix(string $prefix): void 85 { 86 self::$placeholderUniquePrefixes[] = $prefix; 87 } 88 89 /** 90 * Resets all current placeholders available. 91 * 92 * @internal 93 */ 94 public static function resetPlaceholders(): void 95 { 96 self::$placeholderUniquePrefixes = []; 97 self::$placeholders = []; 98 } 99 100 public function setAttribute(string $key, $value) 101 { 102 $this->attributes[$key] = $value; 103 } 104 105 /** 106 * @return mixed 107 */ 108 public function getAttribute(string $key, $default = null) 109 { 110 return $this->attributes[$key] ?? $default; 111 } 112 113 /** 114 * @return bool 115 */ 116 public function hasAttribute(string $key) 117 { 118 return isset($this->attributes[$key]); 119 } 120 121 /** 122 * @return array 123 */ 124 public function getAttributes() 125 { 126 return $this->attributes; 127 } 128 129 public function setAttributes(array $attributes) 130 { 131 $this->attributes = $attributes; 132 } 133 134 public function removeAttribute(string $key) 135 { 136 unset($this->attributes[$key]); 137 } 138 139 /** 140 * Sets an info message. 141 */ 142 public function setInfo(string $info) 143 { 144 $this->setAttribute('info', $info); 145 } 146 147 /** 148 * Returns info message. 149 * 150 * @return string|null 151 */ 152 public function getInfo() 153 { 154 return $this->getAttribute('info'); 155 } 156 157 /** 158 * Sets the example configuration for this node. 159 * 160 * @param string|array $example 161 */ 162 public function setExample($example) 163 { 164 $this->setAttribute('example', $example); 165 } 166 167 /** 168 * Retrieves the example configuration for this node. 169 * 170 * @return string|array|null 171 */ 172 public function getExample() 173 { 174 return $this->getAttribute('example'); 175 } 176 177 /** 178 * Adds an equivalent value. 179 * 180 * @param mixed $originalValue 181 * @param mixed $equivalentValue 182 */ 183 public function addEquivalentValue($originalValue, $equivalentValue) 184 { 185 $this->equivalentValues[] = [$originalValue, $equivalentValue]; 186 } 187 188 /** 189 * Set this node as required. 190 */ 191 public function setRequired(bool $boolean) 192 { 193 $this->required = $boolean; 194 } 195 196 /** 197 * Sets this node as deprecated. 198 * 199 * @param string $package The name of the composer package that is triggering the deprecation 200 * @param string $version The version of the package that introduced the deprecation 201 * @param string $message the deprecation message to use 202 * 203 * You can use %node% and %path% placeholders in your message to display, 204 * respectively, the node name and its complete path 205 */ 206 public function setDeprecated(?string $package/*, string $version, string $message = 'The child node "%node%" at path "%path%" is deprecated.' */) 207 { 208 $args = \func_get_args(); 209 210 if (\func_num_args() < 2) { 211 trigger_deprecation('symfony/config', '5.1', 'The signature of method "%s()" requires 3 arguments: "string $package, string $version, string $message", not defining them is deprecated.', __METHOD__); 212 213 if (!isset($args[0])) { 214 trigger_deprecation('symfony/config', '5.1', 'Passing a null message to un-deprecate a node is deprecated.'); 215 216 $this->deprecation = []; 217 218 return; 219 } 220 221 $message = (string) $args[0]; 222 $package = $version = ''; 223 } else { 224 $package = (string) $args[0]; 225 $version = (string) $args[1]; 226 $message = (string) ($args[2] ?? 'The child node "%node%" at path "%path%" is deprecated.'); 227 } 228 229 $this->deprecation = [ 230 'package' => $package, 231 'version' => $version, 232 'message' => $message, 233 ]; 234 } 235 236 /** 237 * Sets if this node can be overridden. 238 */ 239 public function setAllowOverwrite(bool $allow) 240 { 241 $this->allowOverwrite = $allow; 242 } 243 244 /** 245 * Sets the closures used for normalization. 246 * 247 * @param \Closure[] $closures An array of Closures used for normalization 248 */ 249 public function setNormalizationClosures(array $closures) 250 { 251 $this->normalizationClosures = $closures; 252 } 253 254 /** 255 * Sets the closures used for final validation. 256 * 257 * @param \Closure[] $closures An array of Closures used for final validation 258 */ 259 public function setFinalValidationClosures(array $closures) 260 { 261 $this->finalValidationClosures = $closures; 262 } 263 264 /** 265 * {@inheritdoc} 266 */ 267 public function isRequired() 268 { 269 return $this->required; 270 } 271 272 /** 273 * Checks if this node is deprecated. 274 * 275 * @return bool 276 */ 277 public function isDeprecated() 278 { 279 return (bool) $this->deprecation; 280 } 281 282 /** 283 * Returns the deprecated message. 284 * 285 * @param string $node the configuration node name 286 * @param string $path the path of the node 287 * 288 * @return string 289 * 290 * @deprecated since Symfony 5.1, use "getDeprecation()" instead. 291 */ 292 public function getDeprecationMessage(string $node, string $path) 293 { 294 trigger_deprecation('symfony/config', '5.1', 'The "%s()" method is deprecated, use "getDeprecation()" instead.', __METHOD__); 295 296 return $this->getDeprecation($node, $path)['message']; 297 } 298 299 /** 300 * @param string $node The configuration node name 301 * @param string $path The path of the node 302 */ 303 public function getDeprecation(string $node, string $path): array 304 { 305 return [ 306 'package' => $this->deprecation['package'] ?? '', 307 'version' => $this->deprecation['version'] ?? '', 308 'message' => strtr($this->deprecation['message'] ?? '', ['%node%' => $node, '%path%' => $path]), 309 ]; 310 } 311 312 /** 313 * {@inheritdoc} 314 */ 315 public function getName() 316 { 317 return $this->name; 318 } 319 320 /** 321 * {@inheritdoc} 322 */ 323 public function getPath() 324 { 325 if (null !== $this->parent) { 326 return $this->parent->getPath().$this->pathSeparator.$this->name; 327 } 328 329 return $this->name; 330 } 331 332 /** 333 * {@inheritdoc} 334 */ 335 final public function merge($leftSide, $rightSide) 336 { 337 if (!$this->allowOverwrite) { 338 throw new ForbiddenOverwriteException(sprintf('Configuration path "%s" cannot be overwritten. You have to define all options for this path, and any of its sub-paths in one configuration section.', $this->getPath())); 339 } 340 341 if ($leftSide !== $leftPlaceholders = self::resolvePlaceholderValue($leftSide)) { 342 foreach ($leftPlaceholders as $leftPlaceholder) { 343 $this->handlingPlaceholder = $leftSide; 344 try { 345 $this->merge($leftPlaceholder, $rightSide); 346 } finally { 347 $this->handlingPlaceholder = null; 348 } 349 } 350 351 return $rightSide; 352 } 353 354 if ($rightSide !== $rightPlaceholders = self::resolvePlaceholderValue($rightSide)) { 355 foreach ($rightPlaceholders as $rightPlaceholder) { 356 $this->handlingPlaceholder = $rightSide; 357 try { 358 $this->merge($leftSide, $rightPlaceholder); 359 } finally { 360 $this->handlingPlaceholder = null; 361 } 362 } 363 364 return $rightSide; 365 } 366 367 $this->doValidateType($leftSide); 368 $this->doValidateType($rightSide); 369 370 return $this->mergeValues($leftSide, $rightSide); 371 } 372 373 /** 374 * {@inheritdoc} 375 */ 376 final public function normalize($value) 377 { 378 $value = $this->preNormalize($value); 379 380 // run custom normalization closures 381 foreach ($this->normalizationClosures as $closure) { 382 $value = $closure($value); 383 } 384 385 // resolve placeholder value 386 if ($value !== $placeholders = self::resolvePlaceholderValue($value)) { 387 foreach ($placeholders as $placeholder) { 388 $this->handlingPlaceholder = $value; 389 try { 390 $this->normalize($placeholder); 391 } finally { 392 $this->handlingPlaceholder = null; 393 } 394 } 395 396 return $value; 397 } 398 399 // replace value with their equivalent 400 foreach ($this->equivalentValues as $data) { 401 if ($data[0] === $value) { 402 $value = $data[1]; 403 } 404 } 405 406 // validate type 407 $this->doValidateType($value); 408 409 // normalize value 410 return $this->normalizeValue($value); 411 } 412 413 /** 414 * Normalizes the value before any other normalization is applied. 415 * 416 * @param mixed $value 417 * 418 * @return mixed 419 */ 420 protected function preNormalize($value) 421 { 422 return $value; 423 } 424 425 /** 426 * Returns parent node for this node. 427 * 428 * @return NodeInterface|null 429 */ 430 public function getParent() 431 { 432 return $this->parent; 433 } 434 435 /** 436 * {@inheritdoc} 437 */ 438 final public function finalize($value) 439 { 440 if ($value !== $placeholders = self::resolvePlaceholderValue($value)) { 441 foreach ($placeholders as $placeholder) { 442 $this->handlingPlaceholder = $value; 443 try { 444 $this->finalize($placeholder); 445 } finally { 446 $this->handlingPlaceholder = null; 447 } 448 } 449 450 return $value; 451 } 452 453 $this->doValidateType($value); 454 455 $value = $this->finalizeValue($value); 456 457 // Perform validation on the final value if a closure has been set. 458 // The closure is also allowed to return another value. 459 foreach ($this->finalValidationClosures as $closure) { 460 try { 461 $value = $closure($value); 462 } catch (Exception $e) { 463 if ($e instanceof UnsetKeyException && null !== $this->handlingPlaceholder) { 464 continue; 465 } 466 467 throw $e; 468 } catch (\Exception $e) { 469 throw new InvalidConfigurationException(sprintf('Invalid configuration for path "%s": ', $this->getPath()).$e->getMessage(), $e->getCode(), $e); 470 } 471 } 472 473 return $value; 474 } 475 476 /** 477 * Validates the type of a Node. 478 * 479 * @param mixed $value The value to validate 480 * 481 * @throws InvalidTypeException when the value is invalid 482 */ 483 abstract protected function validateType($value); 484 485 /** 486 * Normalizes the value. 487 * 488 * @param mixed $value The value to normalize 489 * 490 * @return mixed 491 */ 492 abstract protected function normalizeValue($value); 493 494 /** 495 * Merges two values together. 496 * 497 * @param mixed $leftSide 498 * @param mixed $rightSide 499 * 500 * @return mixed 501 */ 502 abstract protected function mergeValues($leftSide, $rightSide); 503 504 /** 505 * Finalizes a value. 506 * 507 * @param mixed $value The value to finalize 508 * 509 * @return mixed 510 */ 511 abstract protected function finalizeValue($value); 512 513 /** 514 * Tests if placeholder values are allowed for this node. 515 */ 516 protected function allowPlaceholders(): bool 517 { 518 return true; 519 } 520 521 /** 522 * Tests if a placeholder is being handled currently. 523 */ 524 protected function isHandlingPlaceholder(): bool 525 { 526 return null !== $this->handlingPlaceholder; 527 } 528 529 /** 530 * Gets allowed dynamic types for this node. 531 */ 532 protected function getValidPlaceholderTypes(): array 533 { 534 return []; 535 } 536 537 private static function resolvePlaceholderValue($value) 538 { 539 if (\is_string($value)) { 540 if (isset(self::$placeholders[$value])) { 541 return self::$placeholders[$value]; 542 } 543 544 foreach (self::$placeholderUniquePrefixes as $placeholderUniquePrefix) { 545 if (str_starts_with($value, $placeholderUniquePrefix)) { 546 return []; 547 } 548 } 549 } 550 551 return $value; 552 } 553 554 private function doValidateType($value): void 555 { 556 if (null !== $this->handlingPlaceholder && !$this->allowPlaceholders()) { 557 $e = new InvalidTypeException(sprintf('A dynamic value is not compatible with a "%s" node type at path "%s".', static::class, $this->getPath())); 558 $e->setPath($this->getPath()); 559 560 throw $e; 561 } 562 563 if (null === $this->handlingPlaceholder || null === $value) { 564 $this->validateType($value); 565 566 return; 567 } 568 569 $knownTypes = array_keys(self::$placeholders[$this->handlingPlaceholder]); 570 $validTypes = $this->getValidPlaceholderTypes(); 571 572 if ($validTypes && array_diff($knownTypes, $validTypes)) { 573 $e = new InvalidTypeException(sprintf( 574 'Invalid type for path "%s". Expected %s, but got %s.', 575 $this->getPath(), 576 1 === \count($validTypes) ? '"'.reset($validTypes).'"' : 'one of "'.implode('", "', $validTypes).'"', 577 1 === \count($knownTypes) ? '"'.reset($knownTypes).'"' : 'one of "'.implode('", "', $knownTypes).'"' 578 )); 579 if ($hint = $this->getInfo()) { 580 $e->addHint($hint); 581 } 582 $e->setPath($this->getPath()); 583 584 throw $e; 585 } 586 587 $this->validateType($value); 588 } 589} 590