. */ declare(strict_types=1); namespace App\Services\LogSystem; use App\Entity\Attachments\AttachmentType; use App\Entity\Base\AbstractDBElement; use App\Entity\Base\AbstractStructuralDBElement; use App\Entity\Contracts\TimeStampableInterface; use App\Entity\Contracts\TimeTravelInterface; use App\Entity\LogSystem\AbstractLogEntry; use App\Entity\LogSystem\CollectionElementDeleted; use App\Entity\LogSystem\ElementEditedLogEntry; use App\Repository\LogEntryRepository; use Brick\Math\BigDecimal; use DateTime; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadataInfo; use Doctrine\ORM\Mapping\MappingException; use Exception; use InvalidArgumentException; use ReflectionClass; class TimeTravel { protected LogEntryRepository $repo; public function __construct(protected EntityManagerInterface $em) { $this->repo = $em->getRepository(AbstractLogEntry::class); } /** * Undeletes the element with the given ID. * * @param string $class The class name of the element that should be undeleted * @param int $id the ID of the element that should be undeleted */ public function undeleteEntity(string $class, int $id): AbstractDBElement { $log = $this->repo->getUndeleteDataForElement($class, $id); $element = new $class(); $this->applyEntry($element, $log); //Set internal ID so the element can be reverted $this->setField($element, 'id', $id); //Let database determine when it will be created $this->setField($element, 'addedDate', null); return $element; } /** * Revert the given element to the state it has on the given timestamp. * * @param AbstractLogEntry[] $reverted_elements * * @throws Exception */ public function revertEntityToTimestamp(AbstractDBElement $element, \DateTimeInterface $timestamp, array $reverted_elements = []): void { if (!$element instanceof TimeStampableInterface) { throw new InvalidArgumentException('$element must have a Timestamp!'); } if ($timestamp > new DateTime('now')) { throw new InvalidArgumentException('You can not travel to the future (yet)...'); } //Skip this process if already were reverted... if (in_array($element, $reverted_elements, true)) { return; } $reverted_elements[] = $element; $history = $this->repo->getTimetravelDataForElement($element, $timestamp); /* if (!$this->repo->getElementExistedAtTimestamp($element, $timestamp)) { $element = null; return; }*/ foreach ($history as $logEntry) { if ($logEntry instanceof ElementEditedLogEntry) { $this->applyEntry($element, $logEntry); } if ($logEntry instanceof CollectionElementDeleted) { //Undelete element and add it to collection again $undeleted = $this->undeleteEntity( $logEntry->getDeletedElementClass(), $logEntry->getDeletedElementID() ); if ($this->repo->getElementExistedAtTimestamp($undeleted, $timestamp)) { $this->revertEntityToTimestamp($undeleted, $timestamp, $reverted_elements); $collection = $this->getField($element, $logEntry->getCollectionName()); if ($collection instanceof Collection) { $collection->add($undeleted); } } } } // Revert any of the associated elements $metadata = $this->em->getClassMetadata($element::class); $associations = $metadata->getAssociationMappings(); foreach ($associations as $field => $mapping) { if ( ($element instanceof AbstractStructuralDBElement && ('parts' === $field || 'children' === $field)) || ($element instanceof AttachmentType && 'attachments' === $field) ) { continue; } //Revert many-to-one association (one element in property) if ( ClassMetadataInfo::MANY_TO_ONE === $mapping['type'] || ClassMetadataInfo::ONE_TO_ONE === $mapping['type'] ) { $target_element = $this->getField($element, $field); if (null !== $target_element && $element->getLastModified() > $timestamp) { $this->revertEntityToTimestamp($target_element, $timestamp, $reverted_elements); } } elseif ( //Revert *_TO_MANY associations (collection properties) (ClassMetadataInfo::MANY_TO_MANY === $mapping['type'] || ClassMetadataInfo::ONE_TO_MANY === $mapping['type']) && !$mapping['isOwningSide'] ) { $target_elements = $this->getField($element, $field); if (null === $target_elements || (is_countable($target_elements) ? count($target_elements) : 0) > 10) { continue; } foreach ($target_elements as $target_element) { if (null !== $target_element && $element->getLastModified() >= $timestamp) { //Remove the element from collection, if it did not exist at $timestamp if (!$this->repo->getElementExistedAtTimestamp( $target_element, $timestamp ) && $target_elements instanceof Collection) { $target_elements->removeElement($target_element); } $this->revertEntityToTimestamp($target_element, $timestamp, $reverted_elements); } } } } } /** * This function decodes the array which is created during the json_encode of a datetime object and returns a DateTime object. * @param array $input * @return DateTime * @throws Exception */ private function dateTimeDecode(?array $input): ?\DateTime { //Allow null values if ($input === null) { return null; } return new \DateTime($input['date'], new \DateTimeZone($input['timezone'])); } /** * Apply the changeset in the given LogEntry to the element. * * @throws MappingException */ public function applyEntry(AbstractDBElement $element, TimeTravelInterface $logEntry): void { //Skip if this does not provide any info... if (!$logEntry->hasOldDataInformation()) { return; } if (!$element instanceof TimeStampableInterface) { return; } $metadata = $this->em->getClassMetadata($element::class); $old_data = $logEntry->getOldData(); foreach ($old_data as $field => $data) { if ($metadata->hasField($field)) { //We need to convert the string to a BigDecimal first if (!$data instanceof BigDecimal && ('big_decimal' === $metadata->getFieldMapping($field)['type'])) { $data = BigDecimal::of($data); } if (!$data instanceof DateTime && ('datetime' === $metadata->getFieldMapping($field)['type'])) { $data = $this->dateTimeDecode($data); } $this->setField($element, $field, $data); } if ($metadata->hasAssociation($field)) { $mapping = $metadata->getAssociationMapping($field); $target_class = $mapping['targetEntity']; //Try to extract the old ID: if (is_array($data) && isset($data['@id'])) { $entity = $this->em->getPartialReference($target_class, $data['@id']); $this->setField($element, $field, $entity); } } } $this->setField($element, 'lastModified', $logEntry->getTimestamp()); } protected function getField(AbstractDBElement $element, string $field): mixed { $reflection = new ReflectionClass($element::class); $property = $reflection->getProperty($field); $property->setAccessible(true); return $property->getValue($element); } protected function setField(AbstractDBElement $element, string $field, mixed $new_value): void { $reflection = new ReflectionClass($element::class); $property = $reflection->getProperty($field); $property->setAccessible(true); $property->setValue($element, $new_value); } }