Skip to content

Commit

Permalink
Merge pull request #48 from swiftotter/46-mysqldump-per-table
Browse files Browse the repository at this point in the history
#46: Split local export mysqldump command to multiple commands (one per table)
  • Loading branch information
JesseMaxwell authored Nov 17, 2022
2 parents e841212 + ec39c6e commit 8da0872
Show file tree
Hide file tree
Showing 4 changed files with 250 additions and 98 deletions.
70 changes: 70 additions & 0 deletions src/Engines/MySql/Export/CommandAssembler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace Driver\Engines\MySql\Export;

use Driver\Engines\ConnectionInterface;
use Driver\Pipeline\Environment\EnvironmentInterface;

use function implode;
use function in_array;

class CommandAssembler
{
private TablesProvider $tablesProvider;

public function __construct(TablesProvider $tablesProvider)
{
$this->tablesProvider = $tablesProvider;
}

public function execute(
ConnectionInterface $connection,
EnvironmentInterface $environment,
string $dumpFile
): string {
$commands = [];
$ignoredTables = $this->tablesProvider->getIgnoredTables($environment);
$emptyTables = $this->tablesProvider->getEmptyTables($environment);
foreach ($this->tablesProvider->getAllTables($connection) as $table) {
if (in_array($table, $ignoredTables) || in_array($table, $emptyTables)) {
continue;
}
$commands[] = $this->getSingleCommand($connection, [$table], $dumpFile);
}
if (!empty($emptyTables)) {
$commands[] = $this->getSingleCommand($connection, $emptyTables, $dumpFile, false);
}
if (empty($commands)) {
return '';
}
$commands[] = "cat $dumpFile | "
. "sed -E 's/DEFINER[ ]*=[ ]*`[^`]+`@`[^`]+`/DEFINER=CURRENT_USER/g' | gzip > $dumpFile.gz";
return implode(';', $commands);
}

/**
* @param string[] $tables
*/
private function getSingleCommand(
ConnectionInterface $connection,
array $tables,
string $dumpFile,
bool $withData = true
): string {
$parts = [
"mysqldump --user=\"{$connection->getUser()}\"",
"--password=\"{$connection->getPassword()}\"",
"--single-transaction",
"--host={$connection->getHost()}",
$connection->getDatabase(),
implode(' ', $tables)
];
if (!$withData) {
$parts[] = '--no-data';
}
$parts[] = ">> $dumpFile";
return implode(' ', $parts);
}
}
133 changes: 35 additions & 98 deletions src/Engines/MySql/Export/Primary.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
use Driver\System\Configuration;
use Driver\System\Logs\LoggerInterface;
use Driver\System\Random;
use Exception;
use RuntimeException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\ConsoleOutput;

Expand All @@ -28,6 +30,7 @@ class Primary extends Command implements CommandInterface, CleanupInterface
private ?string $path = null;
private Configuration $configuration;
private ConsoleOutput $output;
private CommandAssembler $commandAssembler;

// phpcs:ignore SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingTraversableTypeHintSpecification
public function __construct(
Expand All @@ -36,6 +39,7 @@ public function __construct(
LoggerInterface $logger,
Random $random,
ConsoleOutput $output,
CommandAssembler $commandAssembler,
array $properties = []
) {
$this->localConnection = $localConnection;
Expand All @@ -44,6 +48,7 @@ public function __construct(
$this->random = $random;
$this->configuration = $configuration;
$this->output = $output;
$this->commandAssembler = $commandAssembler;
return parent::__construct('mysql-default-export');
}

Expand All @@ -52,35 +57,39 @@ public function go(TransportInterface $transport, EnvironmentInterface $environm
$transport->getLogger()->notice("Exporting database from local MySql");
$this->output->writeln("<comment>Exporting database from local MySql</comment>");

$transport->getLogger()->debug(
"Local connection string: " . str_replace(
try {
$command = $this->commandAssembler->execute($this->localConnection, $environment, $this->getDumpFile());
if (empty($command)) {
throw new RuntimeException('Nothing to import');
}

$transport->getLogger()->debug(
"Local connection string: " . str_replace(
$this->localConnection->getPassword(),
'',
$command
)
);
$this->output->writeln("<comment>Local connection string: </comment>" . str_replace(
$this->localConnection->getPassword(),
'',
$this->assembleCommand($environment)
)
);
$this->output->writeln("<comment>Local connection string: </comment>" . str_replace(
$this->localConnection->getPassword(),
'',
$this->assembleCommand($environment)
));

$command = implode(';', array_filter([
$this->assembleCommand($environment),
$this->assembleEmptyCommand($environment)
]));

$results = system($command);

if ($results) {
$this->output->writeln('<error>Import to RDS instance failed: ' . $results . '</error>');
throw new \Exception('Import to RDS instance failed: ' . $results);
} else {
$this->logger->notice("Database dump has completed.");
$this->output->writeln("<info>Database dump has completed.</info>");
return $transport->withStatus(new Status('sandbox_init', 'success'))
->withNewData('dump-file', $this->getDumpFile());
$command
));

$results = system($command);

if ($results) {
throw new RuntimeException($results);
}
} catch (Exception $e) {
$this->output->writeln('<error>Import to RDS instance failed: ' . $e->getMessage() . '</error>');
throw new Exception('Import to RDS instance failed: ' . $e->getMessage());
}

$this->logger->notice("Database dump has completed.");
$this->output->writeln("<info>Database dump has completed.</info>");
return $transport->withStatus(new Status('sandbox_init', 'success'))
->withNewData('dump-file', $this->getDumpFile());
}

public function cleanup(TransportInterface $transport, EnvironmentInterface $environment): TransportInterface
Expand All @@ -97,78 +106,6 @@ public function getProperties(): array
return $this->properties;
}

public function assembleEmptyCommand(EnvironmentInterface $environment): string
{
$tables = implode(' ', $environment->getEmptyTables());

if (!$tables) {
return '';
}

return implode(' ', array_merge(
$this->getDumpCommand(),
[
"--no-data",
$tables,
"| sed -E 's/DEFINER[ ]*=[ ]*`[^`]+`@`[^`]+`/DEFINER=CURRENT_USER/g'",
">>",
$this->getDumpFile()
]
));
}

public function assembleCommand(EnvironmentInterface $environment): string
{
return implode(' ', array_merge(
$this->getDumpCommand(),
[
$this->assembleEmptyTables($environment),
$this->assembleIgnoredTables($environment),
"| sed -E 's/DEFINER[ ]*=[ ]*`[^`]+`@`[^`]+`/DEFINER=CURRENT_USER/g'",
">",
$this->getDumpFile()
]
));
}

/**
* @return string[]
*/
private function getDumpCommand(): array
{
return [
"mysqldump --user=\"{$this->localConnection->getUser()}\"",
"--password=\"{$this->localConnection->getPassword()}\"",
"--single-transaction",
"--compress",
"--order-by-primary",
"--host={$this->localConnection->getHost()}",
"{$this->localConnection->getDatabase()}"
];
}

private function assembleEmptyTables(EnvironmentInterface $environment): string
{
$tables = $environment->getEmptyTables();
$output = [];

foreach ($tables as $table) {
$output[] = '--ignore-table=' . $this->localConnection->getDatabase() . '.' . $table;
}

return implode(' ', $output);
}

private function assembleIgnoredTables(EnvironmentInterface $environment): string
{
$tables = $environment->getIgnoredTables();
$output = implode(' | ', array_map(function ($table) {
return "awk '!/^INSERT INTO `{$table}` VALUES/'";
}, $tables));

return $output ? ' | ' . $output : '';
}

private function getDumpFile(): string
{
if (!$this->path) {
Expand Down
46 changes: 46 additions & 0 deletions src/Engines/MySql/Export/TablesProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace Driver\Engines\MySql\Export;

use Driver\Engines\ConnectionInterface;
use Driver\Pipeline\Environment\EnvironmentInterface;

use function exec;
use function explode;

class TablesProvider
{
/**
* @return string[]
*/
public function getAllTables(ConnectionInterface $connection): array
{
$command = "mysql --user=\"{$connection->getUser()}\" --password=\"{$connection->getPassword()}\" "
. "--host=\"{$connection->getHost()}\" --skip-column-names "
. "-e \"SELECT GROUP_CONCAT(table_name SEPARATOR ',') FROM information_schema.tables "
. "WHERE table_schema = '{$connection->getDatabase()}';\"";
$result = exec($command);
if (!$result) {
throw new \RuntimeException('Unable to get table names');
}
return explode(",", $result);
}

/**
* @return string[]
*/
public function getEmptyTables(EnvironmentInterface $environment): array
{
return $environment->getEmptyTables();
}

/**
* @return string[]
*/
public function getIgnoredTables(EnvironmentInterface $environment): array
{
return $environment->getIgnoredTables();
}
}
99 changes: 99 additions & 0 deletions src/Tests/Unit/Engines/MySql/Export/CommandAssemblerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

namespace Driver\Tests\Unit\Engines\MySql\Export;

use Driver\Engines\ConnectionInterface;
use Driver\Engines\MySql\Export\CommandAssembler;
use Driver\Engines\MySql\Export\TablesProvider;
use Driver\Pipeline\Environment\EnvironmentInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

class CommandAssemblerTest extends TestCase
{
private CommandAssembler $commandAssembler;

/** @var TablesProvider&MockObject */
private MockObject $tablesProviderMock;

/** @var ConnectionInterface&MockObject */
private MockObject $connectionMock;

/** @var EnvironmentInterface&MockObject */
private MockObject $environmentMock;

public function setUp(): void
{
$this->tablesProviderMock = $this->getMockBuilder(TablesProvider::class)
->disableOriginalConstructor()->getMock();
$this->connectionMock = $this->getMockBuilder(ConnectionInterface::class)->getMockForAbstractClass();
$this->connectionMock->expects($this->any())->method('getUser')->willReturn('user');
$this->connectionMock->expects($this->any())->method('getPassword')->willReturn('password');
$this->connectionMock->expects($this->any())->method('getHost')->willReturn('host');
$this->connectionMock->expects($this->any())->method('getDatabase')->willReturn('db');
$this->environmentMock = $this->getMockBuilder(EnvironmentInterface::class)->getMockForAbstractClass();
$this->commandAssembler = new CommandAssembler($this->tablesProviderMock);
}

public function testReturnsEmptyStringIfNoTables(): void
{
$this->tablesProviderMock->expects($this->any())->method('getAllTables')->willReturn([]);
$this->assertSame(
'',
$this->commandAssembler->execute($this->connectionMock, $this->environmentMock, 'dump.sql')
);
}

public function testReturnsEmptyStringIfAllTablesAreIgnored(): void
{
$this->tablesProviderMock->expects($this->any())->method('getAllTables')->willReturn(['a', 'b']);
$this->tablesProviderMock->expects($this->any())->method('getIgnoredTables')->willReturn(['a', 'b']);
$this->assertSame(
'',
$this->commandAssembler->execute($this->connectionMock, $this->environmentMock, 'dump.sql')
);
}

public function testReturnsCommandForNormalTables(): void
{
$this->tablesProviderMock->expects($this->any())->method('getAllTables')->willReturn(['a', 'b']);
$this->tablesProviderMock->expects($this->any())->method('getIgnoredTables')->willReturn([]);
$this->assertSame(
'mysqldump --user="user" --password="password" --single-transaction --host=host db a >> dump.sql;'
. 'mysqldump --user="user" --password="password" --single-transaction --host=host db b >> dump.sql;'
. "cat dump.sql | sed -E 's/DEFINER[ ]*=[ ]*`[^`]+`@`[^`]+`/DEFINER=CURRENT_USER/g' | gzip > dump.sql.gz",
$this->commandAssembler->execute($this->connectionMock, $this->environmentMock, 'dump.sql')
);
}

public function testReturnsCommandForEmptyTables(): void
{
$this->tablesProviderMock->expects($this->any())->method('getAllTables')->willReturn(['a', 'b']);
$this->tablesProviderMock->expects($this->any())->method('getIgnoredTables')->willReturn([]);
$this->tablesProviderMock->expects($this->any())->method('getEmptyTables')->willReturn(['a', 'b']);
$this->assertSame(
'mysqldump --user="user" --password="password" --single-transaction --host=host '
. 'db a b --no-data >> dump.sql;'
. "cat dump.sql | sed -E 's/DEFINER[ ]*=[ ]*`[^`]+`@`[^`]+`/DEFINER=CURRENT_USER/g' | gzip > dump.sql.gz",
$this->commandAssembler->execute($this->connectionMock, $this->environmentMock, 'dump.sql')
);
}

public function testReturnsCommandForMixedTables(): void
{
$this->tablesProviderMock->expects($this->any())->method('getAllTables')
->willReturn(['a', 'b', 'c', 'd', 'e', 'f']);
$this->tablesProviderMock->expects($this->any())->method('getIgnoredTables')->willReturn(['c', 'f']);
$this->tablesProviderMock->expects($this->any())->method('getEmptyTables')->willReturn(['b', 'e']);
$this->assertSame(
'mysqldump --user="user" --password="password" --single-transaction --host=host db a >> dump.sql;'
. 'mysqldump --user="user" --password="password" --single-transaction --host=host db d >> dump.sql;'
. 'mysqldump --user="user" --password="password" --single-transaction --host=host '
. 'db b e --no-data >> dump.sql;'
. "cat dump.sql | sed -E 's/DEFINER[ ]*=[ ]*`[^`]+`@`[^`]+`/DEFINER=CURRENT_USER/g' | gzip > dump.sql.gz",
$this->commandAssembler->execute($this->connectionMock, $this->environmentMock, 'dump.sql')
);
}
}

0 comments on commit 8da0872

Please sign in to comment.