From 141f0a0155118b1437e794808a15ed3741b1ac67 Mon Sep 17 00:00:00 2001 From: fs Date: Sun, 22 Mar 2026 13:45:16 +0100 Subject: [PATCH] refactor: extract shared code from bin/ scripts and remove legacy scheduler runner - Extract ModuleManifestLoader for standalone manifest loading (used by module-deactivate and ValidateCommand) - Extract SqlStatementParser, ModuleMigrationRepository, and ModuleMigrationService from bin/module-migrate.php to enforce strict layering (SQL in repository, orchestration in service) - Delete bin/scheduler-run.php (fully superseded by bin/console scheduler:run) - Update docs and test fixtures to reference bin/console Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/module-deactivate.php | 18 +- bin/module-migrate.php | 216 +----------------- bin/scheduler-run.php | 58 ----- docs/explanation-di-container.md | 2 +- docs/howto-geplante-aufgaben.md | 2 +- docs/reference-benutzer-lifecycle-policy.md | 4 +- lib/App/Module/ModuleManifestLoader.php | 42 ++++ .../Commands/Module/ValidateCommand.php | 17 +- .../Module/ModuleMigrationRepository.php | 75 ++++++ .../ModuleMigrationRepositoryInterface.php | 24 ++ lib/Service/Module/ModuleMigrationService.php | 100 ++++++++ lib/Support/SqlStatementParser.php | 128 +++++++++++ tests/Http/RequestContextTest.php | 4 +- tests/Support/SqlStatementParserTest.php | 97 ++++++++ 14 files changed, 487 insertions(+), 300 deletions(-) delete mode 100755 bin/scheduler-run.php create mode 100644 lib/App/Module/ModuleManifestLoader.php create mode 100644 lib/Repository/Module/ModuleMigrationRepository.php create mode 100644 lib/Repository/Module/ModuleMigrationRepositoryInterface.php create mode 100644 lib/Service/Module/ModuleMigrationService.php create mode 100644 lib/Support/SqlStatementParser.php create mode 100644 tests/Support/SqlStatementParserTest.php diff --git a/bin/module-deactivate.php b/bin/module-deactivate.php index 46f7254..5547adf 100644 --- a/bin/module-deactivate.php +++ b/bin/module-deactivate.php @@ -37,20 +37,10 @@ function moduleDeactivateRun(): int } $projectRoot = moduleCliProjectRoot(); - $manifestFile = $projectRoot . '/modules/' . $moduleId . '/module.php'; - - if (!is_file($manifestFile)) { - fwrite(STDERR, "Error: module manifest not found at {$manifestFile}\n"); - return 1; - } - - $raw = require $manifestFile; - if (!is_array($raw)) { - fwrite(STDERR, "Error: manifest must return an array.\n"); - return 1; - } - - $manifest = \MintyPHP\App\Module\ModuleManifest::fromArray($raw, dirname($manifestFile)); + $manifest = \MintyPHP\App\Module\ModuleManifestLoader::loadFromDisk( + $projectRoot . '/modules', + $moduleId + ); $handlerClass = $manifest->deactivationHandler; if ($handlerClass === null) { diff --git a/bin/module-migrate.php b/bin/module-migrate.php index a98e131..434f0d0 100644 --- a/bin/module-migrate.php +++ b/bin/module-migrate.php @@ -18,7 +18,8 @@ declare(strict_types=1); use MintyPHP\App\Module\ModuleRegistry; -use MintyPHP\DB; +use MintyPHP\Repository\Module\ModuleMigrationRepository; +use MintyPHP\Service\Module\ModuleMigrationService; require_once __DIR__ . '/module-cli-bootstrap.php'; @@ -31,220 +32,17 @@ function moduleMigrateRun(): int $lockFile, 'module-migrate: another migration run is in progress, skipping.', static function (): int { - $totalApplied = 0; - $registry = app(ModuleRegistry::class); - $modules = $registry->getModules(); - $hasModules = count($modules) > 0; - - if (!$hasModules) { - fwrite(STDOUT, "module-migrate: no modules enabled, nothing to do.\n"); - return 0; - } - - // Ensure tracking table exists (safe if already created by db/updates/ script) - DB::query( - 'CREATE TABLE IF NOT EXISTS `module_migrations` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `module_id` VARCHAR(64) NOT NULL, - `filename` VARCHAR(255) NOT NULL, - `applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), - UNIQUE KEY `uq_module_migration` (`module_id`, `filename`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' + $service = new ModuleMigrationService( + app(ModuleRegistry::class), + new ModuleMigrationRepository() ); - - foreach ($modules as $manifest) { - $migrationsPath = $manifest->migrationsPath; - if ($migrationsPath === null || !is_dir($migrationsPath)) { - continue; - } - - // Discover SQL files - $files = glob($migrationsPath . '/*.sql'); - if ($files === false || count($files) === 0) { - continue; - } - sort($files); // alphabetical order - - // Get already-applied filenames - $applied = []; - $rows = DB::select('SELECT filename FROM module_migrations WHERE module_id = ?', (string) $manifest->id); - foreach ((array) $rows as $row) { - if (!is_array($row)) { - continue; - } - $filename = trim((string) (($row['module_migrations']['filename'] ?? $row['filename'] ?? ''))); - if ($filename !== '') { - $applied[$filename] = true; - } - } - - foreach ($files as $file) { - $filename = basename($file); - if (isset($applied[$filename])) { - continue; - } - - $sql = file_get_contents($file); - if ($sql === false || trim($sql) === '') { - fwrite(STDERR, sprintf("module-migrate: WARNING skipping empty file %s/%s\n", $manifest->id, $filename)); - continue; - } - - fwrite(STDOUT, sprintf("module-migrate: applying %s/%s ... ", $manifest->id, $filename)); - - // Execute migration inside a transaction so partial failures don't leave the DB inconsistent. - DB::query('START TRANSACTION'); - try { - $statements = moduleMigrateSplitSqlStatements($sql); - foreach ($statements as $statement) { - DB::query($statement); - } - - // Record as applied - DB::query( - 'INSERT INTO module_migrations (module_id, filename) VALUES (?, ?)', - (string) $manifest->id, - $filename - ); - DB::query('COMMIT'); - } catch (\Throwable $migrationError) { - DB::query('ROLLBACK'); - throw new \RuntimeException( - sprintf("Migration %s/%s failed: %s", $manifest->id, $filename, $migrationError->getMessage()), - 0, - $migrationError - ); - } - - fwrite(STDOUT, "OK\n"); - $totalApplied++; - } - } - - if ($totalApplied === 0) { - fwrite(STDOUT, "module-migrate: all modules up to date, 0 new migrations.\n"); - } else { - fwrite(STDOUT, sprintf("module-migrate: applied %d migration(s).\n", $totalApplied)); - } - - return 0; + $result = $service->applyPendingMigrations(); + return $result['ok'] ? 0 : 1; } ); }); } -/** - * @return list - */ -function moduleMigrateSplitSqlStatements(string $sql): array -{ - if (preg_match('/^\s*DELIMITER\s+/mi', $sql) === 1) { - throw new RuntimeException('DELIMITER directives are not supported in module migration SQL files.'); - } - - $statements = []; - $buffer = ''; - $length = strlen($sql); - $inSingleQuote = false; - $inDoubleQuote = false; - $inBacktick = false; - $inLineComment = false; - $inBlockComment = false; - $escapeNext = false; - - for ($i = 0; $i < $length; $i++) { - $char = $sql[$i]; - $next = $i + 1 < $length ? $sql[$i + 1] : ''; - - if ($inLineComment) { - $buffer .= $char; - if ($char === "\n") { - $inLineComment = false; - } - continue; - } - - if ($inBlockComment) { - $buffer .= $char; - if ($char === '*' && $next === '/') { - $buffer .= $next; - $i++; - $inBlockComment = false; - } - continue; - } - - if (!$inSingleQuote && !$inDoubleQuote && !$inBacktick) { - if ($char === '-' && $next === '-' && ($i + 2 >= $length || ctype_space($sql[$i + 2]))) { - $buffer .= $char . $next; - $i++; - $inLineComment = true; - continue; - } - if ($char === '#') { - $buffer .= $char; - $inLineComment = true; - continue; - } - if ($char === '/' && $next === '*') { - $buffer .= $char . $next; - $i++; - $inBlockComment = true; - continue; - } - } - - if ($escapeNext) { - $buffer .= $char; - $escapeNext = false; - continue; - } - - if (($inSingleQuote || $inDoubleQuote) && $char === '\\') { - $buffer .= $char; - $escapeNext = true; - continue; - } - - if (!$inDoubleQuote && !$inBacktick && $char === '\'') { - $inSingleQuote = !$inSingleQuote; - $buffer .= $char; - continue; - } - - if (!$inSingleQuote && !$inBacktick && $char === '"') { - $inDoubleQuote = !$inDoubleQuote; - $buffer .= $char; - continue; - } - - if (!$inSingleQuote && !$inDoubleQuote && $char === '`') { - $inBacktick = !$inBacktick; - $buffer .= $char; - continue; - } - - if (!$inSingleQuote && !$inDoubleQuote && !$inBacktick && $char === ';') { - $statement = trim($buffer); - if ($statement !== '') { - $statements[] = $statement; - } - $buffer = ''; - continue; - } - - $buffer .= $char; - } - - $tail = trim($buffer); - if ($tail !== '') { - $statements[] = $tail; - } - - return $statements; -} - if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) { fwrite(STDERR, "Hint: prefer `php bin/console module:migrate` instead.\n"); exit(moduleMigrateRun()); diff --git a/bin/scheduler-run.php b/bin/scheduler-run.php deleted file mode 100755 index 684b083..0000000 --- a/bin/scheduler-run.php +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env php -createSchedulerRunService()->runDueJobs(); - if (!($result['ok'] ?? false)) { - $error = (string) ($result['error'] ?? 'unexpected_error'); - fwrite(STDERR, sprintf("scheduler run failed: %s\n", $error)); - $exitCode = 1; - } else { - $line = sprintf( - "scheduler completed: processed=%d success=%d failed=%d skipped=%d duration_ms=%d\n", - (int) ($result['processed'] ?? 0), - (int) ($result['success'] ?? 0), - (int) ($result['failed'] ?? 0), - (int) ($result['skipped'] ?? 0), - (int) ($result['duration_ms'] ?? 0) - ); - fwrite(STDOUT, $line); - } -} catch (Throwable $exception) { - try { - app(SystemAuditService::class)->record('scheduler.run', 'failed', [ - 'error_code' => 'unexpected_error', - 'metadata' => [ - 'trigger_type' => 'scheduler', - 'run_status' => 'failed', - 'processed' => 0, - 'success_count' => 0, - 'failed_count' => 0, - 'skipped_count' => 0, - 'duration_ms' => max(0, (int) round((microtime(true) - $startedAt) * 1000)), - 'bootstrap_error' => true, - ], - ]); - } catch (Throwable) { - // fail-open - } - - fwrite(STDERR, sprintf("scheduler run failed: unexpected_error: %s\n", $exception->getMessage())); - $exitCode = 1; -} finally { - DB::close(); -} -exit($exitCode); diff --git a/docs/explanation-di-container.md b/docs/explanation-di-container.md index 75a094d..6026581 100644 --- a/docs/explanation-di-container.md +++ b/docs/explanation-di-container.md @@ -103,6 +103,6 @@ $meinService = app(\MintyPHP\Service\MeinNeuerService::class); den konkreten Service direkt registrieren und per `app()` holen. - Der Container lebt **pro HTTP-Anfrage** — er wird in `web/index.php` initialisiert und über `setAppContainer()` im globalen State abgelegt. -- In CLI-Skripten (`bin/`) wird der Container separat initialisiert (s. `bin/scheduler-run.php`). +- In CLI-Skripten (`bin/`) wird der Container separat initialisiert (s. `bin/module-cli-bootstrap.php`). - In `lib/Service/**` außerhalb von `*Factory.php` niemals `new ...Factory()`, `new ...Service()` oder `new ...Repository()` direkt instanziieren. diff --git a/docs/howto-geplante-aufgaben.md b/docs/howto-geplante-aufgaben.md index 70d18c6..e8dd755 100644 --- a/docs/howto-geplante-aufgaben.md +++ b/docs/howto-geplante-aufgaben.md @@ -8,7 +8,7 @@ Scheduler-Betrieb und Job-Erweiterung kurz und verlässlich dokumentieren. ## Kernkomponenten -- Runner: `bin/scheduler-run.php` +- Runner: `bin/console scheduler:run` - Orchestrierung: `lib/Service/Scheduler/SchedulerRunService.php` - Job-Konfiguration: `lib/Service/Scheduler/ScheduledJobService.php` - Registry: `lib/Service/Scheduler/ScheduledJobRegistry.php` diff --git a/docs/reference-benutzer-lifecycle-policy.md b/docs/reference-benutzer-lifecycle-policy.md index 5b974cd..ab90f74 100644 --- a/docs/reference-benutzer-lifecycle-policy.md +++ b/docs/reference-benutzer-lifecycle-policy.md @@ -50,11 +50,11 @@ Automatische Lifecycle-Aktionen ignorieren Benutzer mit mindestens einer aktiven ### Cron/CLI - Bevorzugt über den zentralen Scheduler: - - Script: `bin/scheduler-run.php` (führt fällige Jobs aus, inkl. `user_lifecycle_run`) + - Kommando: `bin/console scheduler:run` (führt fällige Jobs aus, inkl. `user_lifecycle_run`) - Beispiel (minütlich): ```bash -* * * * * docker compose exec php php bin/scheduler-run.php +* * * * * docker compose exec php php bin/console scheduler:run ``` Exit-Codes: diff --git a/lib/App/Module/ModuleManifestLoader.php b/lib/App/Module/ModuleManifestLoader.php new file mode 100644 index 0000000..f57fdd3 --- /dev/null +++ b/lib/App/Module/ModuleManifestLoader.php @@ -0,0 +1,42 @@ +getMessage()], []]; - } - - // 4. ModuleManifest validates - try { - $manifest = ModuleManifest::fromArray($raw, $moduleDir); - } catch (\Throwable $e) { - return [['Manifest validation error: ' . $e->getMessage()], []]; + return [['Manifest load/validation error: ' . $e->getMessage()], []]; } // Register module lib/ with autoloader so we can resolve classes diff --git a/lib/Repository/Module/ModuleMigrationRepository.php b/lib/Repository/Module/ModuleMigrationRepository.php new file mode 100644 index 0000000..9daea6c --- /dev/null +++ b/lib/Repository/Module/ModuleMigrationRepository.php @@ -0,0 +1,75 @@ + Filenames already applied, keyed by filename + */ + public function getAppliedFilenames(string $moduleId): array + { + $applied = []; + $rows = DB::select('SELECT filename FROM module_migrations WHERE module_id = ?', $moduleId); + foreach ((array) $rows as $row) { + if (!is_array($row)) { + continue; + } + $filename = trim((string) (($row['module_migrations']['filename'] ?? $row['filename'] ?? ''))); + if ($filename !== '') { + $applied[$filename] = true; + } + } + return $applied; + } + + public function recordApplied(string $moduleId, string $filename): void + { + DB::query( + 'INSERT INTO module_migrations (module_id, filename) VALUES (?, ?)', + $moduleId, + $filename + ); + } + + public function beginTransaction(): void + { + DB::query('START TRANSACTION'); + } + + public function commit(): void + { + DB::query('COMMIT'); + } + + public function rollback(): void + { + DB::query('ROLLBACK'); + } + + public function executeStatement(string $sql): void + { + DB::query($sql); + } +} diff --git a/lib/Repository/Module/ModuleMigrationRepositoryInterface.php b/lib/Repository/Module/ModuleMigrationRepositoryInterface.php new file mode 100644 index 0000000..6c8184a --- /dev/null +++ b/lib/Repository/Module/ModuleMigrationRepositoryInterface.php @@ -0,0 +1,24 @@ + + */ + public function getAppliedFilenames(string $moduleId): array; + + public function recordApplied(string $moduleId, string $filename): void; + + public function beginTransaction(): void; + + public function commit(): void; + + public function rollback(): void; + + public function executeStatement(string $sql): void; +} diff --git a/lib/Service/Module/ModuleMigrationService.php b/lib/Service/Module/ModuleMigrationService.php new file mode 100644 index 0000000..a03197b --- /dev/null +++ b/lib/Service/Module/ModuleMigrationService.php @@ -0,0 +1,100 @@ +registry->getModules(); + + if (count($modules) === 0) { + fwrite(STDOUT, "module-migrate: no modules enabled, nothing to do.\n"); + return ['ok' => true, 'applied' => 0]; + } + + $this->repository->ensureTrackingTable(); + + $totalApplied = 0; + + foreach ($modules as $manifest) { + $migrationsPath = $manifest->migrationsPath; + if ($migrationsPath === null || !is_dir($migrationsPath)) { + continue; + } + + $files = glob($migrationsPath . '/*.sql'); + if ($files === false || count($files) === 0) { + continue; + } + sort($files); + + $applied = $this->repository->getAppliedFilenames((string) $manifest->id); + + foreach ($files as $file) { + $filename = basename($file); + if (isset($applied[$filename])) { + continue; + } + + $sql = file_get_contents($file); + if ($sql === false || trim($sql) === '') { + fwrite(STDERR, sprintf("module-migrate: WARNING skipping empty file %s/%s\n", $manifest->id, $filename)); + continue; + } + + fwrite(STDOUT, sprintf("module-migrate: applying %s/%s ... ", $manifest->id, $filename)); + + $this->repository->beginTransaction(); + try { + $statements = SqlStatementParser::splitStatements($sql); + foreach ($statements as $statement) { + $this->repository->executeStatement($statement); + } + + $this->repository->recordApplied((string) $manifest->id, $filename); + $this->repository->commit(); + } catch (\Throwable $migrationError) { + $this->repository->rollback(); + throw new RuntimeException( + sprintf("Migration %s/%s failed: %s", $manifest->id, $filename, $migrationError->getMessage()), + 0, + $migrationError + ); + } + + fwrite(STDOUT, "OK\n"); + $totalApplied++; + } + } + + if ($totalApplied === 0) { + fwrite(STDOUT, "module-migrate: all modules up to date, 0 new migrations.\n"); + } else { + fwrite(STDOUT, sprintf("module-migrate: applied %d migration(s).\n", $totalApplied)); + } + + return ['ok' => true, 'applied' => $totalApplied]; + } +} diff --git a/lib/Support/SqlStatementParser.php b/lib/Support/SqlStatementParser.php new file mode 100644 index 0000000..9ecc36c --- /dev/null +++ b/lib/Support/SqlStatementParser.php @@ -0,0 +1,128 @@ + + * + * @throws RuntimeException If a DELIMITER directive is encountered + */ + public static function splitStatements(string $sql): array + { + if (preg_match('/^\s*DELIMITER\s+/mi', $sql) === 1) { + throw new RuntimeException('DELIMITER directives are not supported in module migration SQL files.'); + } + + $statements = []; + $buffer = ''; + $length = strlen($sql); + $inSingleQuote = false; + $inDoubleQuote = false; + $inBacktick = false; + $inLineComment = false; + $inBlockComment = false; + $escapeNext = false; + + for ($i = 0; $i < $length; $i++) { + $char = $sql[$i]; + $next = $i + 1 < $length ? $sql[$i + 1] : ''; + + if ($inLineComment) { + $buffer .= $char; + if ($char === "\n") { + $inLineComment = false; + } + continue; + } + + if ($inBlockComment) { + $buffer .= $char; + if ($char === '*' && $next === '/') { + $buffer .= $next; + $i++; + $inBlockComment = false; + } + continue; + } + + if (!$inSingleQuote && !$inDoubleQuote && !$inBacktick) { + if ($char === '-' && $next === '-' && ($i + 2 >= $length || ctype_space($sql[$i + 2]))) { + $buffer .= $char . $next; + $i++; + $inLineComment = true; + continue; + } + if ($char === '#') { + $buffer .= $char; + $inLineComment = true; + continue; + } + if ($char === '/' && $next === '*') { + $buffer .= $char . $next; + $i++; + $inBlockComment = true; + continue; + } + } + + if ($escapeNext) { + $buffer .= $char; + $escapeNext = false; + continue; + } + + if (($inSingleQuote || $inDoubleQuote) && $char === '\\') { + $buffer .= $char; + $escapeNext = true; + continue; + } + + if (!$inDoubleQuote && !$inBacktick && $char === '\'') { + $inSingleQuote = !$inSingleQuote; + $buffer .= $char; + continue; + } + + if (!$inSingleQuote && !$inBacktick && $char === '"') { + $inDoubleQuote = !$inDoubleQuote; + $buffer .= $char; + continue; + } + + if (!$inSingleQuote && !$inDoubleQuote && $char === '`') { + $inBacktick = !$inBacktick; + $buffer .= $char; + continue; + } + + if (!$inSingleQuote && !$inDoubleQuote && !$inBacktick && $char === ';') { + $statement = trim($buffer); + if ($statement !== '') { + $statements[] = $statement; + } + $buffer = ''; + continue; + } + + $buffer .= $char; + } + + $tail = trim($buffer); + if ($tail !== '') { + $statements[] = $tail; + } + + return $statements; + } +} diff --git a/tests/Http/RequestContextTest.php b/tests/Http/RequestContextTest.php index 7ea1a8f..04e2ee7 100644 --- a/tests/Http/RequestContextTest.php +++ b/tests/Http/RequestContextTest.php @@ -41,13 +41,13 @@ class RequestContextTest extends TestCase RequestContext::start([ 'channel' => 'scheduler', - 'path' => '/bin/scheduler-run.php', + 'path' => 'bin/console', ]); $context = RequestContext::context(); $this->assertSame('scheduler', $context['channel']); - $this->assertSame('/bin/scheduler-run.php', $context['path']); + $this->assertSame('bin/console', $context['path']); $this->assertSame('POST', $context['method']); } } diff --git a/tests/Support/SqlStatementParserTest.php b/tests/Support/SqlStatementParserTest.php new file mode 100644 index 0000000..b46a8ed --- /dev/null +++ b/tests/Support/SqlStatementParserTest.php @@ -0,0 +1,97 @@ +assertCount(2, $result); + $this->assertSame('CREATE TABLE foo (id INT)', $result[0]); + $this->assertSame('INSERT INTO foo VALUES (1)', $result[1]); + } + + public function testSemicolonInsideSingleQuotedString(): void + { + $sql = "INSERT INTO foo VALUES ('a;b');"; + $result = SqlStatementParser::splitStatements($sql); + + $this->assertCount(1, $result); + $this->assertSame("INSERT INTO foo VALUES ('a;b')", $result[0]); + } + + public function testSemicolonInsideDoubleQuotedString(): void + { + $sql = 'INSERT INTO foo VALUES ("a;b");'; + $result = SqlStatementParser::splitStatements($sql); + + $this->assertCount(1, $result); + $this->assertSame('INSERT INTO foo VALUES ("a;b")', $result[0]); + } + + public function testSemicolonInsideBackticks(): void + { + $sql = "SELECT `col;name` FROM foo;"; + $result = SqlStatementParser::splitStatements($sql); + + $this->assertCount(1, $result); + $this->assertSame('SELECT `col;name` FROM foo', $result[0]); + } + + public function testLineComments(): void + { + $sql = "-- this is a comment\nSELECT 1;\n# another comment\nSELECT 2;"; + $result = SqlStatementParser::splitStatements($sql); + + $this->assertCount(2, $result); + } + + public function testBlockComments(): void + { + $sql = "SELECT /* ; not a delimiter */ 1;"; + $result = SqlStatementParser::splitStatements($sql); + + $this->assertCount(1, $result); + $this->assertSame('SELECT /* ; not a delimiter */ 1', $result[0]); + } + + public function testDelimiterDirectiveThrows(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('DELIMITER'); + + SqlStatementParser::splitStatements("DELIMITER //\nCREATE PROCEDURE foo() BEGIN END//\nDELIMITER ;"); + } + + public function testEmptyInput(): void + { + $this->assertSame([], SqlStatementParser::splitStatements('')); + $this->assertSame([], SqlStatementParser::splitStatements(' ')); + $this->assertSame([], SqlStatementParser::splitStatements(";\n;")); + } + + public function testTrailingStatementWithoutSemicolon(): void + { + $sql = "SELECT 1; SELECT 2"; + $result = SqlStatementParser::splitStatements($sql); + + $this->assertCount(2, $result); + $this->assertSame('SELECT 1', $result[0]); + $this->assertSame('SELECT 2', $result[1]); + } + + public function testEscapedQuotesInString(): void + { + $sql = "INSERT INTO foo VALUES ('it\\'s;here');"; + $result = SqlStatementParser::splitStatements($sql); + + $this->assertCount(1, $result); + } +}