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