xref: /plugin/dw2pdf/vendor/myclabs/deep-copy/src/DeepCopy/DeepCopy.php (revision 01d4b863f647689752623420d92bf6c538d91460)
1<?php
2
3namespace DeepCopy;
4
5use ArrayObject;
6use DateInterval;
7use DatePeriod;
8use DateTimeInterface;
9use DateTimeZone;
10use DeepCopy\Exception\CloneException;
11use DeepCopy\Filter\ChainableFilter;
12use DeepCopy\Filter\Filter;
13use DeepCopy\Matcher\Matcher;
14use DeepCopy\Reflection\ReflectionHelper;
15use DeepCopy\TypeFilter\Date\DateIntervalFilter;
16use DeepCopy\TypeFilter\Date\DatePeriodFilter;
17use DeepCopy\TypeFilter\Spl\ArrayObjectFilter;
18use DeepCopy\TypeFilter\Spl\SplDoublyLinkedListFilter;
19use DeepCopy\TypeFilter\TypeFilter;
20use DeepCopy\TypeMatcher\TypeMatcher;
21use ReflectionObject;
22use ReflectionProperty;
23use SplDoublyLinkedList;
24
25/**
26 * @final
27 */
28class DeepCopy
29{
30    /**
31     * @var object[] List of objects copied.
32     */
33    private $hashMap = [];
34
35    /**
36     * Filters to apply.
37     *
38     * @var array Array of ['filter' => Filter, 'matcher' => Matcher] pairs.
39     */
40    private $filters = [];
41
42    /**
43     * Type Filters to apply.
44     *
45     * @var array Array of ['filter' => Filter, 'matcher' => Matcher] pairs.
46     */
47    private $typeFilters = [];
48
49    /**
50     * @var bool
51     */
52    private $skipUncloneable = false;
53
54    /**
55     * @var bool
56     */
57    private $useCloneMethod;
58
59    /**
60     * @param bool $useCloneMethod   If set to true, when an object implements the __clone() function, it will be used
61     *                               instead of the regular deep cloning.
62     */
63    public function __construct($useCloneMethod = false)
64    {
65        $this->useCloneMethod = $useCloneMethod;
66
67        $this->addTypeFilter(new ArrayObjectFilter($this), new TypeMatcher(ArrayObject::class));
68        $this->addTypeFilter(new DateIntervalFilter(), new TypeMatcher(DateInterval::class));
69        $this->addTypeFilter(new DatePeriodFilter(), new TypeMatcher(DatePeriod::class));
70        $this->addTypeFilter(new SplDoublyLinkedListFilter($this), new TypeMatcher(SplDoublyLinkedList::class));
71    }
72
73    /**
74     * If enabled, will not throw an exception when coming across an uncloneable property.
75     *
76     * @param $skipUncloneable
77     *
78     * @return $this
79     */
80    public function skipUncloneable($skipUncloneable = true)
81    {
82        $this->skipUncloneable = $skipUncloneable;
83
84        return $this;
85    }
86
87    /**
88     * Deep copies the given object.
89     *
90     * @template TObject
91     *
92     * @param TObject $object
93     *
94     * @return TObject
95     */
96    public function copy($object)
97    {
98        $this->hashMap = [];
99
100        return $this->recursiveCopy($object);
101    }
102
103    public function addFilter(Filter $filter, Matcher $matcher)
104    {
105        $this->filters[] = [
106            'matcher' => $matcher,
107            'filter'  => $filter,
108        ];
109    }
110
111    public function prependFilter(Filter $filter, Matcher $matcher)
112    {
113        array_unshift($this->filters, [
114            'matcher' => $matcher,
115            'filter'  => $filter,
116        ]);
117    }
118
119    public function addTypeFilter(TypeFilter $filter, TypeMatcher $matcher)
120    {
121        $this->typeFilters[] = [
122            'matcher' => $matcher,
123            'filter'  => $filter,
124        ];
125    }
126
127    public function prependTypeFilter(TypeFilter $filter, TypeMatcher $matcher)
128    {
129        array_unshift($this->typeFilters, [
130            'matcher' => $matcher,
131            'filter'  => $filter,
132        ]);
133    }
134
135    private function recursiveCopy($var)
136    {
137        // Matches Type Filter
138        if ($filter = $this->getFirstMatchedTypeFilter($this->typeFilters, $var)) {
139            return $filter->apply($var);
140        }
141
142        // Resource
143        if (is_resource($var)) {
144            return $var;
145        }
146
147        // Array
148        if (is_array($var)) {
149            return $this->copyArray($var);
150        }
151
152        // Scalar
153        if (! is_object($var)) {
154            return $var;
155        }
156
157        // Enum
158        if (PHP_VERSION_ID >= 80100 && enum_exists(get_class($var))) {
159            return $var;
160        }
161
162        // Object
163        return $this->copyObject($var);
164    }
165
166    /**
167     * Copy an array
168     * @param array $array
169     * @return array
170     */
171    private function copyArray(array $array)
172    {
173        foreach ($array as $key => $value) {
174            $array[$key] = $this->recursiveCopy($value);
175        }
176
177        return $array;
178    }
179
180    /**
181     * Copies an object.
182     *
183     * @param object $object
184     *
185     * @throws CloneException
186     *
187     * @return object
188     */
189    private function copyObject($object)
190    {
191        $objectHash = spl_object_hash($object);
192
193        if (isset($this->hashMap[$objectHash])) {
194            return $this->hashMap[$objectHash];
195        }
196
197        $reflectedObject = new ReflectionObject($object);
198        $isCloneable = $reflectedObject->isCloneable();
199
200        if (false === $isCloneable) {
201            if ($this->skipUncloneable) {
202                $this->hashMap[$objectHash] = $object;
203
204                return $object;
205            }
206
207            throw new CloneException(
208                sprintf(
209                    'The class "%s" is not cloneable.',
210                    $reflectedObject->getName()
211                )
212            );
213        }
214
215        $newObject = clone $object;
216        $this->hashMap[$objectHash] = $newObject;
217
218        if ($this->useCloneMethod && $reflectedObject->hasMethod('__clone')) {
219            return $newObject;
220        }
221
222        if ($newObject instanceof DateTimeInterface || $newObject instanceof DateTimeZone) {
223            return $newObject;
224        }
225
226        foreach (ReflectionHelper::getProperties($reflectedObject) as $property) {
227            $this->copyObjectProperty($newObject, $property);
228        }
229
230        return $newObject;
231    }
232
233    private function copyObjectProperty($object, ReflectionProperty $property)
234    {
235        // Ignore static properties
236        if ($property->isStatic()) {
237            return;
238        }
239
240        // Ignore readonly properties
241        if (method_exists($property, 'isReadOnly') && $property->isReadOnly()) {
242            return;
243        }
244
245        // Apply the filters
246        foreach ($this->filters as $item) {
247            /** @var Matcher $matcher */
248            $matcher = $item['matcher'];
249            /** @var Filter $filter */
250            $filter = $item['filter'];
251
252            if ($matcher->matches($object, $property->getName())) {
253                $filter->apply(
254                    $object,
255                    $property->getName(),
256                    function ($object) {
257                        return $this->recursiveCopy($object);
258                    }
259                );
260
261                if ($filter instanceof ChainableFilter) {
262                    continue;
263                }
264
265                // If a filter matches, we stop processing this property
266                return;
267            }
268        }
269
270        if (PHP_VERSION_ID < 80100) {
271            $property->setAccessible(true);
272        }
273
274        // Ignore uninitialized properties (for PHP >7.4)
275        if (method_exists($property, 'isInitialized') && !$property->isInitialized($object)) {
276            return;
277        }
278
279        $propertyValue = $property->getValue($object);
280
281        // Copy the property
282        $property->setValue($object, $this->recursiveCopy($propertyValue));
283    }
284
285    /**
286     * Returns first filter that matches variable, `null` if no such filter found.
287     *
288     * @param array $filterRecords Associative array with 2 members: 'filter' with value of type {@see TypeFilter} and
289     *                             'matcher' with value of type {@see TypeMatcher}
290     * @param mixed $var
291     *
292     * @return TypeFilter|null
293     */
294    private function getFirstMatchedTypeFilter(array $filterRecords, $var)
295    {
296        $matched = $this->first(
297            $filterRecords,
298            function (array $record) use ($var) {
299                /* @var TypeMatcher $matcher */
300                $matcher = $record['matcher'];
301
302                return $matcher->matches($var);
303            }
304        );
305
306        return isset($matched) ? $matched['filter'] : null;
307    }
308
309    /**
310     * Returns first element that matches predicate, `null` if no such element found.
311     *
312     * @param array    $elements Array of ['filter' => Filter, 'matcher' => Matcher] pairs.
313     * @param callable $predicate Predicate arguments are: element.
314     *
315     * @return array|null Associative array with 2 members: 'filter' with value of type {@see TypeFilter} and 'matcher'
316     *                    with value of type {@see TypeMatcher} or `null`.
317     */
318    private function first(array $elements, callable $predicate)
319    {
320        foreach ($elements as $element) {
321            if (call_user_func($predicate, $element)) {
322                return $element;
323            }
324        }
325
326        return null;
327    }
328}
329