Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 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';
}
}
161 changes: 161 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,162 @@ 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');
}

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);
}
}