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