From 4918ddcc8b93f479cebf85b83349ee51d945dbe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Rock=C3=A9tt?= Date: Tue, 27 Jun 2023 10:34:30 +0200 Subject: [PATCH] Add support for database URLs (#196) * add support for database urls * fix: use static InvalidDatabaseUrl constructor --- README.md | 19 ++++- src/DbDumper.php | 45 ++++++++++- src/DsnParser.php | 106 ++++++++++++++++++++++++++ src/Exceptions/InvalidDatabaseUrl.php | 13 ++++ tests/MongoDbTest.php | 11 +++ tests/MySqlTest.php | 10 +++ tests/PostgreSqlTest.php | 38 +++++---- tests/SqliteTest.php | 20 +++++ 8 files changed, 242 insertions(+), 20 deletions(-) create mode 100644 src/DsnParser.php create mode 100644 src/Exceptions/InvalidDatabaseUrl.php diff --git a/README.md b/README.md index 7a95381..01b714c 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,9 @@ [![MIT Licensed](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) [![Total Downloads](https://img.shields.io/packagist/dt/spatie/db-dumper.svg?style=flat-square)](https://packagist.org/packages/spatie/db-dumper) -This repo contains an easy to use class to dump a database using PHP. Currently MySQL, PostgreSQL, SQLite and MongoDB are supported. Behind -the scenes `mysqldump`, `pg_dump`, `sqlite3` and `mongodump` are used. +This repo contains an easy to use class to dump a database using PHP. Currently MySQL, PostgreSQL, SQLite and MongoDB are supported. Behind the scenes `mysqldump`, `pg_dump`, `sqlite3` and `mongodump` are used. -Here's are simple examples of how to create a database dump with different drivers: +Here are simple examples of how to create a database dump with different drivers: **MySQL** @@ -124,6 +123,20 @@ Spatie\DbDumper\Databases\MySql::create() ->dumpToFile('dump.sql'); ``` +### Use a Database URL + +In some applications or environments, database credentials are provided as URLs instead of individual components. In this case, you can use the `setDatabaseUrl` method instead of the individual methods. + +```php +Spatie\DbDumper\Databases\MySql::create() + ->setDatabaseUrl($databaseUrl) + ->dumpToFile('dump.sql'); +``` + +When providing a URL, the package will automatically parse it and provide the individual components to the applicable dumper. + +For example, if you provide the URL `mysql://username:password@hostname:3306/dbname`, the dumper will use the `hostname` host, running on port `3306`, and will connect to `dbname` with `username` and `password`. + ### Dump specific tables Using an array: diff --git a/src/DbDumper.php b/src/DbDumper.php index 726b2e9..b29214c 100644 --- a/src/DbDumper.php +++ b/src/DbDumper.php @@ -9,6 +9,8 @@ abstract class DbDumper { + protected string $databaseUrl = ''; + protected string $dbName = ''; protected string $userName = ''; @@ -52,6 +54,20 @@ public function setDbName(string $dbName): self return $this; } + public function getDatabaseUrl(): string + { + return $this->databaseUrl; + } + + public function setDatabaseUrl(string $databaseUrl): self + { + $this->databaseUrl = $databaseUrl; + + $this->configureFromDatabaseUrl(); + + return $this; + } + public function setUserName(string $userName): self { $this->userName = $userName; @@ -187,6 +203,31 @@ public function checkIfDumpWasSuccessFul(Process $process, string $outputFile): } } + protected function configureFromDatabaseUrl(): void + { + $parsed = (new DsnParser($this->databaseUrl))->parse(); + + $componentMap = [ + 'host' => 'setHost', + 'port' => 'setPort', + 'database' => 'setDbName', + 'username' => 'setUserName', + 'password' => 'setPassword', + ]; + + foreach ($parsed as $component => $value) { + if (isset($componentMap[$component])) { + $setterMethod = $componentMap[$component]; + + if (! $value || in_array($value, ['', 'null'])) { + continue; + } + + $this->$setterMethod($value); + } + } + } + protected function getCompressCommand(string $command, string $dumpFile): string { $compressCommand = $this->compressor->useCommand(); @@ -200,13 +241,13 @@ protected function getCompressCommand(string $command, string $dumpFile): string protected function echoToFile(string $command, string $dumpFile): string { - $dumpFile = '"'.addcslashes($dumpFile, '\\"').'"'; + $dumpFile = '"' . addcslashes($dumpFile, '\\"') . '"'; if ($this->compressor) { return $this->getCompressCommand($command, $dumpFile); } - return $command.' > '.$dumpFile; + return $command . ' > ' . $dumpFile; } protected function determineQuote(): string diff --git a/src/DsnParser.php b/src/DsnParser.php new file mode 100644 index 0000000..df4d99e --- /dev/null +++ b/src/DsnParser.php @@ -0,0 +1,106 @@ +dsn = $dsn; + } + + public function parse(): array + { + $rawComponents = $this->parseUrl($this->dsn); + + $decodedComponents = $this->parseNativeTypes( + array_map('rawurldecode', $rawComponents) + ); + + return array_merge( + $this->getPrimaryOptions($decodedComponents), + $this->getQueryOptions($rawComponents) + ); + } + + protected function getPrimaryOptions($url): array + { + return array_filter([ + 'database' => $this->getDatabase($url), + 'host' => $url['host'] ?? null, + 'port' => $url['port'] ?? null, + 'username' => $url['user'] ?? null, + 'password' => $url['pass'] ?? null, + ], static fn ($value) => ! is_null($value)); + } + + protected function getDatabase($url): ?string + { + $path = $url['path'] ?? null; + + if (! $path) { + return null; + } + + if ($path === '/') { + return null; + } + + if (isset($url['scheme']) && str_contains($url['scheme'], 'sqlite')) { + return $path; + } + + return trim($path, '/'); + } + + protected function getQueryOptions($url) + { + $queryString = $url['query'] ?? null; + + if (! $queryString) { + return []; + } + + $query = []; + + parse_str($queryString, $query); + + return $this->parseNativeTypes($query); + } + + protected function parseUrl($url): array + { + $url = preg_replace('#^(sqlite3?):///#', '$1://null/', $url); + + $parsedUrl = parse_url($url); + + if ($parsedUrl === false) { + throw InvalidDatabaseUrl::invalidUrl($url); + } + + return $parsedUrl; + } + + protected function parseNativeTypes($value) + { + if (is_array($value)) { + return array_map([$this, 'parseNativeTypes'], $value); + } + + if (! is_string($value)) { + return $value; + } + + $parsedValue = json_decode($value, true); + + if (json_last_error() === JSON_ERROR_NONE) { + return $parsedValue; + } + + return $value; + } +} diff --git a/src/Exceptions/InvalidDatabaseUrl.php b/src/Exceptions/InvalidDatabaseUrl.php new file mode 100644 index 0000000..5dfc1f7 --- /dev/null +++ b/src/Exceptions/InvalidDatabaseUrl.php @@ -0,0 +1,13 @@ + "dbname.gz"'); }); +it('can generate a dump command using a database url', function () { + $dumpCommand = MongoDb::create() + ->setDatabaseUrl('monogodb://username:password@localhost:27017/dbname') + ->getDumpCommand('dbname.gz'); + + expect($dumpCommand)->toEqual( + '\'mongodump\' --db dbname' + . ' --archive --username \'username\' --password \'password\' --host localhost --port 27017 > "dbname.gz"' + ); +}); + it('can generate a dump command with gzip compressor enabled', function () { $dumpCommand = MongoDb::create() ->setDbName('dbname') diff --git a/tests/MySqlTest.php b/tests/MySqlTest.php index 09bcd9e..05422e3 100644 --- a/tests/MySqlTest.php +++ b/tests/MySqlTest.php @@ -26,6 +26,16 @@ ); }); +it('can generate a dump command using a database url', function () { + $dumpCommand = Mysql::create() + ->setDatabaseUrl('mysql://username:password@hostname:3306/dbname') + ->getDumpCommand('dump.sql', 'credentials.txt'); + + expect($dumpCommand)->toEqual( + '\'mysqldump\' --defaults-extra-file="credentials.txt" --skip-comments --extended-insert dbname > "dump.sql"' + ); +}); + it('can generate a dump command with columnstatistics', function () { $dumpCommand = MySql::create() ->setDbName('dbname') diff --git a/tests/PostgreSqlTest.php b/tests/PostgreSqlTest.php index 326ec42..e755b2e 100644 --- a/tests/PostgreSqlTest.php +++ b/tests/PostgreSqlTest.php @@ -21,7 +21,15 @@ ->setPassword('password') ->getDumpCommand('dump.sql'); - expect($dumpCommand)->toEqual('\'pg_dump\' -U username -h localhost -p 5432 > "dump.sql"'); + expect($dumpCommand)->toEqual('\'pg_dump\' -U "username" -h localhost -p 5432 > "dump.sql"'); +}); + +it('can generate a dump command using a database url', function () { + $dumpCommand = Postgresql::create() + ->setDatabaseUrl('postgres://username:password@hostname:5432/dbname') + ->getDumpCommand('dump.sql'); + + expect($dumpCommand)->toEqual('\'pg_dump\' -U "username" -h hostname -p 5432 > "dump.sql"'); }); it('can generate a dump command with gzip compressor enabled', function () { @@ -33,7 +41,7 @@ ->getDumpCommand('dump.sql'); expect($dumpCommand)->toEqual( - '((((\'pg_dump\' -U username -h localhost -p 5432; echo $? >&3) | gzip > "dump.sql") 3>&1) | (read x; exit $x))' + '((((\'pg_dump\' -U "username" -h localhost -p 5432; echo $? >&3) | gzip > "dump.sql") 3>&1) | (read x; exit $x))' ); }); @@ -45,7 +53,7 @@ ->useCompressor(new Bzip2Compressor()) ->getDumpCommand('dump.sql'); - $expected = '((((\'pg_dump\' -U username -h localhost -p 5432; echo $? >&3) | bzip2 > "dump.sql") 3>&1) | (read x; exit $x))'; + $expected = '((((\'pg_dump\' -U "username" -h localhost -p 5432; echo $? >&3) | bzip2 > "dump.sql") 3>&1) | (read x; exit $x))'; expect($dumpCommand)->toEqual($expected); }); @@ -58,7 +66,7 @@ ->getDumpCommand('/save/to/new (directory)/dump.sql'); expect($dumpCommand)->toEqual( - '\'pg_dump\' -U username -h localhost -p 5432 > "/save/to/new (directory)/dump.sql"' + '\'pg_dump\' -U "username" -h localhost -p 5432 > "/save/to/new (directory)/dump.sql"' ); }); @@ -71,7 +79,7 @@ ->getDumpCommand('dump.sql'); expect($dumpCommand)->toEqual( - '\'pg_dump\' -U username -h localhost -p 5432 --inserts > "dump.sql"' + '\'pg_dump\' -U "username" -h localhost -p 5432 --inserts > "dump.sql"' ); }); @@ -83,7 +91,7 @@ ->setPort(1234) ->getDumpCommand('dump.sql'); - expect($dumpCommand)->toEqual('\'pg_dump\' -U username -h localhost -p 1234 > "dump.sql"'); + expect($dumpCommand)->toEqual('\'pg_dump\' -U "username" -h localhost -p 1234 > "dump.sql"'); }); it('can generate a dump command with custom binary path', function () { @@ -94,7 +102,7 @@ ->setDumpBinaryPath('/custom/directory') ->getDumpCommand('dump.sql'); - expect($dumpCommand)->toEqual('\'/custom/directory/pg_dump\' -U username -h localhost -p 5432 > "dump.sql"'); + expect($dumpCommand)->toEqual('\'/custom/directory/pg_dump\' -U "username" -h localhost -p 5432 > "dump.sql"'); }); it('can generate a dump command with a custom socket', function () { @@ -105,7 +113,7 @@ ->setSocket('/var/socket.1234') ->getDumpCommand('dump.sql'); - expect($dumpCommand)->toEqual('\'pg_dump\' -U username -h /var/socket.1234 -p 5432 > "dump.sql"'); + expect($dumpCommand)->toEqual('\'pg_dump\' -U "username" -h /var/socket.1234 -p 5432 > "dump.sql"'); }); it('can generate a dump command for specific tables as array', function () { @@ -116,7 +124,7 @@ ->includeTables(['tb1', 'tb2', 'tb3']) ->getDumpCommand('dump.sql', 'credentials.txt'); - expect($dumpCommand)->toEqual('\'pg_dump\' -U username -h localhost -p 5432 -t tb1 -t tb2 -t tb3 > "dump.sql"'); + expect($dumpCommand)->toEqual('\'pg_dump\' -U "username" -h localhost -p 5432 -t tb1 -t tb2 -t tb3 > "dump.sql"'); }); it('can generate a dump command for specific tables as string', function () { @@ -127,7 +135,7 @@ ->includeTables('tb1, tb2, tb3') ->getDumpCommand('dump.sql', 'credentials.txt'); - expect($dumpCommand)->toEqual('\'pg_dump\' -U username -h localhost -p 5432 -t tb1 -t tb2 -t tb3 > "dump.sql"'); + expect($dumpCommand)->toEqual('\'pg_dump\' -U "username" -h localhost -p 5432 -t tb1 -t tb2 -t tb3 > "dump.sql"'); }); it('will throw an exception when setting exclude tables after setting tables', function () { @@ -148,7 +156,7 @@ ->getDumpCommand('dump.sql', 'credentials.txt'); expect($dumpCommand)->toEqual( - '\'pg_dump\' -U username -h localhost -p 5432 -T tb1 -T tb2 -T tb3 > "dump.sql"' + '\'pg_dump\' -U "username" -h localhost -p 5432 -T tb1 -T tb2 -T tb3 > "dump.sql"' ); }); @@ -161,7 +169,7 @@ ->getDumpCommand('dump.sql', 'credentials.txt'); expect($dumpCommand)->toEqual( - '\'pg_dump\' -U username -h localhost -p 5432 -T tb1 -T tb2 -T tb3 > "dump.sql"' + '\'pg_dump\' -U "username" -h localhost -p 5432 -T tb1 -T tb2 -T tb3 > "dump.sql"' ); }); @@ -203,7 +211,7 @@ ->getDumpCommand('dump.sql'); expect($dumpCommand)->toEqual( - '\'pg_dump\' -U username -h localhost -p 5432 -something-else > "dump.sql"' + '\'pg_dump\' -U "username" -h localhost -p 5432 -something-else > "dump.sql"' ); }); @@ -219,7 +227,7 @@ ->setUserName('username') ->setPassword('password') ->doNotCreateTables() - ->getDumpCommand('dump.sql', 'credentials.txt'); + ->getDumpCommand('dump.sql'); - expect($dumpCommand)->toEqual('\'pg_dump\' -U username -h localhost -p 5432 --data-only > "dump.sql"'); + expect($dumpCommand)->toEqual('\'pg_dump\' -U "username" -h localhost -p 5432 --data-only > "dump.sql"'); }); diff --git a/tests/SqliteTest.php b/tests/SqliteTest.php index c7a5877..7710601 100644 --- a/tests/SqliteTest.php +++ b/tests/SqliteTest.php @@ -22,6 +22,26 @@ expect($dumpCommand)->toEqual($expected); }); +it('can generate a dump command using a database url containing an absolute path', function () { + $dumpCommand = Sqlite::create() + ->setDatabaseUrl('sqlite:///path/to/dbname.sqlite') + ->getDumpCommand('dump.sql'); + + expect($dumpCommand)->toEqual( + "echo 'BEGIN IMMEDIATE;\n.dump' | 'sqlite3' --bail '/path/to/dbname.sqlite' > \"dump.sql\"" + ); +}); + +it('can generate a dump command using a database url containing a relative path', function () { + $dumpCommand = Sqlite::create() + ->setDatabaseUrl('sqlite:dbname.sqlite') + ->getDumpCommand('dump.sql'); + + expect($dumpCommand)->toEqual( + "echo 'BEGIN IMMEDIATE;\n.dump' | 'sqlite3' --bail 'dbname.sqlite' > \"dump.sql\"" + ); +}); + it('can generate a dump command with gzip compressor enabled', function () { $dumpCommand = Sqlite::create() ->setDbName('dbname.sqlite')