Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
],
"require": {
"php": "^8.2",
"ext-pdo": "*",
"ext-zlib": "*",
"guzzlehttp/promises": "^2.0",
"laravel/framework": "^10.0|^11.0|^12.0",
Expand All @@ -35,7 +36,6 @@
},
"require-dev": {
"ext-pcntl": "*",
"ext-pdo": "*",
"aws/aws-sdk-php": "^3.349",
"guzzlehttp/guzzle": "^7.0",
"guzzlehttp/psr7": "^2.0",
Expand Down
4 changes: 4 additions & 0 deletions src/Records/Query.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@

final class Query
{
/**
* @param 'read'|'write'|'' $connectionType
*/
Comment on lines +7 to +9
Copy link
Member

@timacdonald timacdonald Oct 8, 2025

Choose a reason for hiding this comment

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

From a user API perspective in PHP land, I wonder if null would be a better value, rather than an empty string, to indicate that it is not configured.

Going further, I also wonder if this should be an enum (which rules out null)?

ConnectionType::Read;
ConnectionType::Write;
ConnectionType::NotConfigured; // dunno about this ??

public function __construct(
public string $sql,
public readonly string $file,
public readonly int $line,
public readonly int $duration,
public readonly string $connection,
public readonly string $connectionType,
) {
//
}
Expand Down
34 changes: 34 additions & 0 deletions src/Sensors/QuerySensor.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

namespace Laravel\Nightwatch\Sensors;

use Illuminate\Database\Connection;
use Illuminate\Database\Events\QueryExecuted;
use Laravel\Nightwatch\Clock;
use Laravel\Nightwatch\Location;
use Laravel\Nightwatch\Records\Query;
use Laravel\Nightwatch\State\CommandState;
use Laravel\Nightwatch\State\RequestState;
use Laravel\Nightwatch\Types\Str;
use PDO;

use function hash;
use function in_array;
Expand Down Expand Up @@ -46,6 +48,7 @@ public function __invoke(QueryExecuted $event, array $trace): array
line: $line ?? 0,
duration: $durationInMicroseconds,
connection: $event->connectionName ?? '', // @phpstan-ignore nullCoalesce.property
connectionType: $this->connectionType($event) ?? '',
),
function () use ($event, $record) {
$this->executionState->queries++;
Expand All @@ -68,6 +71,7 @@ function () use ($event, $record) {
'line' => $record->line,
'duration' => $record->duration,
'connection' => Str::tinyText($record->connection),
'connection_type' => $record->connectionType,
];
},
];
Expand All @@ -87,4 +91,34 @@ private function hash(QueryExecuted $event, Query $record): string

return hash('xxh128', "{$record->connection},{$sql}");
}

/**
* Get the read or write connection type if configured.
*
* @return 'read'|'write'|null
*/
private function connectionType(QueryExecuted $event): ?string
{
$connection = $event->connection;
$readPdo = $connection->getRawReadPdo();
$writePdo = $connection->getRawPdo();

if ($readPdo === null) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

$readPdo is null when a read connection is not configured.

return null;
}

if (! $readPdo instanceof PDO) {
Copy link
Contributor Author

@avosalmon avosalmon Oct 8, 2025

Choose a reason for hiding this comment

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

When $readPdo is not null and not an instance of PDO, that means that the read connection has not been established. At this point, $readPdo is a closure to resolve a PDO.

return 'write';
}

if (! $writePdo instanceof PDO) {
return 'read';
}

if ($connection->getReadPdo() === $writePdo) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

$connection->getReadPdo() returns a write PDO when a sticky connection is active or it's forced to use the write connection.

https://github.com/laravel/framework/blob/da58855c8069dbdd8f8f7c9e3c332360f9a231f8/src/Illuminate/Database/Connection.php#L1246-L1267

return 'write';
}

return 'read';
}
}
200 changes: 200 additions & 0 deletions tests/Feature/Sensors/QuerySensorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@
use SingleStore\Laravel\Connect\SingleStoreConnection;
use Tests\TestCase;

use function array_merge;
use function base64_encode;
use function class_exists;
use function dirname;
use function fake;
use function hash;
use function hex2bin;
use function in_array;
Expand Down Expand Up @@ -93,6 +95,7 @@ public function test_it_can_ingest_queries(): void
'line' => $line,
'duration' => 4321,
'connection' => $connection,
'connection_type' => '',
],
]);
}
Expand Down Expand Up @@ -352,4 +355,201 @@ public function test_it_can_capture_null_connection_name()
$ingest->assertWrittenTimes(1);
$ingest->assertLatestWrite('query:0.connection', '');
}

public function test_it_captures_connection_type_as_empty_string_when_read_and_write_connections_are_not_configured()
{
$ingest = $this->fakeIngest();

Route::get('/users', function () {
return DB::table('users')->get();
});

$response = $this->get('/users');

$response->assertOk();
$ingest->assertWrittenTimes(1);
$ingest->assertLatestWrite('query:0.connection_type', '');
}

public function test_it_captures_connection_type_as_read_for_select_query()
{
$this->configureReadWriteConnection();

$ingest = $this->fakeIngest();

Route::get('/users', function () {
return DB::table('users')->get();
});

$response = $this->get('/users');

$response->assertOk();
$ingest->assertWrittenTimes(1);
$ingest->assertLatestWrite('query:0.connection_type', 'read');
}

public function test_it_captures_connection_type_as_write_for_write_query()
{
$this->configureReadWriteConnection();

$ingest = $this->fakeIngest();

Route::get('/users', function () {
return DB::table('users')->insert([
'name' => fake()->name(),
'email' => fake()->email(),
'password' => fake()->password(),
]);
});

$response = $this->get('/users');

$response->assertOk();
$ingest->assertWrittenTimes(1);
$ingest->assertLatestWrite('query:0.connection_type', 'write');
}

public function test_it_captures_connection_type_as_write_when_records_have_been_modified_and_sticky_connection_is_enabled()
{
$this->configureReadWriteConnection(['sticky' => true]);

$ingest = $this->fakeIngest();

Route::get('/users', function () {
DB::table('users')->insert([
'name' => fake()->name(),
'email' => fake()->email(),
'password' => fake()->password(),
]);

return DB::table('users')->get();
});

$response = $this->get('/users');

$response->assertOk();
$ingest->assertWrittenTimes(1);
$ingest->assertLatestWrite('query:0.connection_type', 'write'); // insert
$ingest->assertLatestWrite('query:1.connection_type', 'write'); // select
}

public function test_it_captures_connection_type_as_write_for_insert_and_read_for_select_when_sticky_connection_is_disabled()
{
$this->configureReadWriteConnection(['sticky' => false]);

$ingest = $this->fakeIngest();

Route::get('/users', function () {
DB::table('users')->insert([
'name' => fake()->name(),
'email' => fake()->email(),
'password' => fake()->password(),
]);

return DB::table('users')->get();
});

$response = $this->get('/users');

$response->assertOk();
$ingest->assertWrittenTimes(1);
$ingest->assertLatestWrite('query:0.connection_type', 'write'); // insert
$ingest->assertLatestWrite('query:1.connection_type', 'read'); // select
}

public function test_it_captures_connection_type_as_write_when_it_should_use_write_connection_when_reading()
{
$this->configureReadWriteConnection();

$ingest = $this->fakeIngest();

Route::get('/users', function () {
return DB::useWriteConnectionWhenReading()->table('users')->get();
});

$response = $this->get('/users');

$response->assertOk();
$ingest->assertWrittenTimes(1);
$ingest->assertLatestWrite('query:0.connection_type', 'write');
}

public function test_it_captures_connection_type_as_write_when_in_a_transaction()
{
$this->configureReadWriteConnection();

$ingest = $this->fakeIngest();

Route::get('/users', function () {
DB::beginTransaction();

$users = DB::table('users')->get();

DB::rollBack();

return $users;
});

$response = $this->get('/users');

$response->assertOk();
$ingest->assertWrittenTimes(1);
$ingest->assertLatestWrite('query:0.connection_type', 'write');
}

public function test_it_captures_write_connection_when_forcing_select_from_write_after_read_pdo_is_resolved()
{
$this->configureReadWriteConnection();

$ingest = $this->fakeIngest();

Route::get('/users', function () {
DB::select('select 1');
DB::selectFromWriteConnection('select 1');
});

$response = $this->get('/users');

$response->assertOk();
$ingest->assertWrittenTimes(1);
$ingest->assertLatestWrite('query:0.connection_type', 'read');
$ingest->assertLatestWrite('query:1.connection_type', 'write');
}

public function test_it_captures_connection_type_when_forgetting_modified_records_state()
{
$this->configureReadWriteConnection(['sticky' => true]);

$ingest = $this->fakeIngest();

Route::get('/users', function () {
DB::statement('select 1');
DB::forgetRecordModificationState();
DB::select('select 1');
});

$response = $this->get('/users');

$response->assertOk();
$ingest->assertWrittenTimes(1);
$ingest->assertLatestWrite('query:0.connection_type', 'write');
$ingest->assertLatestWrite('query:1.connection_type', 'read');
}

private function configureReadWriteConnection(array $options = []): void
{
$connection = Config::get('database.default');
$config = Config::get("database.connections.{$connection}");

Config::set("database.connections.{$connection}", array_merge($config, [
'read' => [
'database' => $config['database'],
],
'write' => [
'database' => $config['database'],
],
], $options));

DB::purge($connection);
}
}
Loading