Skip to content
30 changes: 26 additions & 4 deletions src/Illuminate/Database/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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()
);
}
}
Expand All @@ -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');
}
}

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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.
*
Expand Down
11 changes: 10 additions & 1 deletion src/Illuminate/Database/Events/QueryExecuted.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,21 +39,30 @@ class QueryExecuted
*/
public $connectionName;

/**
* The PDO read / write type for the executed query.
*
* @var null|'read'|'write'
*/
public $readWriteType;

/**
* Create a new event instance.
*
* @param string $sql
* @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)
Comment on lines -50 to +58
Copy link
Member Author

@timacdonald timacdonald Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Constructor remains backwards compatible.

{
$this->sql = $sql;
$this->time = $time;
$this->bindings = $bindings;
$this->connection = $connection;
$this->connectionName = $connection->getName();
$this->readWriteType = $readWriteType;
}

/**
Expand Down
11 changes: 10 additions & 1 deletion src/Illuminate/Database/QueryException.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,21 +30,30 @@ 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.
*
* @param string $connectionName
* @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);

Expand Down
194 changes: 194 additions & 0 deletions tests/Integration/Database/DatabaseConnectionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}
}
Loading