From 6daa93f757045484c435560319c04d42cc804334 Mon Sep 17 00:00:00 2001 From: acoulton Date: Sat, 22 Oct 2022 11:18:29 +0100 Subject: [PATCH 1/3] Add a SqlDumpImporter utility class Provide a standard way to read in and execute statements from a SQL dump file. --- CHANGELOG.md | 1 + composer.json | 3 +- src/Phinx/Util/SqlDumpImporter.php | 93 ++++++++ tests/Phinx/Util/SqlDumpImporterTest.php | 264 +++++++++++++++++++++++ 4 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 src/Phinx/Util/SqlDumpImporter.php create mode 100644 tests/Phinx/Util/SqlDumpImporterTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index bbb8024..2a01fad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ** UNRELEASED ** +* Add a `SqlDumpImporter` utility class to have a migration load in statements from a standard SQL dump file. * Remove support for 'wrapping' database adapters, and the `TimedOutputAdapter`. The timed adapter was the only remaining implementation of this proxy pattern, and was only actually printing statistics for `insert()` operations. This was not that useful, and the overhead (e.g. on identifying dead code / improving type safety / adding to diff --git a/composer.json b/composer.json index 6f246d8..a0cc677 100644 --- a/composer.json +++ b/composer.json @@ -52,7 +52,8 @@ "symfony/yaml": "^4.0" }, "require-dev": { - "phpunit/phpunit": "^9.5.5" + "phpunit/phpunit": "^9.5.5", + "mikey179/vfsstream": "^1.6.11" }, "conflict": { "robmorgan/phinx": "*" diff --git a/src/Phinx/Util/SqlDumpImporter.php b/src/Phinx/Util/SqlDumpImporter.php new file mode 100644 index 0000000..9db33b2 --- /dev/null +++ b/src/Phinx/Util/SqlDumpImporter.php @@ -0,0 +1,93 @@ +iterateSqlStatements($file, $max_statement_length) as $index => $statement) { + try { + $this->adapter->execute($statement); + } catch (\Exception $e) { + $this->output->writeln(sprintf('Failed importing %s - failing statement #%d:', $file, $index)); + $this->output->writeln($statement); + throw $e; + } + + if ($index % 20 === 0) { + $this->output->write('.'); + } + } + + if ($index === 0) { + throw new \RuntimeException(sprintf("File %s is empty", $file)); + } + + // Close off the `.` progress line + $this->output->write("\n"); + $this->output->writeln(sprintf('%s statements executed from %s', $index, $file)); + } + + private function iterateSqlStatements(string $file, int $max_statement_length) + { + $fp = $this->openFileForReading($file); + $index = 1; + try { + while ( ! feof($fp)) { + $statement = stream_get_line($fp, $max_statement_length, ";\n"); + + // stream_get_line does not return the delimiter so can't tell if it actually hit the max buffer size. + // However it's fairly unlikely the statement is identical to the buffer size unless it was truncated. + // If you hit this, either reduce the length of individual statements in the SQL file, or increase the + // buffer size to accommodate them. + if (strlen($statement) === $max_statement_length) { + throw new \RuntimeException( + sprintf( + "Statement %s was exactly the buffer size (%s bytes) - most likely it was truncated?\nGot:\n%s", + $index, + $max_statement_length, + $statement + ) + ); + } + + if ($statement) { + yield $index => $statement; + $index++; + } + } + } finally { + fclose($fp); + } + } + + /** + * @param string $file + * + * @return resource + */ + private function openFileForReading(string $file) + { + try { + \set_error_handler(fn(int $type, string $msg) => throw new \RuntimeException("fopen failed: $msg ($type)")); + + return fopen($file, 'r'); + } catch (\Throwable $e) { + throw new \RuntimeException(sprintf('Could not open import file "%s": %s', $file, $e->getMessage()), 0, $e); + } finally { + \restore_error_handler(); + } + } +} diff --git a/tests/Phinx/Util/SqlDumpImporterTest.php b/tests/Phinx/Util/SqlDumpImporterTest.php new file mode 100644 index 0000000..58b9826 --- /dev/null +++ b/tests/Phinx/Util/SqlDumpImporterTest.php @@ -0,0 +1,264 @@ + [ + << [ + << [ + <<givenSqlFileWithContent($content); + $this->adapter = new class extends MysqlAdapter { + public array $executed = []; + + public function __construct() { } + + public function execute(string $sql): false|int + { + $this->executed[] = $sql; + + return 0; + } + }; + + $this->newSubject()->import($path); + + $this->assertSame($expectStatements, $this->adapter->executed); + } + + public function testItIsInitialisable() + { + $this->assertInstanceOf(SqlDumpImporter::class, $this->newSubject()); + } + + private function newSubject(): SqlDumpImporter + { + return new SqlDumpImporter($this->adapter, $this->output); + } + + public function testItLogsFailingStatementAndRethrows() + { + $path = $this->givenSqlFileWithContent( + <<adapter = new class($mockException) extends MysqlAdapter { + public function __construct(private \Exception $exception) { } + + public function execute(string $sql): false|int + { + if (\str_contains($sql, 'BADTYPE')) { + throw $this->exception; + } + + return 0; + } + }; + $this->output = new BufferedOutput(decorated: FALSE); + + try { + $this->newSubject()->import($path); + $this->fail('Expected exception, none got'); + } catch (\RuntimeException$e) { + $this->assertSame($e, $mockException, 'Expected exception to be rethrown'); + } + + $this->assertSame( + <<output->fetch() + ); + } + + public function testItThrowsIfFileIsEmpty() + { + $path = $this->givenSqlFileWithContent(''); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('is empty'); + + $this->newSubject()->import($path); + } + + private function givenSqlFileWithContent(string $fileContent): string + { + $vfs = vfsStream::setup('import', NULL, [ + 'some-sql.sql' => $fileContent, + ]); + $path = $vfs->getChild('some-sql.sql')->url(); + + return $path; + } + + public function testItThrowsIfFileNotExists() + { + $vfs = vfsStream::setup('import'); + $missing_file_path = $vfs->url().'/some-file.sql'; + $subject = $this->newSubject(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('some-file.sql'); + $subject->import($missing_file_path); + } + + protected function setUp(): void + { + parent::setUp(); + $this->adapter = $this->getMockBuilder(AdapterInterface::class)->getMock(); + $this->output = new NullOutput; + } + + public function testItThrowsIfStatementLineTooLong() + { + $path = $this->givenSqlFileWithContent( + <<expectException(\RuntimeException::class); + $this->expectExceptionMessage( + 'Statement 3 was exactly the buffer size (110 bytes) - most likely it was truncated' + ); + + $this->newSubject()->import($path, max_statement_length: 110); + } + + public function testItWritesRegularProgressAndSummaryAtEnd() + { + $queries = \str_repeat("/* SELECT anything */;\n", 45); + $path = $this->givenSqlFileWithContent($queries); + $this->output = new BufferedOutput; + $this->newSubject()->import($path); + + $this->assertSame( + <<output->fetch() + ); + } + +} From 579f7d7c7a9435d5dead40bcad7f1fe6fa76069c Mon Sep 17 00:00:00 2001 From: acoulton Date: Mon, 24 Oct 2022 12:50:41 +0100 Subject: [PATCH 2/3] Bump minimum phpunit version Older versions appear not to cope with mocking methods with union return types properly. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index a0cc677..5f0bbbc 100644 --- a/composer.json +++ b/composer.json @@ -52,7 +52,7 @@ "symfony/yaml": "^4.0" }, "require-dev": { - "phpunit/phpunit": "^9.5.5", + "phpunit/phpunit": "^9.5.25", "mikey179/vfsstream": "^1.6.11" }, "conflict": { From fb685584a5a7203da51ccb85155a6bab60b72eea Mon Sep 17 00:00:00 2001 From: acoulton Date: Mon, 24 Oct 2022 12:51:29 +0100 Subject: [PATCH 3/3] Bump changelog for 1.0.0 release --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a01fad..de8bdbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Version History -** UNRELEASED ** +** 1.0.0 ** (2022-10-24) * Add a `SqlDumpImporter` utility class to have a migration load in statements from a standard SQL dump file. * Remove support for 'wrapping' database adapters, and the `TimedOutputAdapter`. The timed adapter was the only