diff --git a/src/Illuminate/Database/Connection.php b/src/Illuminate/Database/Connection.php index 24465134f377..5f9ab8df3d43 100755 --- a/src/Illuminate/Database/Connection.php +++ b/src/Illuminate/Database/Connection.php @@ -203,6 +203,13 @@ class Connection implements ConnectionInterface */ protected static $resolvers = []; + /** + * The last retrieved PDO read / write type. + * + * @var null|'read'|'write' + */ + protected $latestPdoTypeRetrieved = null; + /** * Create a new database connection instance. * @@ -819,12 +826,12 @@ protected function runQueryCallback($query, $bindings, Closure $callback) catch (Exception $e) { if ($this->isUniqueConstraintError($e)) { throw new UniqueConstraintViolationException( - $this->getName(), $query, $this->prepareBindings($bindings), $e + $this->getName(), $query, $this->prepareBindings($bindings), $e, $this->latestReadWriteTypeUsed() ); } throw new QueryException( - $this->getName(), $query, $this->prepareBindings($bindings), $e + $this->getName(), $query, $this->prepareBindings($bindings), $e, $this->latestReadWriteTypeUsed() ); } } @@ -851,15 +858,16 @@ protected function isUniqueConstraintError(Exception $exception) public function logQuery($query, $bindings, $time = null) { $this->totalQueryDuration += $time ?? 0.0; + $readWriteType = $this->latestReadWriteTypeUsed(); - $this->event(new QueryExecuted($query, $bindings, $time, $this)); + $this->event(new QueryExecuted($query, $bindings, $time, $this, $readWriteType)); $query = $this->pretending === true ? $this->queryGrammar?->substituteBindingsIntoRawSql($query, $bindings) ?? $query : $query; if ($this->loggingQueries) { - $this->queryLog[] = compact('query', 'bindings', 'time'); + $this->queryLog[] = compact('query', 'bindings', 'time', 'readWriteType'); } } @@ -1232,6 +1240,8 @@ public function useWriteConnectionWhenReading($value = true) */ public function getPdo() { + $this->latestPdoTypeRetrieved = 'write'; + if ($this->pdo instanceof Closure) { return $this->pdo = call_user_func($this->pdo); } @@ -1265,6 +1275,8 @@ public function getReadPdo() return $this->getPdo(); } + $this->latestPdoTypeRetrieved = 'read'; + if ($this->readPdo instanceof Closure) { return $this->readPdo = call_user_func($this->readPdo); } @@ -1621,6 +1633,16 @@ public function setReadWriteType($readWriteType) return $this; } + /** + * Retrieve the latest read / write type used. + * + * @return 'read'|'write'|null + */ + protected function latestReadWriteTypeUsed() + { + return $this->readWriteType ?? $this->latestPdoTypeRetrieved; + } + /** * Get the table prefix for the connection. * diff --git a/src/Illuminate/Database/Events/QueryExecuted.php b/src/Illuminate/Database/Events/QueryExecuted.php index 960df9da0954..d9209dfd591a 100644 --- a/src/Illuminate/Database/Events/QueryExecuted.php +++ b/src/Illuminate/Database/Events/QueryExecuted.php @@ -39,6 +39,13 @@ class QueryExecuted */ public $connectionName; + /** + * The PDO read / write type for the executed query. + * + * @var null|'read'|'write' + */ + public $readWriteType; + /** * Create a new event instance. * @@ -46,14 +53,16 @@ class QueryExecuted * @param array $bindings * @param float|null $time * @param \Illuminate\Database\Connection $connection + * @param null|'read'|'write' $readWriteType */ - public function __construct($sql, $bindings, $time, $connection) + public function __construct($sql, $bindings, $time, $connection, $readWriteType = null) { $this->sql = $sql; $this->time = $time; $this->bindings = $bindings; $this->connection = $connection; $this->connectionName = $connection->getName(); + $this->readWriteType = $readWriteType; } /** diff --git a/src/Illuminate/Database/QueryException.php b/src/Illuminate/Database/QueryException.php index a83657188538..9896b6ec6f2b 100644 --- a/src/Illuminate/Database/QueryException.php +++ b/src/Illuminate/Database/QueryException.php @@ -30,6 +30,13 @@ class QueryException extends PDOException */ protected $bindings; + /** + * The PDO read / write type for the executed query. + * + * @var null|'read'|'write' + */ + public $readWriteType; + /** * Create a new query exception instance. * @@ -37,14 +44,16 @@ class QueryException extends PDOException * @param string $sql * @param array $bindings * @param \Throwable $previous + * @param null|'read'|'write' $readWriteType */ - public function __construct($connectionName, $sql, array $bindings, Throwable $previous) + public function __construct($connectionName, $sql, array $bindings, Throwable $previous, $readWriteType = null) { parent::__construct('', 0, $previous); $this->connectionName = $connectionName; $this->sql = $sql; $this->bindings = $bindings; + $this->readWriteType = $readWriteType; $this->code = $previous->getCode(); $this->message = $this->formatMessage($connectionName, $sql, $bindings, $previous); diff --git a/tests/Integration/Database/DatabaseConnectionsTest.php b/tests/Integration/Database/DatabaseConnectionsTest.php index bc8d647ea8c8..13d55c4f037b 100644 --- a/tests/Integration/Database/DatabaseConnectionsTest.php +++ b/tests/Integration/Database/DatabaseConnectionsTest.php @@ -6,10 +6,14 @@ use Illuminate\Database\DatabaseManager; use Illuminate\Database\Events\ConnectionEstablished; +use Illuminate\Database\QueryException; use Illuminate\Database\SQLiteConnection; use Illuminate\Events\Dispatcher; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\DB; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; class DatabaseConnectionsTest extends DatabaseTestCase @@ -172,4 +176,194 @@ public function testDynamicConnectionWithNoNameDoesntFailOnReconnect() } } } + + #[DataProvider('readWriteExpectations')] + public function testReadWriteTypeIsProvidedInQueryExecutedEventAndQueryLog(string $connectionName, array $expectedTypes, ?string $loggedType) + { + $readPath = __DIR__.'/read.sqlite'; + $writePath = __DIR__.'/write.sqlite'; + Config::set('database.connections.sqlite', [ + 'driver' => 'sqlite', + 'read' => [ + 'database' => $readPath, + ], + 'write' => [ + 'database' => $writePath, + ], + ]); + $events = collect(); + DB::listen($events->push(...)); + + try { + touch($readPath); + touch($writePath); + + $connection = DB::connection($connectionName); + $connection->enableQueryLog(); + + $connection->statement('select 1'); + $this->assertSame(array_shift($expectedTypes), $events->shift()->readWriteType); + + $connection->select('select 1'); + $this->assertSame(array_shift($expectedTypes), $events->shift()->readWriteType); + + $connection->statement('select 1'); + $this->assertSame(array_shift($expectedTypes), $events->shift()->readWriteType); + + $connection->select('select 1'); + $this->assertSame(array_shift($expectedTypes), $events->shift()->readWriteType); + + $this->assertEmpty($events); + $this->assertSame([ + ['query' => 'select 1', 'readWriteType' => $loggedType ?? 'write'], + ['query' => 'select 1', 'readWriteType' => $loggedType ?? 'read'], + ['query' => 'select 1', 'readWriteType' => $loggedType ?? 'write'], + ['query' => 'select 1', 'readWriteType' => $loggedType ?? 'read'], + ], Arr::select($connection->getQueryLog(), [ + 'query', 'readWriteType', + ])); + } finally { + @unlink($readPath); + @unlink($writePath); + } + } + + public static function readWriteExpectations(): iterable + { + yield 'sqlite' => ['sqlite', ['write', 'read', 'write', 'read'], null]; + yield 'sqlite::read' => ['sqlite::read', ['read', 'read', 'read', 'read'], 'read']; + yield 'sqlite::write' => ['sqlite::write', ['write', 'write', 'write', 'write'], 'write']; + } + + public function testConnectionsWithoutReadWriteConfigurationAlwaysShowAsWrite() + { + $writePath = __DIR__.'/write.sqlite'; + Config::set('database.connections.sqlite', [ + 'driver' => 'sqlite', + 'database' => $writePath, + ]); + $events = collect(); + DB::listen($events->push(...)); + + try { + touch($writePath); + + $connection = DB::connection('sqlite'); + + $connection->statement('select 1'); + $this->assertSame('write', $events->shift()->readWriteType); + + $connection->select('select 1'); + $this->assertSame('write', $events->shift()->readWriteType); + + $connection->statement('select 1'); + $this->assertSame('write', $events->shift()->readWriteType); + + $connection->select('select 1'); + $this->assertSame('write', $events->shift()->readWriteType); + } finally { + @unlink($writePath); + } + } + + public function testQueryExceptionsProviderReadWriteType() + { + $readPath = __DIR__.'/read.sqlite'; + $writePath = __DIR__.'/write.sqlite'; + Config::set('database.connections.sqlite', [ + 'driver' => 'sqlite', + 'read' => [ + 'database' => $readPath, + ], + 'write' => [ + 'database' => $writePath, + ], + ]); + + try { + touch($readPath); + touch($writePath); + + try { + DB::connection('sqlite::write')->statement('xxxx'); + $this->fail(); + } catch (QueryException $exception) { + $this->assertSame('write', $exception->readWriteType); + } + + try { + DB::connection('sqlite::read')->statement('xxxx'); + $this->fail(); + } catch (QueryException $exception) { + $this->assertSame('read', $exception->readWriteType); + } + } finally { + @unlink($writePath); + @unlink($readPath); + } + } + + #[DataProvider('readWriteExpectations')] + public function testQueryInEventListenerCannotInterfereWithReadWriteType(string $connectionName, array $expectedTypes, ?string $loggedType) + { + $readPath = __DIR__.'/read.sqlite'; + $writePath = __DIR__.'/write.sqlite'; + Config::set('database.connections.sqlite', [ + 'driver' => 'sqlite', + 'read' => [ + 'database' => $readPath, + ], + 'write' => [ + 'database' => $writePath, + ], + ]); + $events = collect(); + DB::listen($events->push(...)); + + try { + touch($readPath); + touch($writePath); + + $connection = DB::connection($connectionName); + $connection->enableQueryLog(); + + $connection->listen(function ($query) use ($connection) { + if ($query->sql === 'select 1') { + $connection->select('select 2'); + } + }); + + $connection->statement('select 1'); + $this->assertSame(array_shift($expectedTypes), $events->shift()->readWriteType); + $this->assertSame($loggedType ?? 'read', $events->shift()->readWriteType); + + $connection->select('select 1'); + $this->assertSame(array_shift($expectedTypes), $events->shift()->readWriteType); + $this->assertSame($loggedType ?? 'read', $events->shift()->readWriteType); + + $connection->statement('select 1'); + $this->assertSame(array_shift($expectedTypes), $events->shift()->readWriteType); + $this->assertSame($loggedType ?? 'read', $events->shift()->readWriteType); + + $connection->select('select 1'); + $this->assertSame(array_shift($expectedTypes), $events->shift()->readWriteType); + $this->assertSame($loggedType ?? 'read', $events->shift()->readWriteType); + + $this->assertSame([ + ['query' => 'select 2', 'readWriteType' => $loggedType ?? 'read'], + ['query' => 'select 1', 'readWriteType' => $loggedType ?? 'write'], + ['query' => 'select 2', 'readWriteType' => $loggedType ?? 'read'], + ['query' => 'select 1', 'readWriteType' => $loggedType ?? 'read'], + ['query' => 'select 2', 'readWriteType' => $loggedType ?? 'read'], + ['query' => 'select 1', 'readWriteType' => $loggedType ?? 'write'], + ['query' => 'select 2', 'readWriteType' => $loggedType ?? 'read'], + ['query' => 'select 1', 'readWriteType' => $loggedType ?? 'read'], + ], Arr::select($connection->getQueryLog(), [ + 'query', 'readWriteType', + ])); + } finally { + @unlink($readPath); + @unlink($writePath); + } + } }