edmondscommerce/doctrine-static-meta

View on GitHub
src/Entity/Savers/BulkSimpleEntityCreator.php

Summary

Maintainability
A
0 mins
Test Coverage
<?php

declare(strict_types=1);

namespace EdmondsCommerce\DoctrineStaticMeta\Entity\Savers;

use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadataInfo;
use EdmondsCommerce\DoctrineStaticMeta\Entity\Fields\Factories\UuidFactory;
use EdmondsCommerce\DoctrineStaticMeta\Entity\Interfaces\EntityInterface;
use EdmondsCommerce\DoctrineStaticMeta\Entity\Savers\BulkEntityUpdater\BulkSimpleEntityCreatorHelper;
use EdmondsCommerce\DoctrineStaticMeta\Schema\MysqliConnectionFactory;
use EdmondsCommerce\DoctrineStaticMeta\Schema\UuidFunctionPolyfill;
use InvalidArgumentException;
use mysqli;
use Ramsey\Uuid\Doctrine\UuidBinaryOrderedTimeType;
use Ramsey\Uuid\UuidInterface;
use RuntimeException;
use Throwable;

use function in_array;
use function is_array;

/**
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
 */
class BulkSimpleEntityCreator extends AbstractBulkProcess
{
    public const INSERT_MODE_INSERT  = 'INSERT ';
    public const INSERT_MODE_IGNORE  = 'INSERT IGNORE ';
    public const INSERT_MODE_DEFAULT = self::INSERT_MODE_INSERT;
    public const INSERT_MODES        = [
        self::INSERT_MODE_INSERT,
        self::INSERT_MODE_IGNORE,
    ];

    /**
     * @var BulkSimpleEntityCreatorHelper
     */
    private $helper;
    /**
     * @var string
     */
    private $tableName;
    /**
     * @var string
     */
    private $entityFqn;
    /**
     * Is the UUID binary
     *
     * @var bool
     */
    private $isBinaryUuid = true;
    /**
     * @var ClassMetadataInfo
     */
    private $meta;
    /**
     * @var string
     */
    private $primaryKeyCol;
    /**
     * @var mysqli
     */
    private $mysqli;
    /**
     * @var UuidFunctionPolyfill
     */
    private $uuidFunctionPolyfill;
    /**
     * @var UuidFactory
     */
    private $uuidFactory;
    /**
     * @var string
     */
    private $query;
    /**
     * For creation this should always be 100%, so 1
     *
     * @var int
     */
    private $requireAffectedRatio = 1;
    /**
     * @var int
     */
    private $totalAffectedRows = 0;

    private $insertMode = self::INSERT_MODE_DEFAULT;
    /**
     * @var MysqliConnectionFactory
     */
    private $mysqliConnectionFactory;

    public function __construct(
        EntityManagerInterface $entityManager,
        MysqliConnectionFactory $mysqliConnectionFactory,
        UuidFunctionPolyfill $uuidFunctionPolyfill,
        UuidFactory $uuidFactory
    ) {
        parent::__construct($entityManager);
        $this->entityManager           = $entityManager;
        $this->mysqliConnectionFactory = $mysqliConnectionFactory;
        $this->uuidFunctionPolyfill    = $uuidFunctionPolyfill;
        $this->uuidFactory             = $uuidFactory;
        $this->connect();
    }

    private function connect(): void
    {
        $this->mysqli = $this->mysqliConnectionFactory->createFromEntityManager($this->entityManager);
    }

    public function endBulkProcess(): void
    {
        parent::endBulkProcess();
        // Reset the insert mode to default to prevent state bleeding across batch runs
        $this->setInsertMode(self::INSERT_MODE_DEFAULT);
    }

    /**
     * @param string $insertMode
     *
     * @return BulkSimpleEntityCreator
     */
    public function setInsertMode(string $insertMode): BulkSimpleEntityCreator
    {
        if (false === in_array($insertMode, self::INSERT_MODES, true)) {
            throw new InvalidArgumentException('Invalid insert mode');
        }
        $this->insertMode = $insertMode;
        if ($this->insertMode === self::INSERT_MODE_IGNORE) {
            $this->requireAffectedRatio = 0;
        }

        return $this;
    }

    public function addEntityToSave(EntityInterface $entity): void
    {
        throw new RuntimeException('You should not try to save Entities with this saver');
    }

    public function addEntitiesToSave(array $entities): void
    {
        foreach ($entities as $entityData) {
            if (is_array($entityData)) {
                $this->addEntityCreationData($entityData);
                continue;
            }
            throw new InvalidArgumentException('You should only pass in simple arrays of scalar entity data');
        }
    }

    public function addEntityCreationData(array $entityData): void
    {
        $this->entitiesToSave[] = $entityData;
        $this->bulkSaveIfChunkBigEnough();
    }

    public function setHelper(BulkSimpleEntityCreatorHelper $helper): void
    {
        $this->helper        = $helper;
        $this->tableName     = $helper->getTableName();
        $this->entityFqn     = $helper->getEntityFqn();
        $this->meta          = $this->entityManager->getClassMetadata($this->entityFqn);
        $this->primaryKeyCol = $this->meta->getSingleIdentifierFieldName();
        $this->isBinaryUuid  = $this->isBinaryUuid();
        $this->runPolyfillIfRequired();
    }

    private function isBinaryUuid(): bool
    {
        $idMapping = $this->meta->getFieldMapping($this->meta->getSingleIdentifierFieldName());

        return $idMapping['type'] === UuidBinaryOrderedTimeType::NAME;
    }

    private function runPolyfillIfRequired(): void
    {
        if (false === $this->isBinaryUuid) {
            return;
        }
        $this->uuidFunctionPolyfill->run();
    }

    /**
     * As these are not actually entities, lets empty them out before
     * parent::freeResources tries to detach from the entity manager
     */
    protected function freeResources(): void
    {
        $this->entitiesToSave = [];
        parent::freeResources();
    }

    protected function doSave(): void
    {
        foreach ($this->entitiesToSave as $entityData) {
            $this->appendToQuery($this->buildSql($entityData));
        }
        $this->runQuery();
        $this->reset();
    }

    private function appendToQuery(string $sql): void
    {
        $this->query .= "\n$sql";
    }

    private function buildSql(array $entityData): string
    {
        $sql  = $this->insertMode . " into {$this->tableName} set ";
        $sqls = [
            $this->primaryKeyCol . ' = ' . $this->generateId(),
        ];
        foreach ($entityData as $key => $value) {
            if ($key === $this->primaryKeyCol) {
                throw new InvalidArgumentException(
                    'You should not pass in IDs, they will be auto generated'
                );
            }
            if ($value instanceof UuidInterface) {
                $sqls[] = "`$key` = " . $this->getUuidSql($value);
                continue;
            }
            $value  = $this->mysqli->escape_string((string)$value);
            $sqls[] = "`$key` = '$value'";
        }
        $sql .= implode(', ', $sqls) . ';';

        return $sql;
    }

    private function generateId(): string
    {
        if ($this->isBinaryUuid) {
            return $this->getUuidSql($this->uuidFactory->getOrderedTimeUuid());
        }

        return $this->getUuidSql($this->uuidFactory->getUuid());
    }

    private function getUuidSql(UuidInterface $uuid): string
    {
        if ($this->isBinaryUuid) {
            $uuidString = (string)$uuid;

            return "UUID_TO_BIN('$uuidString', true)";
        }

        throw new RuntimeException('This is not currently suppported - should be easy enough though');
    }

    private function runQuery(): void
    {
        if ('' === $this->query) {
            return;
        }
        $this->pingAndReconnectOnFailure();
        $this->query = "
           START TRANSACTION;
           SET FOREIGN_KEY_CHECKS = 0; 
           {$this->query}             
           SET FOREIGN_KEY_CHECKS = 1; 
           COMMIT;";
        $result      = $this->mysqli->multi_query($this->query);
        if (true !== $result) {
            throw new RuntimeException(
                'Multi Query returned false which means the first statement failed: ' .
                $this->mysqli->error
            );
        }
        $affectedRows = 0;
        $queryCount   = 0;
        do {
            $queryCount++;
            $errorNo = (int)$this->mysqli->errno;
            if (0 !== $errorNo) {
                $errorMessage = 'Query #' . $queryCount .
                                ' got MySQL Error #' . $errorNo .
                                ': ' . $this->mysqli->error
                                . "\nQuery: " . $this->getQueryLine($queryCount) . "'\n";
                throw new RuntimeException($errorMessage);
            }
            $affectedRows += max($this->mysqli->affected_rows, 0);
            if (false === $this->mysqli->more_results()) {
                break;
            }
            $this->mysqli->next_result();
        } while (true);
        if ($affectedRows < count($this->entitiesToSave) * $this->requireAffectedRatio) {
            throw new RuntimeException(
                'Affected rows count of ' . $affectedRows .
                ' does match the expected count of entitiesToSave ' . count($this->entitiesToSave)
            );
        }
        $this->totalAffectedRows += $affectedRows;
        $this->mysqli->commit();
    }

    private function pingAndReconnectOnFailure(): void
    {
        if (null === $this->mysqli) {
            $this->connect();
        }
        try {
            $this->mysqli->query('select 1');
        } catch (Throwable $exception) {
            $this->mysqli->close();
            $this->mysqli = null;
            $this->connect();
        }
    }

    private function getQueryLine(int $line): string
    {
        $lines = explode(";\n", $this->query);

        return $lines[$line + 1];
    }

    private function reset(): void
    {
        $this->entitiesToSave = [];
        $this->query          = '';
    }
}