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