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\PropertyAccess; 13 14use Psr\Cache\CacheItemPoolInterface; 15use Psr\Log\LoggerInterface; 16use Psr\Log\NullLogger; 17use Symfony\Component\Cache\Adapter\AdapterInterface; 18use Symfony\Component\Cache\Adapter\ApcuAdapter; 19use Symfony\Component\Cache\Adapter\NullAdapter; 20use Symfony\Component\Inflector\Inflector; 21use Symfony\Component\PropertyAccess\Exception\AccessException; 22use Symfony\Component\PropertyAccess\Exception\InvalidArgumentException; 23use Symfony\Component\PropertyAccess\Exception\NoSuchIndexException; 24use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; 25use Symfony\Component\PropertyAccess\Exception\UnexpectedTypeException; 26 27/** 28 * Default implementation of {@link PropertyAccessorInterface}. 29 * 30 * @author Bernhard Schussek <bschussek@gmail.com> 31 * @author Kévin Dunglas <dunglas@gmail.com> 32 * @author Nicolas Grekas <p@tchwork.com> 33 */ 34class PropertyAccessor implements PropertyAccessorInterface 35{ 36 private const VALUE = 0; 37 private const REF = 1; 38 private const IS_REF_CHAINED = 2; 39 private const ACCESS_HAS_PROPERTY = 0; 40 private const ACCESS_TYPE = 1; 41 private const ACCESS_NAME = 2; 42 private const ACCESS_REF = 3; 43 private const ACCESS_ADDER = 4; 44 private const ACCESS_REMOVER = 5; 45 private const ACCESS_TYPE_METHOD = 0; 46 private const ACCESS_TYPE_PROPERTY = 1; 47 private const ACCESS_TYPE_MAGIC = 2; 48 private const ACCESS_TYPE_ADDER_AND_REMOVER = 3; 49 private const ACCESS_TYPE_NOT_FOUND = 4; 50 private const CACHE_PREFIX_READ = 'r'; 51 private const CACHE_PREFIX_WRITE = 'w'; 52 private const CACHE_PREFIX_PROPERTY_PATH = 'p'; 53 54 /** 55 * @var bool 56 */ 57 private $magicCall; 58 private $ignoreInvalidIndices; 59 private $ignoreInvalidProperty; 60 61 /** 62 * @var CacheItemPoolInterface 63 */ 64 private $cacheItemPool; 65 66 private $propertyPathCache = []; 67 private $readPropertyCache = []; 68 private $writePropertyCache = []; 69 private static $resultProto = [self::VALUE => null]; 70 71 /** 72 * Should not be used by application code. Use 73 * {@link PropertyAccess::createPropertyAccessor()} instead. 74 */ 75 public function __construct(bool $magicCall = false, bool $throwExceptionOnInvalidIndex = false, CacheItemPoolInterface $cacheItemPool = null, bool $throwExceptionOnInvalidPropertyPath = true) 76 { 77 $this->magicCall = $magicCall; 78 $this->ignoreInvalidIndices = !$throwExceptionOnInvalidIndex; 79 $this->cacheItemPool = $cacheItemPool instanceof NullAdapter ? null : $cacheItemPool; // Replace the NullAdapter by the null value 80 $this->ignoreInvalidProperty = !$throwExceptionOnInvalidPropertyPath; 81 } 82 83 /** 84 * {@inheritdoc} 85 */ 86 public function getValue($objectOrArray, $propertyPath) 87 { 88 $zval = [ 89 self::VALUE => $objectOrArray, 90 ]; 91 92 if (\is_object($objectOrArray) && false === strpbrk((string) $propertyPath, '.[')) { 93 return $this->readProperty($zval, $propertyPath, $this->ignoreInvalidProperty)[self::VALUE]; 94 } 95 96 $propertyPath = $this->getPropertyPath($propertyPath); 97 98 $propertyValues = $this->readPropertiesUntil($zval, $propertyPath, $propertyPath->getLength(), $this->ignoreInvalidIndices); 99 100 return $propertyValues[\count($propertyValues) - 1][self::VALUE]; 101 } 102 103 /** 104 * {@inheritdoc} 105 */ 106 public function setValue(&$objectOrArray, $propertyPath, $value) 107 { 108 if (\is_object($objectOrArray) && false === strpbrk((string) $propertyPath, '.[')) { 109 $zval = [ 110 self::VALUE => $objectOrArray, 111 ]; 112 113 try { 114 $this->writeProperty($zval, $propertyPath, $value); 115 116 return; 117 } catch (\TypeError $e) { 118 self::throwInvalidArgumentException($e->getMessage(), $e->getTrace(), 0, $propertyPath); 119 // It wasn't thrown in this class so rethrow it 120 throw $e; 121 } 122 } 123 124 $propertyPath = $this->getPropertyPath($propertyPath); 125 126 $zval = [ 127 self::VALUE => $objectOrArray, 128 self::REF => &$objectOrArray, 129 ]; 130 $propertyValues = $this->readPropertiesUntil($zval, $propertyPath, $propertyPath->getLength() - 1); 131 $overwrite = true; 132 133 try { 134 for ($i = \count($propertyValues) - 1; 0 <= $i; --$i) { 135 $zval = $propertyValues[$i]; 136 unset($propertyValues[$i]); 137 138 // You only need set value for current element if: 139 // 1. it's the parent of the last index element 140 // OR 141 // 2. its child is not passed by reference 142 // 143 // This may avoid uncessary value setting process for array elements. 144 // For example: 145 // '[a][b][c]' => 'old-value' 146 // If you want to change its value to 'new-value', 147 // you only need set value for '[a][b][c]' and it's safe to ignore '[a][b]' and '[a]' 148 if ($overwrite) { 149 $property = $propertyPath->getElement($i); 150 151 if ($propertyPath->isIndex($i)) { 152 if ($overwrite = !isset($zval[self::REF])) { 153 $ref = &$zval[self::REF]; 154 $ref = $zval[self::VALUE]; 155 } 156 $this->writeIndex($zval, $property, $value); 157 if ($overwrite) { 158 $zval[self::VALUE] = $zval[self::REF]; 159 } 160 } else { 161 $this->writeProperty($zval, $property, $value); 162 } 163 164 // if current element is an object 165 // OR 166 // if current element's reference chain is not broken - current element 167 // as well as all its ancients in the property path are all passed by reference, 168 // then there is no need to continue the value setting process 169 if (\is_object($zval[self::VALUE]) || isset($zval[self::IS_REF_CHAINED])) { 170 break; 171 } 172 } 173 174 $value = $zval[self::VALUE]; 175 } 176 } catch (\TypeError $e) { 177 self::throwInvalidArgumentException($e->getMessage(), $e->getTrace(), 0, $propertyPath, $e); 178 179 // It wasn't thrown in this class so rethrow it 180 throw $e; 181 } 182 } 183 184 private static function throwInvalidArgumentException(string $message, array $trace, int $i, string $propertyPath, \Throwable $previous = null): void 185 { 186 // the type mismatch is not caused by invalid arguments (but e.g. by an incompatible return type hint of the writer method) 187 if (0 !== strpos($message, 'Argument ')) { 188 return; 189 } 190 191 if (isset($trace[$i]['file']) && __FILE__ === $trace[$i]['file']) { 192 $pos = strpos($message, $delim = 'must be of the type ') ?: (strpos($message, $delim = 'must be an instance of ') ?: strpos($message, $delim = 'must implement interface ')); 193 $pos += \strlen($delim); 194 $j = strpos($message, ',', $pos); 195 $type = substr($message, 2 + $j, strpos($message, ' given', $j) - $j - 2); 196 $message = substr($message, $pos, $j - $pos); 197 198 throw new InvalidArgumentException(sprintf('Expected argument of type "%s", "%s" given at property path "%s".', $message, 'NULL' === $type ? 'null' : $type, $propertyPath), 0, $previous); 199 } 200 } 201 202 /** 203 * {@inheritdoc} 204 */ 205 public function isReadable($objectOrArray, $propertyPath) 206 { 207 if (!$propertyPath instanceof PropertyPathInterface) { 208 $propertyPath = new PropertyPath($propertyPath); 209 } 210 211 try { 212 $zval = [ 213 self::VALUE => $objectOrArray, 214 ]; 215 $this->readPropertiesUntil($zval, $propertyPath, $propertyPath->getLength(), $this->ignoreInvalidIndices); 216 217 return true; 218 } catch (AccessException $e) { 219 return false; 220 } catch (UnexpectedTypeException $e) { 221 return false; 222 } 223 } 224 225 /** 226 * {@inheritdoc} 227 */ 228 public function isWritable($objectOrArray, $propertyPath) 229 { 230 $propertyPath = $this->getPropertyPath($propertyPath); 231 232 try { 233 $zval = [ 234 self::VALUE => $objectOrArray, 235 ]; 236 $propertyValues = $this->readPropertiesUntil($zval, $propertyPath, $propertyPath->getLength() - 1); 237 238 for ($i = \count($propertyValues) - 1; 0 <= $i; --$i) { 239 $zval = $propertyValues[$i]; 240 unset($propertyValues[$i]); 241 242 if ($propertyPath->isIndex($i)) { 243 if (!$zval[self::VALUE] instanceof \ArrayAccess && !\is_array($zval[self::VALUE])) { 244 return false; 245 } 246 } else { 247 if (!$this->isPropertyWritable($zval[self::VALUE], $propertyPath->getElement($i))) { 248 return false; 249 } 250 } 251 252 if (\is_object($zval[self::VALUE])) { 253 return true; 254 } 255 } 256 257 return true; 258 } catch (AccessException $e) { 259 return false; 260 } catch (UnexpectedTypeException $e) { 261 return false; 262 } 263 } 264 265 /** 266 * Reads the path from an object up to a given path index. 267 * 268 * @throws UnexpectedTypeException if a value within the path is neither object nor array 269 * @throws NoSuchIndexException If a non-existing index is accessed 270 */ 271 private function readPropertiesUntil(array $zval, PropertyPathInterface $propertyPath, int $lastIndex, bool $ignoreInvalidIndices = true): array 272 { 273 if (!\is_object($zval[self::VALUE]) && !\is_array($zval[self::VALUE])) { 274 throw new UnexpectedTypeException($zval[self::VALUE], $propertyPath, 0); 275 } 276 277 // Add the root object to the list 278 $propertyValues = [$zval]; 279 280 for ($i = 0; $i < $lastIndex; ++$i) { 281 $property = $propertyPath->getElement($i); 282 $isIndex = $propertyPath->isIndex($i); 283 284 if ($isIndex) { 285 // Create missing nested arrays on demand 286 if (($zval[self::VALUE] instanceof \ArrayAccess && !$zval[self::VALUE]->offsetExists($property)) || 287 (\is_array($zval[self::VALUE]) && !isset($zval[self::VALUE][$property]) && !\array_key_exists($property, $zval[self::VALUE])) 288 ) { 289 if (!$ignoreInvalidIndices) { 290 if (!\is_array($zval[self::VALUE])) { 291 if (!$zval[self::VALUE] instanceof \Traversable) { 292 throw new NoSuchIndexException(sprintf('Cannot read index "%s" while trying to traverse path "%s".', $property, (string) $propertyPath)); 293 } 294 295 $zval[self::VALUE] = iterator_to_array($zval[self::VALUE]); 296 } 297 298 throw new NoSuchIndexException(sprintf('Cannot read index "%s" while trying to traverse path "%s". Available indices are "%s".', $property, (string) $propertyPath, print_r(array_keys($zval[self::VALUE]), true))); 299 } 300 301 if ($i + 1 < $propertyPath->getLength()) { 302 if (isset($zval[self::REF])) { 303 $zval[self::VALUE][$property] = []; 304 $zval[self::REF] = $zval[self::VALUE]; 305 } else { 306 $zval[self::VALUE] = [$property => []]; 307 } 308 } 309 } 310 311 $zval = $this->readIndex($zval, $property); 312 } else { 313 $zval = $this->readProperty($zval, $property, $this->ignoreInvalidProperty); 314 } 315 316 // the final value of the path must not be validated 317 if ($i + 1 < $propertyPath->getLength() && !\is_object($zval[self::VALUE]) && !\is_array($zval[self::VALUE])) { 318 throw new UnexpectedTypeException($zval[self::VALUE], $propertyPath, $i + 1); 319 } 320 321 if (isset($zval[self::REF]) && (0 === $i || isset($propertyValues[$i - 1][self::IS_REF_CHAINED]))) { 322 // Set the IS_REF_CHAINED flag to true if: 323 // current property is passed by reference and 324 // it is the first element in the property path or 325 // the IS_REF_CHAINED flag of its parent element is true 326 // Basically, this flag is true only when the reference chain from the top element to current element is not broken 327 $zval[self::IS_REF_CHAINED] = true; 328 } 329 330 $propertyValues[] = $zval; 331 } 332 333 return $propertyValues; 334 } 335 336 /** 337 * Reads a key from an array-like structure. 338 * 339 * @param string|int $index The key to read 340 * 341 * @throws NoSuchIndexException If the array does not implement \ArrayAccess or it is not an array 342 */ 343 private function readIndex(array $zval, $index): array 344 { 345 if (!$zval[self::VALUE] instanceof \ArrayAccess && !\is_array($zval[self::VALUE])) { 346 throw new NoSuchIndexException(sprintf('Cannot read index "%s" from object of type "%s" because it doesn\'t implement \ArrayAccess.', $index, \get_class($zval[self::VALUE]))); 347 } 348 349 $result = self::$resultProto; 350 351 if (isset($zval[self::VALUE][$index])) { 352 $result[self::VALUE] = $zval[self::VALUE][$index]; 353 354 if (!isset($zval[self::REF])) { 355 // Save creating references when doing read-only lookups 356 } elseif (\is_array($zval[self::VALUE])) { 357 $result[self::REF] = &$zval[self::REF][$index]; 358 } elseif (\is_object($result[self::VALUE])) { 359 $result[self::REF] = $result[self::VALUE]; 360 } 361 } 362 363 return $result; 364 } 365 366 /** 367 * Reads the a property from an object. 368 * 369 * @throws NoSuchPropertyException If $ignoreInvalidProperty is false and the property does not exist or is not public 370 */ 371 private function readProperty(array $zval, string $property, bool $ignoreInvalidProperty = false): array 372 { 373 if (!\is_object($zval[self::VALUE])) { 374 throw new NoSuchPropertyException(sprintf('Cannot read property "%s" from an array. Maybe you intended to write the property path as "[%1$s]" instead.', $property)); 375 } 376 377 $result = self::$resultProto; 378 $object = $zval[self::VALUE]; 379 $access = $this->getReadAccessInfo(\get_class($object), $property); 380 381 try { 382 if (self::ACCESS_TYPE_METHOD === $access[self::ACCESS_TYPE]) { 383 try { 384 $result[self::VALUE] = $object->{$access[self::ACCESS_NAME]}(); 385 } catch (\TypeError $e) { 386 // handle uninitialized properties in PHP >= 7 387 if (preg_match((sprintf('/^Return value of %s::%s\(\) must be of the type (\w+), null returned$/', preg_quote(\get_class($object)), $access[self::ACCESS_NAME])), $e->getMessage(), $matches)) { 388 throw new AccessException(sprintf('The method "%s::%s()" returned "null", but expected type "%3$s". Have you forgotten to initialize a property or to make the return type nullable using "?%3$s" instead?', \get_class($object), $access[self::ACCESS_NAME], $matches[1]), 0, $e); 389 } 390 391 throw $e; 392 } 393 } elseif (self::ACCESS_TYPE_PROPERTY === $access[self::ACCESS_TYPE]) { 394 $result[self::VALUE] = $object->{$access[self::ACCESS_NAME]}; 395 396 if ($access[self::ACCESS_REF] && isset($zval[self::REF])) { 397 $result[self::REF] = &$object->{$access[self::ACCESS_NAME]}; 398 } 399 } elseif (!$access[self::ACCESS_HAS_PROPERTY] && property_exists($object, $property)) { 400 // Needed to support \stdClass instances. We need to explicitly 401 // exclude $access[self::ACCESS_HAS_PROPERTY], otherwise if 402 // a *protected* property was found on the class, property_exists() 403 // returns true, consequently the following line will result in a 404 // fatal error. 405 406 $result[self::VALUE] = $object->$property; 407 if (isset($zval[self::REF])) { 408 $result[self::REF] = &$object->$property; 409 } 410 } elseif (self::ACCESS_TYPE_MAGIC === $access[self::ACCESS_TYPE]) { 411 // we call the getter and hope the __call do the job 412 $result[self::VALUE] = $object->{$access[self::ACCESS_NAME]}(); 413 } elseif (!$ignoreInvalidProperty) { 414 throw new NoSuchPropertyException($access[self::ACCESS_NAME]); 415 } 416 } catch (\Error $e) { 417 // handle uninitialized properties in PHP >= 7.4 418 if (\PHP_VERSION_ID >= 70400 && preg_match('/^Typed property ([\w\\\]+)::\$(\w+) must not be accessed before initialization$/', $e->getMessage(), $matches)) { 419 $r = new \ReflectionProperty($matches[1], $matches[2]); 420 421 throw new AccessException(sprintf('The property "%s::$%s" is not readable because it is typed "%3$s". You should either initialize it or make it nullable using "?%3$s" instead.', $r->getDeclaringClass()->getName(), $r->getName(), $r->getType()->getName()), 0, $e); 422 } 423 424 throw $e; 425 } 426 427 // Objects are always passed around by reference 428 if (isset($zval[self::REF]) && \is_object($result[self::VALUE])) { 429 $result[self::REF] = $result[self::VALUE]; 430 } 431 432 return $result; 433 } 434 435 /** 436 * Guesses how to read the property value. 437 */ 438 private function getReadAccessInfo(string $class, string $property): array 439 { 440 $key = str_replace('\\', '.', $class).'..'.$property; 441 442 if (isset($this->readPropertyCache[$key])) { 443 return $this->readPropertyCache[$key]; 444 } 445 446 if ($this->cacheItemPool) { 447 $item = $this->cacheItemPool->getItem(self::CACHE_PREFIX_READ.rawurlencode($key)); 448 if ($item->isHit()) { 449 return $this->readPropertyCache[$key] = $item->get(); 450 } 451 } 452 453 $access = []; 454 455 $reflClass = new \ReflectionClass($class); 456 $access[self::ACCESS_HAS_PROPERTY] = $reflClass->hasProperty($property); 457 $camelProp = $this->camelize($property); 458 $getter = 'get'.$camelProp; 459 $getsetter = lcfirst($camelProp); // jQuery style, e.g. read: last(), write: last($item) 460 $isser = 'is'.$camelProp; 461 $hasser = 'has'.$camelProp; 462 $canAccessor = 'can'.$camelProp; 463 464 if ($reflClass->hasMethod($getter) && $reflClass->getMethod($getter)->isPublic()) { 465 $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_METHOD; 466 $access[self::ACCESS_NAME] = $getter; 467 } elseif ($reflClass->hasMethod($getsetter) && $reflClass->getMethod($getsetter)->isPublic()) { 468 $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_METHOD; 469 $access[self::ACCESS_NAME] = $getsetter; 470 } elseif ($reflClass->hasMethod($isser) && $reflClass->getMethod($isser)->isPublic()) { 471 $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_METHOD; 472 $access[self::ACCESS_NAME] = $isser; 473 } elseif ($reflClass->hasMethod($hasser) && $reflClass->getMethod($hasser)->isPublic()) { 474 $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_METHOD; 475 $access[self::ACCESS_NAME] = $hasser; 476 } elseif ($reflClass->hasMethod($canAccessor) && $reflClass->getMethod($canAccessor)->isPublic()) { 477 $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_METHOD; 478 $access[self::ACCESS_NAME] = $canAccessor; 479 } elseif ($reflClass->hasMethod('__get') && $reflClass->getMethod('__get')->isPublic()) { 480 $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_PROPERTY; 481 $access[self::ACCESS_NAME] = $property; 482 $access[self::ACCESS_REF] = false; 483 } elseif ($access[self::ACCESS_HAS_PROPERTY] && $reflClass->getProperty($property)->isPublic()) { 484 $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_PROPERTY; 485 $access[self::ACCESS_NAME] = $property; 486 $access[self::ACCESS_REF] = true; 487 } elseif ($this->magicCall && $reflClass->hasMethod('__call') && $reflClass->getMethod('__call')->isPublic()) { 488 // we call the getter and hope the __call do the job 489 $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_MAGIC; 490 $access[self::ACCESS_NAME] = $getter; 491 } else { 492 $methods = [$getter, $getsetter, $isser, $hasser, '__get']; 493 if ($this->magicCall) { 494 $methods[] = '__call'; 495 } 496 497 $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_NOT_FOUND; 498 $access[self::ACCESS_NAME] = sprintf( 499 'Neither the property "%s" nor one of the methods "%s()" '. 500 'exist and have public access in class "%s".', 501 $property, 502 implode('()", "', $methods), 503 $reflClass->name 504 ); 505 } 506 507 if (isset($item)) { 508 $this->cacheItemPool->save($item->set($access)); 509 } 510 511 return $this->readPropertyCache[$key] = $access; 512 } 513 514 /** 515 * Sets the value of an index in a given array-accessible value. 516 * 517 * @param string|int $index The index to write at 518 * @param mixed $value The value to write 519 * 520 * @throws NoSuchIndexException If the array does not implement \ArrayAccess or it is not an array 521 */ 522 private function writeIndex(array $zval, $index, $value) 523 { 524 if (!$zval[self::VALUE] instanceof \ArrayAccess && !\is_array($zval[self::VALUE])) { 525 throw new NoSuchIndexException(sprintf('Cannot modify index "%s" in object of type "%s" because it doesn\'t implement \ArrayAccess.', $index, \get_class($zval[self::VALUE]))); 526 } 527 528 $zval[self::REF][$index] = $value; 529 } 530 531 /** 532 * Sets the value of a property in the given object. 533 * 534 * @param mixed $value The value to write 535 * 536 * @throws NoSuchPropertyException if the property does not exist or is not public 537 */ 538 private function writeProperty(array $zval, string $property, $value) 539 { 540 if (!\is_object($zval[self::VALUE])) { 541 throw new NoSuchPropertyException(sprintf('Cannot write property "%s" to an array. Maybe you should write the property path as "[%1$s]" instead?', $property)); 542 } 543 544 $object = $zval[self::VALUE]; 545 $access = $this->getWriteAccessInfo(\get_class($object), $property, $value); 546 547 if (self::ACCESS_TYPE_METHOD === $access[self::ACCESS_TYPE]) { 548 $object->{$access[self::ACCESS_NAME]}($value); 549 } elseif (self::ACCESS_TYPE_PROPERTY === $access[self::ACCESS_TYPE]) { 550 $object->{$access[self::ACCESS_NAME]} = $value; 551 } elseif (self::ACCESS_TYPE_ADDER_AND_REMOVER === $access[self::ACCESS_TYPE]) { 552 $this->writeCollection($zval, $property, $value, $access[self::ACCESS_ADDER], $access[self::ACCESS_REMOVER]); 553 } elseif (!$access[self::ACCESS_HAS_PROPERTY] && property_exists($object, $property)) { 554 // Needed to support \stdClass instances. We need to explicitly 555 // exclude $access[self::ACCESS_HAS_PROPERTY], otherwise if 556 // a *protected* property was found on the class, property_exists() 557 // returns true, consequently the following line will result in a 558 // fatal error. 559 560 $object->$property = $value; 561 } elseif (self::ACCESS_TYPE_MAGIC === $access[self::ACCESS_TYPE]) { 562 $object->{$access[self::ACCESS_NAME]}($value); 563 } elseif (self::ACCESS_TYPE_NOT_FOUND === $access[self::ACCESS_TYPE]) { 564 throw new NoSuchPropertyException(sprintf('Could not determine access type for property "%s" in class "%s"%s.', $property, \get_class($object), isset($access[self::ACCESS_NAME]) ? ': '.$access[self::ACCESS_NAME] : '.')); 565 } else { 566 throw new NoSuchPropertyException($access[self::ACCESS_NAME]); 567 } 568 } 569 570 /** 571 * Adjusts a collection-valued property by calling add*() and remove*() methods. 572 */ 573 private function writeCollection(array $zval, string $property, iterable $collection, string $addMethod, string $removeMethod) 574 { 575 // At this point the add and remove methods have been found 576 $previousValue = $this->readProperty($zval, $property); 577 $previousValue = $previousValue[self::VALUE]; 578 579 if ($previousValue instanceof \Traversable) { 580 $previousValue = iterator_to_array($previousValue); 581 } 582 if ($previousValue && \is_array($previousValue)) { 583 if (\is_object($collection)) { 584 $collection = iterator_to_array($collection); 585 } 586 foreach ($previousValue as $key => $item) { 587 if (!\in_array($item, $collection, true)) { 588 unset($previousValue[$key]); 589 $zval[self::VALUE]->{$removeMethod}($item); 590 } 591 } 592 } else { 593 $previousValue = false; 594 } 595 596 foreach ($collection as $item) { 597 if (!$previousValue || !\in_array($item, $previousValue, true)) { 598 $zval[self::VALUE]->{$addMethod}($item); 599 } 600 } 601 } 602 603 /** 604 * Guesses how to write the property value. 605 * 606 * @param mixed $value 607 */ 608 private function getWriteAccessInfo(string $class, string $property, $value): array 609 { 610 $useAdderAndRemover = \is_array($value) || $value instanceof \Traversable; 611 $key = str_replace('\\', '.', $class).'..'.$property.'..'.(int) $useAdderAndRemover; 612 613 if (isset($this->writePropertyCache[$key])) { 614 return $this->writePropertyCache[$key]; 615 } 616 617 if ($this->cacheItemPool) { 618 $item = $this->cacheItemPool->getItem(self::CACHE_PREFIX_WRITE.rawurlencode($key)); 619 if ($item->isHit()) { 620 return $this->writePropertyCache[$key] = $item->get(); 621 } 622 } 623 624 $access = []; 625 626 $reflClass = new \ReflectionClass($class); 627 $access[self::ACCESS_HAS_PROPERTY] = $reflClass->hasProperty($property); 628 $camelized = $this->camelize($property); 629 $singulars = (array) Inflector::singularize($camelized); 630 $errors = []; 631 632 if ($useAdderAndRemover) { 633 foreach ($this->findAdderAndRemover($reflClass, $singulars) as $methods) { 634 if (3 === \count($methods)) { 635 $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_ADDER_AND_REMOVER; 636 $access[self::ACCESS_ADDER] = $methods[self::ACCESS_ADDER]; 637 $access[self::ACCESS_REMOVER] = $methods[self::ACCESS_REMOVER]; 638 break; 639 } 640 641 if (isset($methods[self::ACCESS_ADDER])) { 642 $errors[] = sprintf('The add method "%s" in class "%s" was found, but the corresponding remove method "%s" was not found', $methods['methods'][self::ACCESS_ADDER], $reflClass->name, $methods['methods'][self::ACCESS_REMOVER]); 643 } 644 645 if (isset($methods[self::ACCESS_REMOVER])) { 646 $errors[] = sprintf('The remove method "%s" in class "%s" was found, but the corresponding add method "%s" was not found', $methods['methods'][self::ACCESS_REMOVER], $reflClass->name, $methods['methods'][self::ACCESS_ADDER]); 647 } 648 } 649 } 650 651 if (!isset($access[self::ACCESS_TYPE])) { 652 $setter = 'set'.$camelized; 653 $getsetter = lcfirst($camelized); // jQuery style, e.g. read: last(), write: last($item) 654 655 if ($this->isMethodAccessible($reflClass, $setter, 1)) { 656 $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_METHOD; 657 $access[self::ACCESS_NAME] = $setter; 658 } elseif ($this->isMethodAccessible($reflClass, $getsetter, 1)) { 659 $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_METHOD; 660 $access[self::ACCESS_NAME] = $getsetter; 661 } elseif ($this->isMethodAccessible($reflClass, '__set', 2)) { 662 $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_PROPERTY; 663 $access[self::ACCESS_NAME] = $property; 664 } elseif ($access[self::ACCESS_HAS_PROPERTY] && $reflClass->getProperty($property)->isPublic()) { 665 $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_PROPERTY; 666 $access[self::ACCESS_NAME] = $property; 667 } elseif ($this->magicCall && $this->isMethodAccessible($reflClass, '__call', 2)) { 668 // we call the getter and hope the __call do the job 669 $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_MAGIC; 670 $access[self::ACCESS_NAME] = $setter; 671 } else { 672 foreach ($this->findAdderAndRemover($reflClass, $singulars) as $methods) { 673 if (3 === \count($methods)) { 674 $errors[] = sprintf( 675 'The property "%s" in class "%s" can be defined with the methods "%s()" but '. 676 'the new value must be an array or an instance of \Traversable, '. 677 '"%s" given.', 678 $property, 679 $reflClass->name, 680 implode('()", "', [$methods[self::ACCESS_ADDER], $methods[self::ACCESS_REMOVER]]), 681 \is_object($value) ? \get_class($value) : \gettype($value) 682 ); 683 } 684 } 685 686 if (!isset($access[self::ACCESS_NAME])) { 687 $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_NOT_FOUND; 688 689 $triedMethods = [ 690 $setter => 1, 691 $getsetter => 1, 692 '__set' => 2, 693 '__call' => 2, 694 ]; 695 696 foreach ($singulars as $singular) { 697 $triedMethods['add'.$singular] = 1; 698 $triedMethods['remove'.$singular] = 1; 699 } 700 701 foreach ($triedMethods as $methodName => $parameters) { 702 if (!$reflClass->hasMethod($methodName)) { 703 continue; 704 } 705 706 $method = $reflClass->getMethod($methodName); 707 708 if (!$method->isPublic()) { 709 $errors[] = sprintf('The method "%s" in class "%s" was found but does not have public access', $methodName, $reflClass->name); 710 continue; 711 } 712 713 if ($method->getNumberOfRequiredParameters() > $parameters || $method->getNumberOfParameters() < $parameters) { 714 $errors[] = sprintf('The method "%s" in class "%s" requires %d arguments, but should accept only %d', $methodName, $reflClass->name, $method->getNumberOfRequiredParameters(), $parameters); 715 } 716 } 717 718 if (\count($errors)) { 719 $access[self::ACCESS_NAME] = implode('. ', $errors).'.'; 720 } else { 721 $access[self::ACCESS_NAME] = sprintf( 722 'Neither the property "%s" nor one of the methods %s"%s()", "%s()", '. 723 '"__set()" or "__call()" exist and have public access in class "%s".', 724 $property, 725 implode('', array_map(function ($singular) { 726 return '"add'.$singular.'()"/"remove'.$singular.'()", '; 727 }, $singulars)), 728 $setter, 729 $getsetter, 730 $reflClass->name 731 ); 732 } 733 } 734 } 735 } 736 737 if (isset($item)) { 738 $this->cacheItemPool->save($item->set($access)); 739 } 740 741 return $this->writePropertyCache[$key] = $access; 742 } 743 744 /** 745 * Returns whether a property is writable in the given object. 746 * 747 * @param object $object The object to write to 748 */ 749 private function isPropertyWritable($object, string $property): bool 750 { 751 if (!\is_object($object)) { 752 return false; 753 } 754 755 $access = $this->getWriteAccessInfo(\get_class($object), $property, []); 756 757 $isWritable = self::ACCESS_TYPE_METHOD === $access[self::ACCESS_TYPE] 758 || self::ACCESS_TYPE_PROPERTY === $access[self::ACCESS_TYPE] 759 || self::ACCESS_TYPE_ADDER_AND_REMOVER === $access[self::ACCESS_TYPE] 760 || (!$access[self::ACCESS_HAS_PROPERTY] && property_exists($object, $property)) 761 || self::ACCESS_TYPE_MAGIC === $access[self::ACCESS_TYPE]; 762 763 if ($isWritable) { 764 return true; 765 } 766 767 $access = $this->getWriteAccessInfo(\get_class($object), $property, ''); 768 769 return self::ACCESS_TYPE_METHOD === $access[self::ACCESS_TYPE] 770 || self::ACCESS_TYPE_PROPERTY === $access[self::ACCESS_TYPE] 771 || self::ACCESS_TYPE_ADDER_AND_REMOVER === $access[self::ACCESS_TYPE] 772 || (!$access[self::ACCESS_HAS_PROPERTY] && property_exists($object, $property)) 773 || self::ACCESS_TYPE_MAGIC === $access[self::ACCESS_TYPE]; 774 } 775 776 /** 777 * Camelizes a given string. 778 */ 779 private function camelize(string $string): string 780 { 781 return str_replace(' ', '', ucwords(str_replace('_', ' ', $string))); 782 } 783 784 /** 785 * Searches for add and remove methods. 786 */ 787 private function findAdderAndRemover(\ReflectionClass $reflClass, array $singulars): iterable 788 { 789 foreach ($singulars as $singular) { 790 $addMethod = 'add'.$singular; 791 $removeMethod = 'remove'.$singular; 792 $result = ['methods' => [self::ACCESS_ADDER => $addMethod, self::ACCESS_REMOVER => $removeMethod]]; 793 794 $addMethodFound = $this->isMethodAccessible($reflClass, $addMethod, 1); 795 796 if ($addMethodFound) { 797 $result[self::ACCESS_ADDER] = $addMethod; 798 } 799 800 $removeMethodFound = $this->isMethodAccessible($reflClass, $removeMethod, 1); 801 802 if ($removeMethodFound) { 803 $result[self::ACCESS_REMOVER] = $removeMethod; 804 } 805 806 yield $result; 807 } 808 809 return null; 810 } 811 812 /** 813 * Returns whether a method is public and has the number of required parameters. 814 */ 815 private function isMethodAccessible(\ReflectionClass $class, string $methodName, int $parameters): bool 816 { 817 if ($class->hasMethod($methodName)) { 818 $method = $class->getMethod($methodName); 819 820 if ($method->isPublic() 821 && $method->getNumberOfRequiredParameters() <= $parameters 822 && $method->getNumberOfParameters() >= $parameters) { 823 return true; 824 } 825 } 826 827 return false; 828 } 829 830 /** 831 * Gets a PropertyPath instance and caches it. 832 * 833 * @param string|PropertyPath $propertyPath 834 */ 835 private function getPropertyPath($propertyPath): PropertyPath 836 { 837 if ($propertyPath instanceof PropertyPathInterface) { 838 // Don't call the copy constructor has it is not needed here 839 return $propertyPath; 840 } 841 842 if (isset($this->propertyPathCache[$propertyPath])) { 843 return $this->propertyPathCache[$propertyPath]; 844 } 845 846 if ($this->cacheItemPool) { 847 $item = $this->cacheItemPool->getItem(self::CACHE_PREFIX_PROPERTY_PATH.rawurlencode($propertyPath)); 848 if ($item->isHit()) { 849 return $this->propertyPathCache[$propertyPath] = $item->get(); 850 } 851 } 852 853 $propertyPathInstance = new PropertyPath($propertyPath); 854 if (isset($item)) { 855 $item->set($propertyPathInstance); 856 $this->cacheItemPool->save($item); 857 } 858 859 return $this->propertyPathCache[$propertyPath] = $propertyPathInstance; 860 } 861 862 /** 863 * Creates the APCu adapter if applicable. 864 * 865 * @return AdapterInterface 866 * 867 * @throws \LogicException When the Cache Component isn't available 868 */ 869 public static function createCache(string $namespace, int $defaultLifetime, string $version, LoggerInterface $logger = null) 870 { 871 if (!class_exists('Symfony\Component\Cache\Adapter\ApcuAdapter')) { 872 throw new \LogicException(sprintf('The Symfony Cache component must be installed to use "%s()".', __METHOD__)); 873 } 874 875 if (!ApcuAdapter::isSupported()) { 876 return new NullAdapter(); 877 } 878 879 $apcu = new ApcuAdapter($namespace, $defaultLifetime / 5, $version); 880 if ('cli' === \PHP_SAPI && !filter_var(ini_get('apc.enable_cli'), FILTER_VALIDATE_BOOLEAN)) { 881 $apcu->setLogger(new NullLogger()); 882 } elseif (null !== $logger) { 883 $apcu->setLogger($logger); 884 } 885 886 return $apcu; 887 } 888} 889