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