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