diff --git a/composer.json b/composer.json index 1abcf4b97..14eca39fc 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ ], "require": { "php": "^8.2", + "ext-pdo": "*", "ext-zlib": "*", "guzzlehttp/promises": "^2.0", "laravel/framework": "^10.0|^11.0|^12.0", @@ -35,7 +36,6 @@ }, "require-dev": { "ext-pcntl": "*", - "ext-pdo": "*", "aws/aws-sdk-php": "^3.349", "guzzlehttp/guzzle": "^7.0", "guzzlehttp/psr7": "^2.0", diff --git a/src/Records/Query.php b/src/Records/Query.php index 3a79e2203..3deed40be 100644 --- a/src/Records/Query.php +++ b/src/Records/Query.php @@ -4,12 +4,16 @@ final class Query { + /** + * @param 'read'|'write'|'' $connectionType + */ 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, ) { // } diff --git a/src/Sensors/QuerySensor.php b/src/Sensors/QuerySensor.php index 2aaf78dd9..4ce9079ce 100644 --- a/src/Sensors/QuerySensor.php +++ b/src/Sensors/QuerySensor.php @@ -2,6 +2,7 @@ namespace Laravel\Nightwatch\Sensors; +use Illuminate\Database\Connection; use Illuminate\Database\Events\QueryExecuted; use Laravel\Nightwatch\Clock; use Laravel\Nightwatch\Location; @@ -9,6 +10,7 @@ use Laravel\Nightwatch\State\CommandState; use Laravel\Nightwatch\State\RequestState; use Laravel\Nightwatch\Types\Str; +use PDO; use function hash; use function in_array; @@ -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++; @@ -68,6 +71,7 @@ function () use ($event, $record) { 'line' => $record->line, 'duration' => $record->duration, 'connection' => Str::tinyText($record->connection), + 'connection_type' => $record->connectionType, ]; }, ]; @@ -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) { + return null; + } + + if (! $readPdo instanceof PDO) { + return 'write'; + } + + if (! $writePdo instanceof PDO) { + return 'read'; + } + + if ($connection->getReadPdo() === $writePdo) { + return 'write'; + } + + return 'read'; + } } diff --git a/tests/Feature/Sensors/QuerySensorTest.php b/tests/Feature/Sensors/QuerySensorTest.php index 545bb9861..24314e12f 100644 --- a/tests/Feature/Sensors/QuerySensorTest.php +++ b/tests/Feature/Sensors/QuerySensorTest.php @@ -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; @@ -93,6 +95,7 @@ public function test_it_can_ingest_queries(): void 'line' => $line, 'duration' => 4321, 'connection' => $connection, + 'connection_type' => '', ], ]); } @@ -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); + } }