diff --git a/modules/mining-checker/src/Infrastructure/SchemaManager.php b/modules/mining-checker/src/Infrastructure/SchemaManager.php index bca4937..58446a6 100644 --- a/modules/mining-checker/src/Infrastructure/SchemaManager.php +++ b/modules/mining-checker/src/Infrastructure/SchemaManager.php @@ -3,6 +3,7 @@ declare(strict_types=1); namespace Modules\MiningChecker\Infrastructure; +use App\SqlDataImporter; use Modules\MiningChecker\Support\ApiException; use PDO; @@ -12,6 +13,7 @@ final class SchemaManager private string $prefix; private string $moduleBasePath; private string $driver; + private SqlDataImporter $sqlImporter; public function __construct(PDO $pdo, string $prefix, string $moduleBasePath) { @@ -19,6 +21,7 @@ final class SchemaManager $this->prefix = $prefix; $this->moduleBasePath = rtrim($moduleBasePath, '/'); $this->driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME)); + $this->sqlImporter = new SqlDataImporter($this->pdo); } public function ensureSchema(): void @@ -391,7 +394,7 @@ final class SchemaManager throw new ApiException('Die hochgeladene SQL-Datei ist leer oder konnte nicht gelesen werden.', 422, ['file' => $originalName]); } - $statementCount = $this->executeSqlStatements($this->splitSqlStatements($sql), $originalName); + $statementCount = $this->executeSqlContent($sql, $originalName); return [ 'file' => $originalName, @@ -413,283 +416,38 @@ final class SchemaManager } $sql = (string) file_get_contents($schemaFile); - $this->executeSqlStatements($this->splitSqlStatements($sql), $schemaFile, 'Schema-Import fuer Mining-Checker fehlgeschlagen.'); + $this->executeSqlContent($sql, $schemaFile, 'Schema-Import fuer Mining-Checker fehlgeschlagen.'); } - /** - * @param list $statements - */ - private function executeSqlStatements(array $statements, string $sourceLabel, string $errorMessage = 'SQL-Import fuer Mining-Checker fehlgeschlagen.'): int + private function executeSqlContent(string $sql, string $sourceLabel, string $errorMessage = 'SQL-Import fuer Mining-Checker fehlgeschlagen.'): int { - $currentStatement = null; - $useTransaction = $this->driver === 'pgsql' && !$this->pdo->inTransaction(); - try { - if ($useTransaction) { - $this->pdo->beginTransaction(); - } + return $this->sqlImporter->importString($sql); + } catch (\RuntimeException $exception) { + $previous = $exception->getPrevious() ?? $exception; - $normalizedStatements = []; - foreach ($statements as $statement) { - $trimmed = trim($statement); - if ($trimmed === '') { - continue; - } - - $normalizedStatements[] = $this->normalizeImportStatement($trimmed); - } - - $executed = $this->executeImportPass($normalizedStatements, $currentStatement); - - if ($useTransaction && $this->pdo->inTransaction()) { - $this->pdo->commit(); - } - - return $executed; + throw new ApiException( + $errorMessage, + 500, + [ + 'message' => $previous->getMessage(), + 'source' => $sourceLabel, + 'statement' => substr($exception->getMessage(), 0, 1000), + ] + ); } catch (\Throwable $exception) { - if ($useTransaction && $this->pdo->inTransaction()) { - $this->pdo->rollBack(); - } - throw new ApiException( $errorMessage, 500, [ 'message' => $exception->getMessage(), 'source' => $sourceLabel, - 'statement' => $currentStatement !== null ? substr($currentStatement, 0, 1000) : null, + 'statement' => null, ] ); } } - /** - * @param list $statements - */ - private function executeImportPass(array $statements, ?string &$currentStatement): int - { - $pendingStatements = $statements; - $executed = 0; - $maxPasses = max(1, count($pendingStatements)); - - for ($pass = 0; $pass < $maxPasses && $pendingStatements !== []; $pass++) { - $deferredStatements = []; - $progressMade = false; - - foreach ($pendingStatements as $statement) { - $currentStatement = $statement; - - try { - $this->pdo->exec($statement); - $executed++; - $progressMade = true; - } catch (\Throwable $exception) { - if ($this->shouldRetryDeferredImportStatement($exception, $statement)) { - $deferredStatements[] = $statement; - continue; - } - - throw $exception; - } - } - - if ($deferredStatements === []) { - return $executed; - } - - if (!$progressMade) { - $currentStatement = $deferredStatements[0]; - $this->pdo->exec($deferredStatements[0]); - } - - $pendingStatements = $deferredStatements; - } - - return $executed; - } - - private function shouldRetryDeferredImportStatement(\Throwable $exception, string $statement): bool - { - if ($this->driver !== 'pgsql') { - return false; - } - - if (!preg_match('/^(INSERT|COPY)\\b/i', ltrim($statement))) { - return false; - } - - $errorCode = (string) $exception->getCode(); - return $errorCode === '23503'; - } - - private function normalizeImportStatement(string $statement): string - { - if ($this->driver !== 'pgsql') { - return $statement; - } - - $resolvedSetval = $this->normalizePgsqlSetvalStatement($statement); - return $resolvedSetval ?? $statement; - } - - private function normalizePgsqlSetvalStatement(string $statement): ?string - { - $pattern = "/^SELECT\\s+setval\\(\\s*'([^']+)'\\s*,\\s*([0-9]+)\\s*,\\s*(true|false)\\s*\\)$/i"; - if (!preg_match($pattern, trim($statement), $matches)) { - return null; - } - - $sequenceReference = $matches[1]; - $nextValue = $matches[2]; - $isCalled = strtolower($matches[3]); - - if ($this->pgsqlRelationExists($sequenceReference)) { - return $statement; - } - - $sequenceName = $sequenceReference; - $schemaName = 'public'; - if (str_contains($sequenceReference, '.')) { - [$schemaName, $sequenceName] = explode('.', $sequenceReference, 2); - } - - $schemaName = trim($schemaName, "\"'"); - $sequenceName = trim($sequenceName, "\"'"); - - if (!preg_match('/^(.*)_([A-Za-z0-9]+)_seq\d*$/', $sequenceName, $parts)) { - return null; - } - - $tableName = $parts[1]; - $columnName = $parts[2]; - $actualSequence = $this->resolvePgsqlSerialSequence($schemaName, $tableName, $columnName); - if ($actualSequence === null) { - return null; - } - - return sprintf("SELECT setval('%s', %s, %s)", $actualSequence, $nextValue, $isCalled); - } - - private function pgsqlRelationExists(string $qualifiedName): bool - { - $statement = $this->pdo->prepare('SELECT to_regclass(:name) IS NOT NULL'); - $statement->execute(['name' => $qualifiedName]); - return (bool) $statement->fetchColumn(); - } - - private function resolvePgsqlSerialSequence(string $schemaName, string $tableName, string $columnName): ?string - { - $qualifiedTable = sprintf('"%s"."%s"', str_replace('"', '""', $schemaName), str_replace('"', '""', $tableName)); - $statement = $this->pdo->prepare('SELECT pg_get_serial_sequence(:table_name, :column_name)'); - $statement->execute([ - 'table_name' => $qualifiedTable, - 'column_name' => $columnName, - ]); - - $result = $statement->fetchColumn(); - return is_string($result) && $result !== '' ? $result : null; - } - - /** - * @return list - */ - private function splitSqlStatements(string $sql): array - { - $statements = []; - $buffer = ''; - $length = strlen($sql); - $inSingleQuote = false; - $inDoubleQuote = false; - $inBacktickQuote = false; - $inLineComment = false; - $inBlockComment = false; - - for ($index = 0; $index < $length; $index++) { - $char = $sql[$index]; - $next = $index + 1 < $length ? $sql[$index + 1] : ''; - - if ($inLineComment) { - if ($char === "\n") { - $inLineComment = false; - $buffer .= $char; - } - continue; - } - - if ($inBlockComment) { - if ($char === '*' && $next === '/') { - $inBlockComment = false; - $index++; - } - continue; - } - - if (!$inSingleQuote && !$inDoubleQuote && !$inBacktickQuote) { - if ($char === '-' && $next === '-') { - $afterNext = $index + 2 < $length ? $sql[$index + 2] : ''; - if ($afterNext === '' || ctype_space($afterNext)) { - $inLineComment = true; - $index++; - continue; - } - } - - if ($char === '#') { - $inLineComment = true; - continue; - } - - if ($char === '/' && $next === '*') { - $inBlockComment = true; - $index++; - continue; - } - } - - if ($char === "'" && !$inDoubleQuote && !$inBacktickQuote) { - $escaped = $index > 0 && $sql[$index - 1] === '\\'; - if (!$escaped) { - $inSingleQuote = !$inSingleQuote; - } - $buffer .= $char; - continue; - } - - if ($char === '"' && !$inSingleQuote && !$inBacktickQuote) { - $escaped = $index > 0 && $sql[$index - 1] === '\\'; - if (!$escaped) { - $inDoubleQuote = !$inDoubleQuote; - } - $buffer .= $char; - continue; - } - - if ($char === '`' && !$inSingleQuote && !$inDoubleQuote) { - $inBacktickQuote = !$inBacktickQuote; - $buffer .= $char; - continue; - } - - if ($char === ';' && !$inSingleQuote && !$inDoubleQuote && !$inBacktickQuote) { - $trimmed = trim($buffer); - if ($trimmed !== '') { - $statements[] = $trimmed; - } - $buffer = ''; - continue; - } - - $buffer .= $char; - } - - $trimmed = trim($buffer); - if ($trimmed !== '') { - $statements[] = $trimmed; - } - - return $statements; - } - private function dropExistingTables(): array { $tables = array_reverse($this->schemaStatus()['present_tables']); diff --git a/src/App/SqlDataImporter.php b/src/App/SqlDataImporter.php new file mode 100644 index 0000000..90e2af3 --- /dev/null +++ b/src/App/SqlDataImporter.php @@ -0,0 +1,288 @@ +pdo = $pdo; + $this->driver = strtolower((string) $pdo->getAttribute(PDO::ATTR_DRIVER_NAME)); + } + + public function importString(string $sql): int + { + $statements = []; + foreach ($this->splitStatements($sql) as $statement) { + $trimmed = trim($statement); + if ($trimmed === '') { + continue; + } + + $statements[] = $this->normalizeImportStatement($trimmed); + } + + return $this->executeStatements($statements); + } + + /** + * @param list $statements + */ + private function executeStatements(array $statements): int + { + $useTransaction = $this->driver === 'pgsql' && !$this->pdo->inTransaction(); + + try { + if ($useTransaction) { + $this->pdo->beginTransaction(); + } + + $executed = $this->executeImportPass($statements); + + if ($useTransaction && $this->pdo->inTransaction()) { + $this->pdo->commit(); + } + + return $executed; + } catch (\Throwable $exception) { + if ($useTransaction && $this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + + throw $exception; + } + } + + /** + * @param list $statements + */ + private function executeImportPass(array $statements): int + { + $pendingStatements = $statements; + $executed = 0; + $maxPasses = max(1, count($pendingStatements)); + + for ($pass = 0; $pass < $maxPasses && $pendingStatements !== []; $pass++) { + $deferredStatements = []; + $progressMade = false; + + foreach ($pendingStatements as $statement) { + try { + $this->pdo->exec($statement); + $executed++; + $progressMade = true; + } catch (\Throwable $exception) { + if ($this->shouldRetryDeferredImportStatement($exception, $statement)) { + $deferredStatements[] = $statement; + continue; + } + + throw new \RuntimeException($statement, 0, $exception); + } + } + + if ($deferredStatements === []) { + return $executed; + } + + if (!$progressMade) { + try { + $this->pdo->exec($deferredStatements[0]); + } catch (\Throwable $exception) { + throw new \RuntimeException($deferredStatements[0], 0, $exception); + } + } + + $pendingStatements = $deferredStatements; + } + + return $executed; + } + + private function shouldRetryDeferredImportStatement(\Throwable $exception, string $statement): bool + { + if ($this->driver !== 'pgsql') { + return false; + } + + if (!preg_match('/^(INSERT|COPY)\b/i', ltrim($statement))) { + return false; + } + + return (string) $exception->getCode() === '23503'; + } + + private function normalizeImportStatement(string $statement): string + { + if ($this->driver !== 'pgsql') { + return $statement; + } + + $resolvedSetval = $this->normalizePgsqlSetvalStatement($statement); + return $resolvedSetval ?? $statement; + } + + private function normalizePgsqlSetvalStatement(string $statement): ?string + { + $pattern = "/^SELECT\\s+setval\\(\\s*'([^']+)'\\s*,\\s*([0-9]+)\\s*,\\s*(true|false)\\s*\\)$/i"; + if (!preg_match($pattern, trim($statement), $matches)) { + return null; + } + + $sequenceReference = $matches[1]; + $nextValue = $matches[2]; + $isCalled = strtolower($matches[3]); + + if ($this->pgsqlRelationExists($sequenceReference)) { + return $statement; + } + + $sequenceName = $sequenceReference; + $schemaName = 'public'; + if (str_contains($sequenceReference, '.')) { + [$schemaName, $sequenceName] = explode('.', $sequenceReference, 2); + } + + $schemaName = trim($schemaName, "\"'"); + $sequenceName = trim($sequenceName, "\"'"); + + if (!preg_match('/^(.*)_([A-Za-z0-9]+)_seq\d*$/', $sequenceName, $parts)) { + return null; + } + + $tableName = $parts[1]; + $columnName = $parts[2]; + $actualSequence = $this->resolvePgsqlSerialSequence($schemaName, $tableName, $columnName); + if ($actualSequence === null) { + return null; + } + + return sprintf("SELECT setval('%s', %s, %s)", $actualSequence, $nextValue, $isCalled); + } + + private function pgsqlRelationExists(string $qualifiedName): bool + { + $statement = $this->pdo->prepare('SELECT to_regclass(:name) IS NOT NULL'); + $statement->execute(['name' => $qualifiedName]); + return (bool) $statement->fetchColumn(); + } + + private function resolvePgsqlSerialSequence(string $schemaName, string $tableName, string $columnName): ?string + { + $qualifiedTable = sprintf('"%s"."%s"', str_replace('"', '""', $schemaName), str_replace('"', '""', $tableName)); + $statement = $this->pdo->prepare('SELECT pg_get_serial_sequence(:table_name, :column_name)'); + $statement->execute([ + 'table_name' => $qualifiedTable, + 'column_name' => $columnName, + ]); + + $result = $statement->fetchColumn(); + return is_string($result) && $result !== '' ? $result : null; + } + + /** + * @return list + */ + private function splitStatements(string $sql): array + { + $statements = []; + $buffer = ''; + $length = strlen($sql); + $inSingleQuote = false; + $inDoubleQuote = false; + $inBacktickQuote = false; + $inLineComment = false; + $inBlockComment = false; + + for ($index = 0; $index < $length; $index++) { + $char = $sql[$index]; + $next = $index + 1 < $length ? $sql[$index + 1] : ''; + + if ($inLineComment) { + if ($char === "\n") { + $inLineComment = false; + $buffer .= $char; + } + continue; + } + + if ($inBlockComment) { + if ($char === '*' && $next === '/') { + $inBlockComment = false; + $index++; + } + continue; + } + + if (!$inSingleQuote && !$inDoubleQuote && !$inBacktickQuote) { + if ($char === '-' && $next === '-') { + $afterNext = $index + 2 < $length ? $sql[$index + 2] : ''; + if ($afterNext === '' || ctype_space($afterNext)) { + $inLineComment = true; + $index++; + continue; + } + } + + if ($char === '#') { + $inLineComment = true; + continue; + } + + if ($char === '/' && $next === '*') { + $inBlockComment = true; + $index++; + continue; + } + } + + if ($char === "'" && !$inDoubleQuote && !$inBacktickQuote) { + $escaped = $index > 0 && $sql[$index - 1] === '\\'; + if (!$escaped) { + $inSingleQuote = !$inSingleQuote; + } + $buffer .= $char; + continue; + } + + if ($char === '"' && !$inSingleQuote && !$inBacktickQuote) { + $escaped = $index > 0 && $sql[$index - 1] === '\\'; + if (!$escaped) { + $inDoubleQuote = !$inDoubleQuote; + } + $buffer .= $char; + continue; + } + + if ($char === '`' && !$inSingleQuote && !$inDoubleQuote) { + $inBacktickQuote = !$inBacktickQuote; + $buffer .= $char; + continue; + } + + if ($char === ';' && !$inSingleQuote && !$inDoubleQuote && !$inBacktickQuote) { + $trimmed = trim($buffer); + if ($trimmed !== '') { + $statements[] = $trimmed; + } + $buffer = ''; + continue; + } + + $buffer .= $char; + } + + $trimmed = trim($buffer); + if ($trimmed !== '') { + $statements[] = $trimmed; + } + + return $statements; + } +}