diff --git a/src/Illuminate/Database/Console/DbCommand.php b/src/Illuminate/Database/Console/DbCommand.php index 30176073558d..2161d18a40a8 100644 --- a/src/Illuminate/Database/Console/DbCommand.php +++ b/src/Illuminate/Database/Console/DbCommand.php @@ -4,8 +4,11 @@ use Illuminate\Console\Command; use Illuminate\Support\ConfigurationUrlParser; +use Illuminate\Support\Uri; +use PDO; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Process\Exception\ProcessFailedException; +use Symfony\Component\Process\ExecutableFinder; use Symfony\Component\Process\Process; use UnexpectedValueException; @@ -19,7 +22,8 @@ class DbCommand extends Command */ protected $signature = 'db {connection? : The database connection that should be used} {--read : Connect to the read connection} - {--write : Connect to the write connection}'; + {--write : Connect to the write connection} + {--open : Open the connection URL in a GUI client}'; /** * The console command description. @@ -45,6 +49,10 @@ public function handle() return Command::FAILURE; } + if ($this->option('open')) { + return $this->openDatabaseUrl($connection); + } + try { (new Process( array_merge([$command = $this->getCommand($connection)], $this->commandArguments($connection)), @@ -254,4 +262,125 @@ protected function getOptionalArguments(array $args, array $connection) return ! empty($connection[$key]); }, ARRAY_FILTER_USE_KEY)); } + + /** + * Open the database connection URL in a GUI client. + * + * @param array $connection + * @return int + */ + protected function openDatabaseUrl(array $connection) + { + $this->open($this->buildDatabaseUrl($connection)); + + return Command::SUCCESS; + } + + /** + * Build the database connection URL. + * + * @param array $connection + * @return string + */ + protected function buildDatabaseUrl(array $connection) + { + $driver = $this->getDriverScheme($connection['driver']); + + if ($connection['driver'] === 'sqlite') { + return $connection['database']; + } + + return (new Uri) + ->withScheme($driver) + ->withHost($connection['host']) + ->withUser( + $connection['username'] ?? null, + ! empty($connection['password']) ? $connection['password'] : null, + ) + ->when(! empty($connection['port']), fn (Uri $uri) => $uri->withPort((int) $connection['port'])) + ->when(! empty($connection['database']), fn (Uri $uri) => $uri->withPath($connection['database'])) + ->when(! empty($this->getQueryParameters($connection)), fn (Uri $uri) => $uri->withQuery($this->getQueryParameters($connection))) + ->value(); + } + + /** + * Get the URL scheme for the database driver. + * + * @param string $driver + * @return string + */ + protected function getDriverScheme($driver) + { + return match ($driver) { + 'mysql', 'mariadb' => 'mysql', + 'pgsql' => 'postgresql', + 'sqlite' => 'sqlite', + 'sqlsrv' => 'sqlserver', + default => $driver, + }; + } + + /** + * Get additional query parameters for the URL. + * + * @param array $connection + * @return array + */ + protected function getQueryParameters(array $connection) + { + $params = []; + + // Add SSL/TLS parameters if configured + if (! empty($connection['sslmode'])) { + $params['sslmode'] = $connection['sslmode']; + } + + if (! empty($connection['options'])) { + // For PostgreSQL SSL mode + if (isset($connection['options'][PDO::MYSQL_ATTR_SSL_CA])) { + $params['ssl'] = 'true'; + } + } + + return $params; + } + + /** + * Open the database URL. + * + * @param string $url + * @return void + */ + protected function open($url) + { + if (PHP_OS_FAMILY === 'Windows') { + $process = Process::fromShellCommandline(escapeshellcmd("start {$url}")); + $process->run(); + + if (! $process->isSuccessful()) { + throw new ProcessFailedException($process); + } + + return; + } + + $binary = collect(match (PHP_OS_FAMILY) { + 'Darwin' => ['open'], + 'Linux' => ['xdg-open', 'wslview'], + })->first(fn ($binary) => (new ExecutableFinder)->find($binary) !== null); + + if ($binary === null) { + $this->components->warn('Unable to open the URL on your system. You will need to open it yourself.'); + $this->components->info("Database URL: {$url}"); + + return; + } + + $process = Process::fromShellCommandline(escapeshellcmd("{$binary} {$url}")); + $process->run(); + + if (! $process->isSuccessful()) { + throw new ProcessFailedException($process); + } + } } diff --git a/src/Illuminate/Database/Console/DbOpenCommand.php b/src/Illuminate/Database/Console/DbOpenCommand.php new file mode 100644 index 000000000000..0b6084e94602 --- /dev/null +++ b/src/Illuminate/Database/Console/DbOpenCommand.php @@ -0,0 +1,44 @@ + $this->argument('connection'), + '--read' => $this->option('read'), + '--write' => $this->option('write'), + '--open' => true, + ]); + + return $this->call('db', $arguments); + } +} diff --git a/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php b/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php index 0ac742bb9935..0249c020deaa 100755 --- a/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php +++ b/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php @@ -18,6 +18,7 @@ use Illuminate\Console\Signals; use Illuminate\Contracts\Support\DeferrableProvider; use Illuminate\Database\Console\DbCommand; +use Illuminate\Database\Console\DbOpenCommand; use Illuminate\Database\Console\DumpCommand; use Illuminate\Database\Console\Factories\FactoryMakeCommand; use Illuminate\Database\Console\MonitorCommand as DatabaseMonitorCommand; @@ -129,6 +130,7 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid 'ConfigClear' => ConfigClearCommand::class, 'ConfigShow' => ConfigShowCommand::class, 'Db' => DbCommand::class, + 'DbOpen' => DbOpenCommand::class, 'DbMonitor' => DatabaseMonitorCommand::class, 'DbPrune' => PruneCommand::class, 'DbShow' => ShowCommand::class, diff --git a/tests/Database/DbCommandTest.php b/tests/Database/DbCommandTest.php new file mode 100644 index 000000000000..62d34363d027 --- /dev/null +++ b/tests/Database/DbCommandTest.php @@ -0,0 +1,201 @@ + 'mysql', + 'host' => 'localhost', + 'port' => 3306, + 'database' => 'forge', + 'username' => 'root', + 'password' => 'secret', + ]; + + $url = $command->buildDatabaseUrl($connection); + + $this->assertSame('mysql://root:secret@localhost:3306/forge', $url); + } + + public function testBuildDatabaseUrlForPostgresql() + { + $command = new DbCommandTestStub(); + + $connection = [ + 'driver' => 'pgsql', + 'host' => 'ep-falling-heart-a5luqcqc.aws-us-east-2.pg.laravel.cloud', + 'port' => 5432, + 'database' => 'main', + 'username' => 'laravel', + 'password' => 'npg_G7UguAkROK4l', + ]; + + $url = $command->buildDatabaseUrl($connection); + + $this->assertSame( + 'postgresql://laravel:npg_G7UguAkROK4l@ep-falling-heart-a5luqcqc.aws-us-east-2.pg.laravel.cloud:5432/main', + $url + ); + } + + public function testBuildDatabaseUrlForSqlite() + { + $command = new DbCommandTestStub(); + + $connection = [ + 'driver' => 'sqlite', + 'database' => '/path/to/database.sqlite', + ]; + + $url = $command->buildDatabaseUrl($connection); + + $this->assertSame('/path/to/database.sqlite', $url); + } + + public function testBuildDatabaseUrlForSqlServer() + { + $command = new DbCommandTestStub(); + + $connection = [ + 'driver' => 'sqlsrv', + 'host' => 'localhost', + 'port' => 1433, + 'database' => 'master', + 'username' => 'sa', + 'password' => 'Password123!', + ]; + + $url = $command->buildDatabaseUrl($connection); + + $this->assertSame('sqlserver://sa:Password123!@localhost:1433/master', $url); + } + + public function testBuildDatabaseUrlWithSpecialCharactersInPassword() + { + $command = new DbCommandTestStub(); + + $connection = [ + 'driver' => 'mysql', + 'host' => 'localhost', + 'port' => 3306, + 'database' => 'forge', + 'username' => 'root', + 'password' => 'p@ss:word!#$', + ]; + + $url = $command->buildDatabaseUrl($connection); + + $this->assertSame('mysql://root:p%40ss:word!%23$@localhost:3306/forge', $url); + } + + public function testBuildDatabaseUrlWithoutPassword() + { + $command = new DbCommandTestStub(); + + $connection = [ + 'driver' => 'mysql', + 'host' => 'localhost', + 'port' => 3306, + 'database' => 'forge', + 'username' => 'root', + 'password' => '', + ]; + + $url = $command->buildDatabaseUrl($connection); + + $this->assertSame('mysql://root@localhost:3306/forge', $url); + } + + public function testBuildDatabaseUrlWithoutPort() + { + $command = new DbCommandTestStub(); + + $connection = [ + 'driver' => 'mysql', + 'host' => 'localhost', + 'database' => 'forge', + 'username' => 'root', + 'password' => 'secret', + ]; + + $url = $command->buildDatabaseUrl($connection); + + $this->assertSame('mysql://root:secret@localhost/forge', $url); + } + + public function testBuildDatabaseUrlWithSslMode() + { + $command = new DbCommandTestStub(); + + $connection = [ + 'driver' => 'pgsql', + 'host' => 'localhost', + 'port' => 5432, + 'database' => 'forge', + 'username' => 'postgres', + 'password' => 'secret', + 'sslmode' => 'require', + ]; + + $url = $command->buildDatabaseUrl($connection); + + $this->assertSame('postgresql://postgres:secret@localhost:5432/forge?sslmode=require', $url); + } + + public function testGetDriverScheme() + { + $command = new DbCommandTestStub(); + + $this->assertSame('mysql', $command->getDriverScheme('mysql')); + $this->assertSame('mysql', $command->getDriverScheme('mariadb')); + $this->assertSame('postgresql', $command->getDriverScheme('pgsql')); + $this->assertSame('sqlite', $command->getDriverScheme('sqlite')); + $this->assertSame('sqlserver', $command->getDriverScheme('sqlsrv')); + } + + public function testBuildDatabaseUrlForMariaDb() + { + $command = new DbCommandTestStub(); + + $connection = [ + 'driver' => 'mariadb', + 'host' => 'localhost', + 'port' => 3306, + 'database' => 'myapp', + 'username' => 'root', + 'password' => 'password', + ]; + + $url = $command->buildDatabaseUrl($connection); + + // MariaDB uses the same mysql:// scheme + $this->assertSame('mysql://root:password@localhost:3306/myapp', $url); + } +} + +class DbCommandTestStub extends DbCommand +{ + public function buildDatabaseUrl(array $connection) + { + return parent::buildDatabaseUrl($connection); + } + + public function getDriverScheme($driver) + { + return parent::getDriverScheme($driver); + } +}