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