From f03afcdf387cf6b7f6742daf3ae6dc1dfdbd79e6 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 30 Jan 2026 04:19:34 +0100 Subject: [PATCH 01/10] feat(database): add QueryExecuted event class --- packages/database/src/QueryExecuted.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 packages/database/src/QueryExecuted.php diff --git a/packages/database/src/QueryExecuted.php b/packages/database/src/QueryExecuted.php new file mode 100644 index 000000000..2b8861781 --- /dev/null +++ b/packages/database/src/QueryExecuted.php @@ -0,0 +1,18 @@ + Date: Fri, 30 Jan 2026 04:19:41 +0100 Subject: [PATCH 02/10] feat(database): dispatch QueryExecuted event from GenericDatabase --- packages/database/src/DatabaseInitializer.php | 2 ++ packages/database/src/GenericDatabase.php | 30 +++++++++++++++++-- .../TaggedDynamicInitializerTest.php | 4 +++ .../TestingDatabaseInitializer.php | 2 ++ 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/database/src/DatabaseInitializer.php b/packages/database/src/DatabaseInitializer.php index c7a7fd187..c3f59bbad 100644 --- a/packages/database/src/DatabaseInitializer.php +++ b/packages/database/src/DatabaseInitializer.php @@ -11,6 +11,7 @@ use Tempest\Database\Connection\Connection; use Tempest\Database\Connection\PDOConnection; use Tempest\Database\Transactions\GenericTransactionManager; +use Tempest\EventBus\EventBus; use Tempest\Mapper\SerializerFactory; use Tempest\Reflection\ClassReflector; use UnitEnum; @@ -44,6 +45,7 @@ className: Connection::class, connection: $connection, transactionManager: new GenericTransactionManager($connection), serializerFactory: $container->get(SerializerFactory::class), + eventBus: $container->get(EventBus::class), ); } } diff --git a/packages/database/src/GenericDatabase.php b/packages/database/src/GenericDatabase.php index e91004e9c..bc10c8ea2 100644 --- a/packages/database/src/GenericDatabase.php +++ b/packages/database/src/GenericDatabase.php @@ -12,6 +12,7 @@ use Tempest\Database\Connection\Connection; use Tempest\Database\Exceptions\QueryWasInvalid; use Tempest\Database\Transactions\TransactionManager; +use Tempest\EventBus\EventBus; use Tempest\Mapper\SerializerFactory; use Tempest\Support\Str\ImmutableString; use Throwable; @@ -38,6 +39,7 @@ public function __construct( private(set) readonly Connection $connection, private(set) readonly TransactionManager $transactionManager, private(set) readonly SerializerFactory $serializerFactory, + private readonly EventBus $eventBus, ) {} public function execute(BuildsQuery|Query $query): void @@ -47,15 +49,27 @@ public function execute(BuildsQuery|Query $query): void } $bindings = $this->resolveBindings($query); + $sql = $query->compile()->toString(); + $failed = true; + $startTime = hrtime(true); try { - $statement = $this->connection->prepare($query->compile()->toString()); + $statement = $this->connection->prepare($sql); $statement->execute($bindings); $this->lastStatement = $statement; $this->lastQuery = $query; + $failed = false; } catch (PDOException $pdoException) { throw new QueryWasInvalid($query, $bindings, $pdoException); + } finally { + $this->eventBus->dispatch(new QueryExecuted( + sql: $sql, + bindings: $bindings, + durationMs: (hrtime(true) - $startTime) / 1_000_000, + connectionName: $this->tag, + failed: $failed, + )); } } @@ -91,14 +105,26 @@ public function fetch(BuildsQuery|Query $query): array } $bindings = $this->resolveBindings($query); + $sql = $query->compile()->toString(); + $failed = true; + $startTime = hrtime(true); try { - $pdoQuery = $this->connection->prepare($query->compile()->toString()); + $pdoQuery = $this->connection->prepare($sql); $pdoQuery->execute($bindings); + $failed = false; return $pdoQuery->fetchAll(PDO::FETCH_NAMED); } catch (PDOException $pdoException) { throw new QueryWasInvalid($query, $bindings, $pdoException); + } finally { + $this->eventBus->dispatch(new QueryExecuted( + sql: $sql, + bindings: $bindings, + durationMs: (hrtime(true) - $startTime) / 1_000_000, + connectionName: $this->tag, + failed: $failed, + )); } } diff --git a/tests/Integration/Container/TaggedDynamicInitializerTest.php b/tests/Integration/Container/TaggedDynamicInitializerTest.php index 19b0abc64..700e3b82c 100644 --- a/tests/Integration/Container/TaggedDynamicInitializerTest.php +++ b/tests/Integration/Container/TaggedDynamicInitializerTest.php @@ -7,6 +7,9 @@ use Tempest\Database\Config\SQLiteConfig; use Tempest\Database\Database; use Tempest\Database\DatabaseInitializer; +use Tempest\EventBus\EventBus; +use Tempest\EventBus\EventBusConfig; +use Tempest\EventBus\GenericEventBus; use Tempest\Mapper\SerializerFactory; final class TaggedDynamicInitializerTest extends TestCase @@ -15,6 +18,7 @@ public function test_resolve(): void { $container = new GenericContainer(); $container->singleton(SerializerFactory::class, new SerializerFactory($container)); + $container->singleton(EventBus::class, new GenericEventBus($container, new EventBusConfig())); $container->addInitializer(DatabaseInitializer::class); $container->config(new SQLiteConfig( diff --git a/tests/Integration/TestingDatabaseInitializer.php b/tests/Integration/TestingDatabaseInitializer.php index 74185b72b..55cfb49ef 100644 --- a/tests/Integration/TestingDatabaseInitializer.php +++ b/tests/Integration/TestingDatabaseInitializer.php @@ -13,6 +13,7 @@ use Tempest\Database\Database; use Tempest\Database\GenericDatabase; use Tempest\Database\Transactions\GenericTransactionManager; +use Tempest\EventBus\EventBus; use Tempest\Mapper\SerializerFactory; use Tempest\Reflection\ClassReflector; use Tempest\Support\Str; @@ -54,6 +55,7 @@ public function initialize(ClassReflector $class, null|string|UnitEnum $tag, Con $connection, new GenericTransactionManager($connection), $container->get(SerializerFactory::class), + $container->get(EventBus::class), ); } } From 86aa3a3acaffa589df5f549ee925e0c3c9b4ba06 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 30 Jan 2026 04:19:48 +0100 Subject: [PATCH 03/10] test(database): add unit tests for QueryExecuted event --- .../database/tests/GenericDatabaseTest.php | 11 ++ packages/database/tests/QueryExecutedTest.php | 146 ++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 packages/database/tests/QueryExecutedTest.php diff --git a/packages/database/tests/GenericDatabaseTest.php b/packages/database/tests/GenericDatabaseTest.php index ead08fdd6..38b5934da 100644 --- a/packages/database/tests/GenericDatabaseTest.php +++ b/packages/database/tests/GenericDatabaseTest.php @@ -10,6 +10,9 @@ use Tempest\Database\Connection\Connection; use Tempest\Database\GenericDatabase; use Tempest\Database\Transactions\GenericTransactionManager; +use Tempest\EventBus\EventBusConfig; +use Tempest\EventBus\GenericEventBus; +use Tempest\EventBus\Testing\FakeEventBus; use Tempest\Mapper\SerializerFactory; /** @@ -31,10 +34,14 @@ public function test_it_executes_transactions(): void ->withAnyParameters() ->willReturn(true); + $container = new GenericContainer(); + $eventBus = new FakeEventBus(new GenericEventBus($container, new EventBusConfig())); + $database = new GenericDatabase( $connection, new GenericTransactionManager($connection), new SerializerFactory(new GenericContainer()), + $eventBus, ); $result = $database->withinTransaction(function () { @@ -58,10 +65,14 @@ public function test_it_rolls_back_transactions_on_failure(): void ->withAnyParameters() ->willReturn(true); + $container = new GenericContainer(); + $eventBus = new FakeEventBus(new GenericEventBus($container, new EventBusConfig())); + $database = new GenericDatabase( $connection, new GenericTransactionManager($connection), new SerializerFactory(new GenericContainer()), + $eventBus, ); $result = $database->withinTransaction(function (): never { diff --git a/packages/database/tests/QueryExecutedTest.php b/packages/database/tests/QueryExecutedTest.php new file mode 100644 index 000000000..0903e5864 --- /dev/null +++ b/packages/database/tests/QueryExecutedTest.php @@ -0,0 +1,146 @@ +connect(); + + $database = new GenericDatabase( + $connection, + new GenericTransactionManager($connection), + new SerializerFactory(new GenericContainer()), + $eventBus, + ); + + $container = new GenericContainer(); + $container->singleton(Database::class, $database); + GenericContainer::setInstance($container); + + return $database; + } + + #[Test] + public function execute_dispatches_query_executed_event(): void + { + $eventBus = $this->createFakeEventBus(); + $database = $this->createDatabase($eventBus); + + $database->execute(new Query('CREATE TABLE test (id INTEGER)')); + + $this->assertCount(1, $eventBus->dispatched); + $event = $eventBus->dispatched[0]; + $this->assertInstanceOf(QueryExecuted::class, $event); + $this->assertSame('CREATE TABLE test (id INTEGER)', $event->sql); + $this->assertSame([], $event->bindings); + $this->assertFalse($event->failed); + $this->assertGreaterThanOrEqual(0.0, $event->durationMs); + $this->assertNull($event->connectionName); + } + + #[Test] + public function fetch_dispatches_query_executed_event(): void + { + $eventBus = $this->createFakeEventBus(); + $database = $this->createDatabase($eventBus); + + $database->execute(new Query('CREATE TABLE test (id INTEGER)')); + $database->fetch(new Query('SELECT * FROM test')); + + $this->assertCount(2, $eventBus->dispatched); + $event = $eventBus->dispatched[1]; + $this->assertInstanceOf(QueryExecuted::class, $event); + $this->assertSame('SELECT * FROM test', $event->sql); + $this->assertFalse($event->failed); + } + + #[Test] + public function execute_dispatches_event_on_failure(): void + { + $eventBus = $this->createFakeEventBus(); + $database = $this->createDatabase($eventBus); + + try { + $database->execute(new Query('INVALID SQL')); + } catch (QueryWasInvalid) { // @mago-expect lint:no-empty-catch-clause + } + + $this->assertCount(1, $eventBus->dispatched); + $event = $eventBus->dispatched[0]; + $this->assertInstanceOf(QueryExecuted::class, $event); + $this->assertTrue($event->failed); + } + + #[Test] + public function fetch_dispatches_event_on_failure(): void + { + $eventBus = $this->createFakeEventBus(); + $database = $this->createDatabase($eventBus); + + try { + $database->fetch(new Query('INVALID SQL')); + } catch (QueryWasInvalid) { // @mago-expect lint:no-empty-catch-clause + } + + $this->assertCount(1, $eventBus->dispatched); + $event = $eventBus->dispatched[0]; + $this->assertInstanceOf(QueryExecuted::class, $event); + $this->assertTrue($event->failed); + } + + #[Test] + public function query_executed_event_contains_connection_name(): void + { + $eventBus = $this->createFakeEventBus(); + $database = $this->createDatabase($eventBus, tag: 'reporting'); + + $database->execute(new Query('CREATE TABLE test (id INTEGER)')); + + $this->assertCount(1, $eventBus->dispatched); + $event = $eventBus->dispatched[0]; + $this->assertInstanceOf(QueryExecuted::class, $event); + $this->assertSame('reporting', $event->connectionName); + } +} From 85a637d2147738140b37c9710413db3bc9834a79 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 30 Jan 2026 04:19:54 +0100 Subject: [PATCH 04/10] test(database): add integration tests for QueryExecuted event --- tests/Fixtures/Events/QueryLogger.php | 25 ++++ .../Database/QueryExecutedTest.php | 140 ++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 tests/Fixtures/Events/QueryLogger.php create mode 100644 tests/Integration/Database/QueryExecutedTest.php diff --git a/tests/Fixtures/Events/QueryLogger.php b/tests/Fixtures/Events/QueryLogger.php new file mode 100644 index 000000000..f2d34f8ec --- /dev/null +++ b/tests/Fixtures/Events/QueryLogger.php @@ -0,0 +1,25 @@ +container->get(EventBus::class)->listen(function (QueryExecuted $event): void { + $this->dispatched[] = $event; + }); + } + + #[Test] + public function query_executed_event_is_dispatched_on_insert(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + $this->listenForQueryEvents(); + + Book::new(title: 'Timeline Taxi')->save(); + + $this->assertNotEmpty($this->dispatched); + $this->assertFalse($this->dispatched[0]->failed); + $this->assertStringContainsString('INSERT', $this->dispatched[0]->sql); + } + + #[Test] + public function query_executed_event_is_dispatched_on_select(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + Book::new(title: 'Timeline Taxi')->save(); + + $this->listenForQueryEvents(); + + $books = Book::select()->all(); + + $this->assertCount(1, $books); + $this->assertNotEmpty($this->dispatched); + + $selectEvent = $this->dispatched[0]; + $this->assertFalse($selectEvent->failed); + $this->assertStringContainsString('SELECT', $selectEvent->sql); + $this->assertGreaterThanOrEqual(0.0, $selectEvent->durationMs); + } + + #[Test] + public function query_executed_event_is_dispatched_on_failure(): void + { + $this->listenForQueryEvents(); + + try { + Book::select()->orderByRaw('title DES')->first(); + } catch (\Tempest\Database\Exceptions\QueryWasInvalid) { // @mago-expect lint:no-empty-catch-clause + } + + $this->assertNotEmpty($this->dispatched); + $this->assertTrue($this->dispatched[0]->failed); + } + + #[Test] + public function query_executed_event_has_timing_and_bindings(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + Book::new(title: 'Timeline Taxi')->save(); + + $this->listenForQueryEvents(); + + Book::select()->where('title', 'Timeline Taxi')->all(); + + $this->assertNotEmpty($this->dispatched); + + $event = $this->dispatched[0]; + $this->assertFalse($event->failed); + $this->assertGreaterThanOrEqual(0.0, $event->durationMs); + $this->assertNotEmpty($event->bindings); + } + + #[Test] + public function discovered_event_handler_receives_query_executed(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + QueryLogger::reset(); + + Book::new(title: 'Timeline Taxi')->save(); + Book::select()->all(); + + $this->assertNotEmpty(QueryLogger::$queries); + + $sqls = array_map(fn (QueryExecuted $e) => $e->sql, QueryLogger::$queries); + $this->assertTrue( + in_array(true, array_map(fn (string $sql) => str_contains($sql, 'INSERT'), $sqls), true), + ); + $this->assertTrue( + in_array(true, array_map(fn (string $sql) => str_contains($sql, 'SELECT'), $sqls), true), + ); + } +} From 9d49df06cc031a6309b5a88f66b9e410a8b9c33d Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 30 Jan 2026 05:26:05 +0100 Subject: [PATCH 05/10] refactor(database): extract query event dispatcher --- packages/database/src/DatabaseInitializer.php | 3 +- packages/database/src/GenericDatabase.php | 69 +++++++++---------- .../database/src/QueryEventDispatcher.php | 24 +++++++ .../database/tests/GenericDatabaseTest.php | 5 +- packages/database/tests/QueryExecutedTest.php | 3 +- .../TestingDatabaseInitializer.php | 4 +- 6 files changed, 63 insertions(+), 45 deletions(-) create mode 100644 packages/database/src/QueryEventDispatcher.php diff --git a/packages/database/src/DatabaseInitializer.php b/packages/database/src/DatabaseInitializer.php index c3f59bbad..70b39a32f 100644 --- a/packages/database/src/DatabaseInitializer.php +++ b/packages/database/src/DatabaseInitializer.php @@ -11,7 +11,6 @@ use Tempest\Database\Connection\Connection; use Tempest\Database\Connection\PDOConnection; use Tempest\Database\Transactions\GenericTransactionManager; -use Tempest\EventBus\EventBus; use Tempest\Mapper\SerializerFactory; use Tempest\Reflection\ClassReflector; use UnitEnum; @@ -45,7 +44,7 @@ className: Connection::class, connection: $connection, transactionManager: new GenericTransactionManager($connection), serializerFactory: $container->get(SerializerFactory::class), - eventBus: $container->get(EventBus::class), + eventDispatcher: $container->get(QueryEventDispatcher::class), ); } } diff --git a/packages/database/src/GenericDatabase.php b/packages/database/src/GenericDatabase.php index bc10c8ea2..022082161 100644 --- a/packages/database/src/GenericDatabase.php +++ b/packages/database/src/GenericDatabase.php @@ -12,7 +12,6 @@ use Tempest\Database\Connection\Connection; use Tempest\Database\Exceptions\QueryWasInvalid; use Tempest\Database\Transactions\TransactionManager; -use Tempest\EventBus\EventBus; use Tempest\Mapper\SerializerFactory; use Tempest\Support\Str\ImmutableString; use Throwable; @@ -39,7 +38,7 @@ public function __construct( private(set) readonly Connection $connection, private(set) readonly TransactionManager $transactionManager, private(set) readonly SerializerFactory $serializerFactory, - private readonly EventBus $eventBus, + private readonly QueryEventDispatcher $eventDispatcher, ) {} public function execute(BuildsQuery|Query $query): void @@ -48,29 +47,13 @@ public function execute(BuildsQuery|Query $query): void $query = $query->build(); } - $bindings = $this->resolveBindings($query); - $sql = $query->compile()->toString(); - $failed = true; - $startTime = hrtime(true); - - try { + $this->runQuery($query, function (string $sql, array $bindings) use ($query): void { $statement = $this->connection->prepare($sql); $statement->execute($bindings); $this->lastStatement = $statement; $this->lastQuery = $query; - $failed = false; - } catch (PDOException $pdoException) { - throw new QueryWasInvalid($query, $bindings, $pdoException); - } finally { - $this->eventBus->dispatch(new QueryExecuted( - sql: $sql, - bindings: $bindings, - durationMs: (hrtime(true) - $startTime) / 1_000_000, - connectionName: $this->tag, - failed: $failed, - )); - } + }); } public function getLastInsertId(): ?PrimaryKey @@ -104,28 +87,12 @@ public function fetch(BuildsQuery|Query $query): array $query = $query->build(); } - $bindings = $this->resolveBindings($query); - $sql = $query->compile()->toString(); - $failed = true; - $startTime = hrtime(true); - - try { + return $this->runQuery($query, function (string $sql, array $bindings): array { $pdoQuery = $this->connection->prepare($sql); $pdoQuery->execute($bindings); - $failed = false; return $pdoQuery->fetchAll(PDO::FETCH_NAMED); - } catch (PDOException $pdoException) { - throw new QueryWasInvalid($query, $bindings, $pdoException); - } finally { - $this->eventBus->dispatch(new QueryExecuted( - sql: $sql, - bindings: $bindings, - durationMs: (hrtime(true) - $startTime) / 1_000_000, - connectionName: $this->tag, - failed: $failed, - )); - } + }); } public function fetchFirst(BuildsQuery|Query $query): ?array @@ -184,4 +151,30 @@ private function resolveBindings(Query $query): array return $bindings; } + + /** @template TResult */ + private function runQuery(Query $query, callable $runner): mixed + { + $bindings = $this->resolveBindings($query); + $sql = $query->compile()->toString(); + $failed = true; + $startTime = hrtime(true); + + try { + $result = $runner($sql, $bindings); + $failed = false; + + return $result; + } catch (PDOException $pdoException) { + throw new QueryWasInvalid($query, $bindings, $pdoException); + } finally { + $this->eventDispatcher->dispatch(new QueryExecuted( + sql: $sql, + bindings: $bindings, + durationMs: (hrtime(true) - $startTime) / 1_000_000, + connectionName: $this->tag, + failed: $failed, + )); + } + } } diff --git a/packages/database/src/QueryEventDispatcher.php b/packages/database/src/QueryEventDispatcher.php new file mode 100644 index 000000000..f5339a356 --- /dev/null +++ b/packages/database/src/QueryEventDispatcher.php @@ -0,0 +1,24 @@ +eventBus->dispatch($event); + } catch (Throwable $throwable) { + unset($throwable); + } + } +} diff --git a/packages/database/tests/GenericDatabaseTest.php b/packages/database/tests/GenericDatabaseTest.php index 38b5934da..8f9d54f57 100644 --- a/packages/database/tests/GenericDatabaseTest.php +++ b/packages/database/tests/GenericDatabaseTest.php @@ -9,6 +9,7 @@ use Tempest\Container\GenericContainer; use Tempest\Database\Connection\Connection; use Tempest\Database\GenericDatabase; +use Tempest\Database\QueryEventDispatcher; use Tempest\Database\Transactions\GenericTransactionManager; use Tempest\EventBus\EventBusConfig; use Tempest\EventBus\GenericEventBus; @@ -41,7 +42,7 @@ public function test_it_executes_transactions(): void $connection, new GenericTransactionManager($connection), new SerializerFactory(new GenericContainer()), - $eventBus, + new QueryEventDispatcher($eventBus), ); $result = $database->withinTransaction(function () { @@ -72,7 +73,7 @@ public function test_it_rolls_back_transactions_on_failure(): void $connection, new GenericTransactionManager($connection), new SerializerFactory(new GenericContainer()), - $eventBus, + new QueryEventDispatcher($eventBus), ); $result = $database->withinTransaction(function (): never { diff --git a/packages/database/tests/QueryExecutedTest.php b/packages/database/tests/QueryExecutedTest.php index 0903e5864..af12017cc 100644 --- a/packages/database/tests/QueryExecutedTest.php +++ b/packages/database/tests/QueryExecutedTest.php @@ -13,6 +13,7 @@ use Tempest\Database\Exceptions\QueryWasInvalid; use Tempest\Database\GenericDatabase; use Tempest\Database\Query; +use Tempest\Database\QueryEventDispatcher; use Tempest\Database\QueryExecuted; use Tempest\Database\Transactions\GenericTransactionManager; use Tempest\EventBus\EventBusConfig; @@ -52,7 +53,7 @@ private function createDatabase(FakeEventBus $eventBus, ?string $tag = null): Ge $connection, new GenericTransactionManager($connection), new SerializerFactory(new GenericContainer()), - $eventBus, + new QueryEventDispatcher($eventBus), ); $container = new GenericContainer(); diff --git a/tests/Integration/TestingDatabaseInitializer.php b/tests/Integration/TestingDatabaseInitializer.php index 55cfb49ef..5575c6be6 100644 --- a/tests/Integration/TestingDatabaseInitializer.php +++ b/tests/Integration/TestingDatabaseInitializer.php @@ -12,8 +12,8 @@ use Tempest\Database\Connection\PDOConnection; use Tempest\Database\Database; use Tempest\Database\GenericDatabase; +use Tempest\Database\QueryEventDispatcher; use Tempest\Database\Transactions\GenericTransactionManager; -use Tempest\EventBus\EventBus; use Tempest\Mapper\SerializerFactory; use Tempest\Reflection\ClassReflector; use Tempest\Support\Str; @@ -55,7 +55,7 @@ public function initialize(ClassReflector $class, null|string|UnitEnum $tag, Con $connection, new GenericTransactionManager($connection), $container->get(SerializerFactory::class), - $container->get(EventBus::class), + $container->get(QueryEventDispatcher::class), ); } } From 5fc98959b803b81be9e631e80c09835696cdb2ee Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 30 Jan 2026 05:56:51 +0100 Subject: [PATCH 06/10] refactor(database): add query analysis methods to QueryExecuted --- packages/database/src/QueryExecuted.php | 154 +++++++++++- packages/database/tests/QueryExecutedTest.php | 233 ++++++++++++++++++ 2 files changed, 381 insertions(+), 6 deletions(-) diff --git a/packages/database/src/QueryExecuted.php b/packages/database/src/QueryExecuted.php index 2b8861781..a94572f9e 100644 --- a/packages/database/src/QueryExecuted.php +++ b/packages/database/src/QueryExecuted.php @@ -4,15 +4,157 @@ namespace Tempest\Database; +use Tempest\Database\Config\DatabaseDialect; +use Throwable; use UnitEnum; -final readonly class QueryExecuted +use function Tempest\Container\get; +use function Tempest\Support\Arr\contains; +use function Tempest\Support\Str\before_first; +use function Tempest\Support\Str\to_upper_case; + +final class QueryExecuted { + private ?array $explainResult = null; + private bool $explainComputed = false; + + public string $queryType { + get => to_upper_case(before_first(trim($this->sql), ' ')); + } + public function __construct( - public string $sql, - public array $bindings, - public float $durationMs, - public null|string|UnitEnum $connectionName, - public bool $failed, + public readonly string $sql, + public readonly array $bindings, + public readonly float $durationMs, + public readonly null|string|UnitEnum $connectionName, + public readonly bool $failed, ) {} + + public function explain(): ?array + { + if ($this->explainComputed) { + return $this->explainResult; + } + + $this->explainComputed = true; + + if (! $this->isSelect()) { + return null; + } + + try { + $db = get(Database::class); + $this->explainResult = $db->fetch( + new Query($this->getExplainSql($db->dialect), $this->bindings), + ); + } catch (Throwable) { + $this->explainResult = null; + } + + return $this->explainResult; + } + + private function getExplainSql(DatabaseDialect $dialect): string + { + return match ($dialect) { + DatabaseDialect::SQLITE => "EXPLAIN QUERY PLAN {$this->sql}", + default => "EXPLAIN {$this->sql}", + }; + } + + public function isSlow(float $thresholdMs = 100.0): bool + { + return $this->durationMs > $thresholdMs; + } + + public function isSelect(): bool + { + return $this->queryType === 'SELECT'; + } + + public function isInsert(): bool + { + return $this->queryType === 'INSERT'; + } + + public function isUpdate(): bool + { + return $this->queryType === 'UPDATE'; + } + + public function isDelete(): bool + { + return $this->queryType === 'DELETE'; + } + + public function usesFullTableScan(): bool + { + $explain = $this->explain(); + + if ($explain === null) { + return false; + } + + return contains($explain, static function (array $row): bool { + $isFullScanType = isset($row['type']) && strtoupper($row['type']) === 'ALL'; + $hasScanInDetail = isset($row['detail']) && str_contains(strtoupper($row['detail']), 'SCAN'); + + return $isFullScanType || $hasScanInDetail; + }); + } + + public function getRowsExamined(): int + { + $explain = $this->explain(); + + if ($explain === null) { + return 0; + } + + $total = 0; + + foreach ($explain as $row) { + if (isset($row['rows'])) { + $total += (int) $row['rows']; + } + + if (isset($row['detail']) && preg_match('/~(\d+) rows/i', $row['detail'], $matches)) { + $total += (int) $matches[1]; + } + } + + return $total; + } + + public function usesIndex(): bool + { + return $this->getIndexUsed() !== null; + } + + public function getIndexUsed(): ?string + { + $explain = $this->explain(); + + if ($explain === null) { + return null; + } + + foreach ($explain as $row) { + if (isset($row['key']) && $row['key'] !== '') { + return $row['key']; + } + + if (isset($row['detail'])) { + if (preg_match('/USING INDEX (\S+)/i', $row['detail'], $matches)) { + return $matches[1]; + } + + if (preg_match('/USING (INTEGER )?PRIMARY KEY/i', $row['detail'])) { + return 'PRIMARY KEY'; + } + } + } + + return null; + } } diff --git a/packages/database/tests/QueryExecutedTest.php b/packages/database/tests/QueryExecutedTest.php index af12017cc..c0acaa9ea 100644 --- a/packages/database/tests/QueryExecutedTest.php +++ b/packages/database/tests/QueryExecutedTest.php @@ -144,4 +144,237 @@ public function query_executed_event_contains_connection_name(): void $this->assertInstanceOf(QueryExecuted::class, $event); $this->assertSame('reporting', $event->connectionName); } + + #[Test] + public function explain_returns_null_for_non_select_queries(): void + { + $eventBus = $this->createFakeEventBus(); + $database = $this->createDatabase($eventBus); + + $database->execute(new Query('CREATE TABLE test (id INTEGER)')); + + $event = $eventBus->dispatched[0]; + $this->assertNull($event->explain()); + } + + #[Test] + public function explain_returns_explain_result_for_select_queries(): void + { + $eventBus = $this->createFakeEventBus(); + $database = $this->createDatabase($eventBus); + + $database->execute(new Query('CREATE TABLE test (id INTEGER, name TEXT)')); + $database->execute(new Query('INSERT INTO test (id, name) VALUES (1, "test")')); + $database->fetch(new Query('SELECT * FROM test WHERE id = 1')); + + $event = $eventBus->dispatched[2]; + + $this->assertTrue($event->isSelect()); + + $explain = $event->explain(); + + $this->assertIsArray($explain); + $this->assertNotEmpty($explain); + } + + #[Test] + public function explain_caches_result(): void + { + $eventBus = $this->createFakeEventBus(); + $database = $this->createDatabase($eventBus); + + $database->execute(new Query('CREATE TABLE test (id INTEGER)')); + $database->fetch(new Query('SELECT * FROM test')); + + $event = $eventBus->dispatched[1]; + $firstCall = $event->explain(); + $secondCall = $event->explain(); + + $this->assertSame($firstCall, $secondCall); + } + + #[Test] + public function is_slow_returns_true_for_slow_queries(): void + { + $event = new QueryExecuted( + sql: 'SELECT * FROM test', + bindings: [], + durationMs: 150.0, + connectionName: null, + failed: false, + ); + + $this->assertTrue($event->isSlow()); + $this->assertTrue($event->isSlow(100.0)); + $this->assertFalse($event->isSlow(200.0)); + } + + #[Test] + public function is_slow_returns_false_for_fast_queries(): void + { + $event = new QueryExecuted( + sql: 'SELECT * FROM test', + bindings: [], + durationMs: 50.0, + connectionName: null, + failed: false, + ); + + $this->assertFalse($event->isSlow()); + } + + #[Test] + public function query_type_detection(): void + { + $selectEvent = new QueryExecuted('SELECT * FROM test', [], 0.0, null, false); + $insertEvent = new QueryExecuted('INSERT INTO test VALUES (1)', [], 0.0, null, false); + $updateEvent = new QueryExecuted('UPDATE test SET x = 1', [], 0.0, null, false); + $deleteEvent = new QueryExecuted('DELETE FROM test', [], 0.0, null, false); + $createEvent = new QueryExecuted('CREATE TABLE test (id INT)', [], 0.0, null, false); + + $this->assertTrue($selectEvent->isSelect()); + $this->assertFalse($selectEvent->isInsert()); + $this->assertFalse($selectEvent->isUpdate()); + $this->assertFalse($selectEvent->isDelete()); + + $this->assertTrue($insertEvent->isInsert()); + $this->assertTrue($updateEvent->isUpdate()); + $this->assertTrue($deleteEvent->isDelete()); + + $this->assertSame('SELECT', $selectEvent->queryType); + $this->assertSame('INSERT', $insertEvent->queryType); + $this->assertSame('UPDATE', $updateEvent->queryType); + $this->assertSame('DELETE', $deleteEvent->queryType); + $this->assertSame('CREATE', $createEvent->queryType); + } + + #[Test] + public function query_type_detection_with_whitespace(): void + { + $event = new QueryExecuted(' SELECT * FROM test', [], 0.0, null, false); + + $this->assertTrue($event->isSelect()); + $this->assertSame('SELECT', $event->queryType); + } + + #[Test] + public function query_type_detection_is_case_insensitive(): void + { + $select = new QueryExecuted('select * from test', [], 0.0, null, false); + $insert = new QueryExecuted('insert into test values (1)', [], 0.0, null, false); + $update = new QueryExecuted('update test set x = 1', [], 0.0, null, false); + $delete = new QueryExecuted('delete from test', [], 0.0, null, false); + + $this->assertTrue($select->isSelect()); + $this->assertTrue($insert->isInsert()); + $this->assertTrue($update->isUpdate()); + $this->assertTrue($delete->isDelete()); + + $this->assertSame('SELECT', $select->queryType); + } + + #[Test] + public function uses_full_table_scan_detection(): void + { + $eventBus = $this->createFakeEventBus(); + $database = $this->createDatabase($eventBus); + + $database->execute(new Query('CREATE TABLE test (id INTEGER, name TEXT)')); + $database->fetch(new Query('SELECT * FROM test')); + + $event = $eventBus->dispatched[1]; + + $this->assertTrue($event->usesFullTableScan()); + } + + #[Test] + public function uses_full_table_scan_returns_false_for_non_select(): void + { + $event = new QueryExecuted('INSERT INTO test VALUES (1)', [], 0.0, null, false); + + $this->assertFalse($event->usesFullTableScan()); + } + + #[Test] + public function get_rows_examined(): void + { + $eventBus = $this->createFakeEventBus(); + $database = $this->createDatabase($eventBus); + + $database->execute(new Query('CREATE TABLE test (id INTEGER, name TEXT)')); + $database->execute(new Query('INSERT INTO test (id, name) VALUES (1, "test")')); + $database->execute(new Query('INSERT INTO test (id, name) VALUES (2, "test2")')); + $database->fetch(new Query('SELECT * FROM test')); + + $event = $eventBus->dispatched[3]; + + $this->assertGreaterThanOrEqual(0, $event->getRowsExamined()); + } + + #[Test] + public function get_rows_examined_returns_zero_for_non_select(): void + { + $event = new QueryExecuted('INSERT INTO test VALUES (1)', [], 0.0, null, false); + + $this->assertSame(0, $event->getRowsExamined()); + } + + #[Test] + public function uses_index_detection(): void + { + $eventBus = $this->createFakeEventBus(); + $database = $this->createDatabase($eventBus); + + $database->execute(new Query('CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)')); + $database->execute(new Query('INSERT INTO test (id, name) VALUES (1, "test")')); + $database->fetch(new Query('SELECT * FROM test WHERE id = 1')); + + $event = $eventBus->dispatched[2]; + + $this->assertTrue($event->usesIndex()); + } + + #[Test] + public function uses_index_returns_false_for_full_table_scan(): void + { + $eventBus = $this->createFakeEventBus(); + $database = $this->createDatabase($eventBus); + + $database->execute(new Query('CREATE TABLE test (id INTEGER, name TEXT)')); + $database->fetch(new Query('SELECT * FROM test')); + + $event = $eventBus->dispatched[1]; + + $this->assertFalse($event->usesIndex()); + } + + #[Test] + public function get_index_used(): void + { + $eventBus = $this->createFakeEventBus(); + $database = $this->createDatabase($eventBus); + + $database->execute(new Query('CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)')); + $database->execute(new Query('INSERT INTO test (id, name) VALUES (1, "test")')); + $database->fetch(new Query('SELECT * FROM test WHERE id = 1')); + + $event = $eventBus->dispatched[2]; + + $this->assertIsString($event->getIndexUsed()); + $this->assertNotEmpty($event->getIndexUsed()); + } + + #[Test] + public function get_index_used_returns_null_when_no_index(): void + { + $eventBus = $this->createFakeEventBus(); + $database = $this->createDatabase($eventBus); + + $database->execute(new Query('CREATE TABLE test (id INTEGER, name TEXT)')); + $database->fetch(new Query('SELECT * FROM test')); + + $event = $eventBus->dispatched[1]; + + $this->assertNull($event->getIndexUsed()); + } } From 46219d94b46911200a7eb6a6dfd6fe803e582366 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 30 Jan 2026 15:32:48 +0100 Subject: [PATCH 07/10] fix(database): use unnamed catch in QueryEventDispatcher --- packages/database/src/QueryEventDispatcher.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/database/src/QueryEventDispatcher.php b/packages/database/src/QueryEventDispatcher.php index f5339a356..4fc1f3b41 100644 --- a/packages/database/src/QueryEventDispatcher.php +++ b/packages/database/src/QueryEventDispatcher.php @@ -17,8 +17,7 @@ public function dispatch(QueryExecuted $event): void { try { $this->eventBus->dispatch($event); - } catch (Throwable $throwable) { - unset($throwable); + } catch (Throwable) { // @mago-expect lint:no-empty-catch-clause } } } From a3bad8444701d5b78b0524b80e5464f2875b327e Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 30 Jan 2026 15:32:57 +0100 Subject: [PATCH 08/10] refactor(database): strip QueryExecuted to data carrier --- packages/database/src/QueryExecuted.php | 120 +------------- packages/database/tests/QueryExecutedTest.php | 153 ------------------ 2 files changed, 5 insertions(+), 268 deletions(-) diff --git a/packages/database/src/QueryExecuted.php b/packages/database/src/QueryExecuted.php index a94572f9e..2f1427ddc 100644 --- a/packages/database/src/QueryExecuted.php +++ b/packages/database/src/QueryExecuted.php @@ -4,64 +4,25 @@ namespace Tempest\Database; -use Tempest\Database\Config\DatabaseDialect; -use Throwable; use UnitEnum; -use function Tempest\Container\get; -use function Tempest\Support\Arr\contains; use function Tempest\Support\Str\before_first; use function Tempest\Support\Str\to_upper_case; final class QueryExecuted { - private ?array $explainResult = null; - private bool $explainComputed = false; - public string $queryType { get => to_upper_case(before_first(trim($this->sql), ' ')); } public function __construct( - public readonly string $sql, - public readonly array $bindings, - public readonly float $durationMs, - public readonly null|string|UnitEnum $connectionName, - public readonly bool $failed, + public string $sql, + public array $bindings, + public float $durationMs, + public null|string|UnitEnum $connectionName, + public bool $failed, ) {} - public function explain(): ?array - { - if ($this->explainComputed) { - return $this->explainResult; - } - - $this->explainComputed = true; - - if (! $this->isSelect()) { - return null; - } - - try { - $db = get(Database::class); - $this->explainResult = $db->fetch( - new Query($this->getExplainSql($db->dialect), $this->bindings), - ); - } catch (Throwable) { - $this->explainResult = null; - } - - return $this->explainResult; - } - - private function getExplainSql(DatabaseDialect $dialect): string - { - return match ($dialect) { - DatabaseDialect::SQLITE => "EXPLAIN QUERY PLAN {$this->sql}", - default => "EXPLAIN {$this->sql}", - }; - } - public function isSlow(float $thresholdMs = 100.0): bool { return $this->durationMs > $thresholdMs; @@ -86,75 +47,4 @@ public function isDelete(): bool { return $this->queryType === 'DELETE'; } - - public function usesFullTableScan(): bool - { - $explain = $this->explain(); - - if ($explain === null) { - return false; - } - - return contains($explain, static function (array $row): bool { - $isFullScanType = isset($row['type']) && strtoupper($row['type']) === 'ALL'; - $hasScanInDetail = isset($row['detail']) && str_contains(strtoupper($row['detail']), 'SCAN'); - - return $isFullScanType || $hasScanInDetail; - }); - } - - public function getRowsExamined(): int - { - $explain = $this->explain(); - - if ($explain === null) { - return 0; - } - - $total = 0; - - foreach ($explain as $row) { - if (isset($row['rows'])) { - $total += (int) $row['rows']; - } - - if (isset($row['detail']) && preg_match('/~(\d+) rows/i', $row['detail'], $matches)) { - $total += (int) $matches[1]; - } - } - - return $total; - } - - public function usesIndex(): bool - { - return $this->getIndexUsed() !== null; - } - - public function getIndexUsed(): ?string - { - $explain = $this->explain(); - - if ($explain === null) { - return null; - } - - foreach ($explain as $row) { - if (isset($row['key']) && $row['key'] !== '') { - return $row['key']; - } - - if (isset($row['detail'])) { - if (preg_match('/USING INDEX (\S+)/i', $row['detail'], $matches)) { - return $matches[1]; - } - - if (preg_match('/USING (INTEGER )?PRIMARY KEY/i', $row['detail'])) { - return 'PRIMARY KEY'; - } - } - } - - return null; - } } diff --git a/packages/database/tests/QueryExecutedTest.php b/packages/database/tests/QueryExecutedTest.php index c0acaa9ea..4768dcb6d 100644 --- a/packages/database/tests/QueryExecutedTest.php +++ b/packages/database/tests/QueryExecutedTest.php @@ -145,54 +145,6 @@ public function query_executed_event_contains_connection_name(): void $this->assertSame('reporting', $event->connectionName); } - #[Test] - public function explain_returns_null_for_non_select_queries(): void - { - $eventBus = $this->createFakeEventBus(); - $database = $this->createDatabase($eventBus); - - $database->execute(new Query('CREATE TABLE test (id INTEGER)')); - - $event = $eventBus->dispatched[0]; - $this->assertNull($event->explain()); - } - - #[Test] - public function explain_returns_explain_result_for_select_queries(): void - { - $eventBus = $this->createFakeEventBus(); - $database = $this->createDatabase($eventBus); - - $database->execute(new Query('CREATE TABLE test (id INTEGER, name TEXT)')); - $database->execute(new Query('INSERT INTO test (id, name) VALUES (1, "test")')); - $database->fetch(new Query('SELECT * FROM test WHERE id = 1')); - - $event = $eventBus->dispatched[2]; - - $this->assertTrue($event->isSelect()); - - $explain = $event->explain(); - - $this->assertIsArray($explain); - $this->assertNotEmpty($explain); - } - - #[Test] - public function explain_caches_result(): void - { - $eventBus = $this->createFakeEventBus(); - $database = $this->createDatabase($eventBus); - - $database->execute(new Query('CREATE TABLE test (id INTEGER)')); - $database->fetch(new Query('SELECT * FROM test')); - - $event = $eventBus->dispatched[1]; - $firstCall = $event->explain(); - $secondCall = $event->explain(); - - $this->assertSame($firstCall, $secondCall); - } - #[Test] public function is_slow_returns_true_for_slow_queries(): void { @@ -272,109 +224,4 @@ public function query_type_detection_is_case_insensitive(): void $this->assertSame('SELECT', $select->queryType); } - - #[Test] - public function uses_full_table_scan_detection(): void - { - $eventBus = $this->createFakeEventBus(); - $database = $this->createDatabase($eventBus); - - $database->execute(new Query('CREATE TABLE test (id INTEGER, name TEXT)')); - $database->fetch(new Query('SELECT * FROM test')); - - $event = $eventBus->dispatched[1]; - - $this->assertTrue($event->usesFullTableScan()); - } - - #[Test] - public function uses_full_table_scan_returns_false_for_non_select(): void - { - $event = new QueryExecuted('INSERT INTO test VALUES (1)', [], 0.0, null, false); - - $this->assertFalse($event->usesFullTableScan()); - } - - #[Test] - public function get_rows_examined(): void - { - $eventBus = $this->createFakeEventBus(); - $database = $this->createDatabase($eventBus); - - $database->execute(new Query('CREATE TABLE test (id INTEGER, name TEXT)')); - $database->execute(new Query('INSERT INTO test (id, name) VALUES (1, "test")')); - $database->execute(new Query('INSERT INTO test (id, name) VALUES (2, "test2")')); - $database->fetch(new Query('SELECT * FROM test')); - - $event = $eventBus->dispatched[3]; - - $this->assertGreaterThanOrEqual(0, $event->getRowsExamined()); - } - - #[Test] - public function get_rows_examined_returns_zero_for_non_select(): void - { - $event = new QueryExecuted('INSERT INTO test VALUES (1)', [], 0.0, null, false); - - $this->assertSame(0, $event->getRowsExamined()); - } - - #[Test] - public function uses_index_detection(): void - { - $eventBus = $this->createFakeEventBus(); - $database = $this->createDatabase($eventBus); - - $database->execute(new Query('CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)')); - $database->execute(new Query('INSERT INTO test (id, name) VALUES (1, "test")')); - $database->fetch(new Query('SELECT * FROM test WHERE id = 1')); - - $event = $eventBus->dispatched[2]; - - $this->assertTrue($event->usesIndex()); - } - - #[Test] - public function uses_index_returns_false_for_full_table_scan(): void - { - $eventBus = $this->createFakeEventBus(); - $database = $this->createDatabase($eventBus); - - $database->execute(new Query('CREATE TABLE test (id INTEGER, name TEXT)')); - $database->fetch(new Query('SELECT * FROM test')); - - $event = $eventBus->dispatched[1]; - - $this->assertFalse($event->usesIndex()); - } - - #[Test] - public function get_index_used(): void - { - $eventBus = $this->createFakeEventBus(); - $database = $this->createDatabase($eventBus); - - $database->execute(new Query('CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)')); - $database->execute(new Query('INSERT INTO test (id, name) VALUES (1, "test")')); - $database->fetch(new Query('SELECT * FROM test WHERE id = 1')); - - $event = $eventBus->dispatched[2]; - - $this->assertIsString($event->getIndexUsed()); - $this->assertNotEmpty($event->getIndexUsed()); - } - - #[Test] - public function get_index_used_returns_null_when_no_index(): void - { - $eventBus = $this->createFakeEventBus(); - $database = $this->createDatabase($eventBus); - - $database->execute(new Query('CREATE TABLE test (id INTEGER, name TEXT)')); - $database->fetch(new Query('SELECT * FROM test')); - - $event = $eventBus->dispatched[1]; - - $this->assertNull($event->getIndexUsed()); - } } From 7e4e7f4ff2f8ac99f4f81234752c89307dbb3d85 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 30 Jan 2026 15:33:08 +0100 Subject: [PATCH 09/10] feat(database): add QueryAnalyzer class --- packages/database/src/QueryAnalyzer.php | 123 +++++++++++ packages/database/tests/QueryAnalyzerTest.php | 200 ++++++++++++++++++ 2 files changed, 323 insertions(+) create mode 100644 packages/database/src/QueryAnalyzer.php create mode 100644 packages/database/tests/QueryAnalyzerTest.php diff --git a/packages/database/src/QueryAnalyzer.php b/packages/database/src/QueryAnalyzer.php new file mode 100644 index 000000000..bc95f963f --- /dev/null +++ b/packages/database/src/QueryAnalyzer.php @@ -0,0 +1,123 @@ +explainComputed) { + return $this->explainResult; + } + + $this->explainComputed = true; + + if (! $this->query->isSelect()) { + return null; + } + + try { + $this->explainResult = $this->database->fetch( + new Query($this->getExplainSql(), $this->query->bindings), + ); + } catch (Throwable) { + $this->explainResult = null; + } + + return $this->explainResult; + } + + public function usesFullTableScan(): bool + { + $explain = $this->explain(); + + if ($explain === null) { + return false; + } + + return contains($explain, static function (array $row): bool { + $isFullScanType = isset($row['type']) && strtoupper($row['type']) === 'ALL'; + $hasScanInDetail = isset($row['detail']) && str_contains(strtoupper($row['detail']), 'SCAN'); + + return $isFullScanType || $hasScanInDetail; + }); + } + + public function getRowsExamined(): int + { + $explain = $this->explain(); + + if ($explain === null) { + return 0; + } + + $total = 0; + + foreach ($explain as $row) { + if (isset($row['rows'])) { + $total += (int) $row['rows']; + } + + if (isset($row['detail']) && preg_match('/~(\d+) rows/i', $row['detail'], $matches)) { + $total += (int) $matches[1]; + } + } + + return $total; + } + + public function usesIndex(): bool + { + return $this->getIndexUsed() !== null; + } + + public function getIndexUsed(): ?string + { + $explain = $this->explain(); + + if ($explain === null) { + return null; + } + + foreach ($explain as $row) { + if (isset($row['key']) && $row['key'] !== '') { + return $row['key']; + } + + if (isset($row['detail'])) { + if (preg_match('/USING INDEX (\S+)/i', $row['detail'], $matches)) { + return $matches[1]; + } + + if (preg_match('/USING (INTEGER )?PRIMARY KEY/i', $row['detail'])) { + return 'PRIMARY KEY'; + } + } + } + + return null; + } + + private function getExplainSql(): string + { + return match ($this->database->dialect) { + DatabaseDialect::SQLITE => "EXPLAIN QUERY PLAN {$this->query->sql}", + default => "EXPLAIN {$this->query->sql}", + }; + } +} diff --git a/packages/database/tests/QueryAnalyzerTest.php b/packages/database/tests/QueryAnalyzerTest.php new file mode 100644 index 000000000..31963a558 --- /dev/null +++ b/packages/database/tests/QueryAnalyzerTest.php @@ -0,0 +1,200 @@ +eventBus = new FakeEventBus( + genericEventBus: new GenericEventBus( + container: new GenericContainer(), + eventBusConfig: new EventBusConfig(), + ), + ); + + $config = new SQLiteConfig(path: ':memory:'); + $connection = new PDOConnection($config); + $connection->connect(); + + $this->database = new GenericDatabase( + $connection, + new GenericTransactionManager($connection), + new SerializerFactory(new GenericContainer()), + new QueryEventDispatcher($this->eventBus), + ); + + $container = new GenericContainer(); + $container->singleton(Database::class, $this->database); + GenericContainer::setInstance($container); + } + + protected function tearDown(): void + { + GenericContainer::setInstance(null); + + parent::tearDown(); + } + + private function lastEvent(): QueryExecuted + { + return $this->eventBus->dispatched[array_key_last($this->eventBus->dispatched)]; + } + + #[Test] + public function explain_returns_null_for_non_select_queries(): void + { + $this->database->execute(new Query('CREATE TABLE test (id INTEGER)')); + + $analyzer = new QueryAnalyzer($this->lastEvent(), $this->database); + + $this->assertNull($analyzer->explain()); + } + + #[Test] + public function explain_returns_result_for_select_queries(): void + { + $this->database->execute(new Query('CREATE TABLE test (id INTEGER, name TEXT)')); + $this->database->execute(new Query('INSERT INTO test (id, name) VALUES (1, "test")')); + $this->database->fetch(new Query('SELECT * FROM test WHERE id = 1')); + + $analyzer = new QueryAnalyzer($this->lastEvent(), $this->database); + + $this->assertTrue($analyzer->query->isSelect()); + + $explain = $analyzer->explain(); + + $this->assertIsArray($explain); + $this->assertNotEmpty($explain); + } + + #[Test] + public function explain_caches_result(): void + { + $this->database->execute(new Query('CREATE TABLE test (id INTEGER)')); + $this->database->fetch(new Query('SELECT * FROM test')); + + $analyzer = new QueryAnalyzer($this->lastEvent(), $this->database); + + $firstCall = $analyzer->explain(); + $secondCall = $analyzer->explain(); + + $this->assertSame($firstCall, $secondCall); + } + + #[Test] + public function uses_full_table_scan_detection(): void + { + $this->database->execute(new Query('CREATE TABLE test (id INTEGER, name TEXT)')); + $this->database->fetch(new Query('SELECT * FROM test')); + + $analyzer = new QueryAnalyzer($this->lastEvent(), $this->database); + + $this->assertTrue($analyzer->usesFullTableScan()); + } + + #[Test] + public function uses_full_table_scan_returns_false_for_non_select(): void + { + $event = new QueryExecuted('INSERT INTO test VALUES (1)', [], 0.0, null, false); + $analyzer = new QueryAnalyzer($event, $this->database); + + $this->assertFalse($analyzer->usesFullTableScan()); + } + + #[Test] + public function get_rows_examined(): void + { + $this->database->execute(new Query('CREATE TABLE test (id INTEGER, name TEXT)')); + $this->database->execute(new Query('INSERT INTO test (id, name) VALUES (1, "test")')); + $this->database->execute(new Query('INSERT INTO test (id, name) VALUES (2, "test2")')); + $this->database->fetch(new Query('SELECT * FROM test')); + + $analyzer = new QueryAnalyzer($this->lastEvent(), $this->database); + + $this->assertGreaterThanOrEqual(0, $analyzer->getRowsExamined()); + } + + #[Test] + public function get_rows_examined_returns_zero_for_non_select(): void + { + $event = new QueryExecuted('INSERT INTO test VALUES (1)', [], 0.0, null, false); + $analyzer = new QueryAnalyzer($event, $this->database); + + $this->assertSame(0, $analyzer->getRowsExamined()); + } + + #[Test] + public function uses_index_detection(): void + { + $this->database->execute(new Query('CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)')); + $this->database->execute(new Query('INSERT INTO test (id, name) VALUES (1, "test")')); + $this->database->fetch(new Query('SELECT * FROM test WHERE id = 1')); + + $analyzer = new QueryAnalyzer($this->lastEvent(), $this->database); + + $this->assertTrue($analyzer->usesIndex()); + } + + #[Test] + public function uses_index_returns_false_for_full_table_scan(): void + { + $this->database->execute(new Query('CREATE TABLE test (id INTEGER, name TEXT)')); + $this->database->fetch(new Query('SELECT * FROM test')); + + $analyzer = new QueryAnalyzer($this->lastEvent(), $this->database); + + $this->assertFalse($analyzer->usesIndex()); + } + + #[Test] + public function get_index_used(): void + { + $this->database->execute(new Query('CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)')); + $this->database->execute(new Query('INSERT INTO test (id, name) VALUES (1, "test")')); + $this->database->fetch(new Query('SELECT * FROM test WHERE id = 1')); + + $analyzer = new QueryAnalyzer($this->lastEvent(), $this->database); + + $this->assertIsString($analyzer->getIndexUsed()); + $this->assertNotEmpty($analyzer->getIndexUsed()); + } + + #[Test] + public function get_index_used_returns_null_when_no_index(): void + { + $this->database->execute(new Query('CREATE TABLE test (id INTEGER, name TEXT)')); + $this->database->fetch(new Query('SELECT * FROM test')); + + $analyzer = new QueryAnalyzer($this->lastEvent(), $this->database); + + $this->assertNull($analyzer->getIndexUsed()); + } +} From 6b7a5628dc1b9a34b5a198b805d2a528e45ebf6c Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 30 Jan 2026 21:54:52 +0100 Subject: [PATCH 10/10] refactor(database): inline event dispatch, drop QueryEventDispatcher --- packages/database/src/DatabaseInitializer.php | 3 ++- packages/database/src/GenericDatabase.php | 20 +++++++++------- .../database/src/QueryEventDispatcher.php | 23 ------------------- .../database/tests/GenericDatabaseTest.php | 5 ++-- packages/database/tests/QueryAnalyzerTest.php | 3 +-- packages/database/tests/QueryExecutedTest.php | 3 +-- .../TestingDatabaseInitializer.php | 4 ++-- 7 files changed, 20 insertions(+), 41 deletions(-) delete mode 100644 packages/database/src/QueryEventDispatcher.php diff --git a/packages/database/src/DatabaseInitializer.php b/packages/database/src/DatabaseInitializer.php index 70b39a32f..c3f59bbad 100644 --- a/packages/database/src/DatabaseInitializer.php +++ b/packages/database/src/DatabaseInitializer.php @@ -11,6 +11,7 @@ use Tempest\Database\Connection\Connection; use Tempest\Database\Connection\PDOConnection; use Tempest\Database\Transactions\GenericTransactionManager; +use Tempest\EventBus\EventBus; use Tempest\Mapper\SerializerFactory; use Tempest\Reflection\ClassReflector; use UnitEnum; @@ -44,7 +45,7 @@ className: Connection::class, connection: $connection, transactionManager: new GenericTransactionManager($connection), serializerFactory: $container->get(SerializerFactory::class), - eventDispatcher: $container->get(QueryEventDispatcher::class), + eventBus: $container->get(EventBus::class), ); } } diff --git a/packages/database/src/GenericDatabase.php b/packages/database/src/GenericDatabase.php index 022082161..f7ea7c391 100644 --- a/packages/database/src/GenericDatabase.php +++ b/packages/database/src/GenericDatabase.php @@ -12,6 +12,7 @@ use Tempest\Database\Connection\Connection; use Tempest\Database\Exceptions\QueryWasInvalid; use Tempest\Database\Transactions\TransactionManager; +use Tempest\EventBus\EventBus; use Tempest\Mapper\SerializerFactory; use Tempest\Support\Str\ImmutableString; use Throwable; @@ -38,7 +39,7 @@ public function __construct( private(set) readonly Connection $connection, private(set) readonly TransactionManager $transactionManager, private(set) readonly SerializerFactory $serializerFactory, - private readonly QueryEventDispatcher $eventDispatcher, + private readonly EventBus $eventBus, ) {} public function execute(BuildsQuery|Query $query): void @@ -168,13 +169,16 @@ private function runQuery(Query $query, callable $runner): mixed } catch (PDOException $pdoException) { throw new QueryWasInvalid($query, $bindings, $pdoException); } finally { - $this->eventDispatcher->dispatch(new QueryExecuted( - sql: $sql, - bindings: $bindings, - durationMs: (hrtime(true) - $startTime) / 1_000_000, - connectionName: $this->tag, - failed: $failed, - )); + try { + $this->eventBus->dispatch(new QueryExecuted( + sql: $sql, + bindings: $bindings, + durationMs: (hrtime(true) - $startTime) / 1_000_000, + connectionName: $this->tag, + failed: $failed, + )); + } catch (Throwable) { // @mago-ignore lint:no-empty-catch-clause + } } } } diff --git a/packages/database/src/QueryEventDispatcher.php b/packages/database/src/QueryEventDispatcher.php deleted file mode 100644 index 4fc1f3b41..000000000 --- a/packages/database/src/QueryEventDispatcher.php +++ /dev/null @@ -1,23 +0,0 @@ -eventBus->dispatch($event); - } catch (Throwable) { // @mago-expect lint:no-empty-catch-clause - } - } -} diff --git a/packages/database/tests/GenericDatabaseTest.php b/packages/database/tests/GenericDatabaseTest.php index 8f9d54f57..38b5934da 100644 --- a/packages/database/tests/GenericDatabaseTest.php +++ b/packages/database/tests/GenericDatabaseTest.php @@ -9,7 +9,6 @@ use Tempest\Container\GenericContainer; use Tempest\Database\Connection\Connection; use Tempest\Database\GenericDatabase; -use Tempest\Database\QueryEventDispatcher; use Tempest\Database\Transactions\GenericTransactionManager; use Tempest\EventBus\EventBusConfig; use Tempest\EventBus\GenericEventBus; @@ -42,7 +41,7 @@ public function test_it_executes_transactions(): void $connection, new GenericTransactionManager($connection), new SerializerFactory(new GenericContainer()), - new QueryEventDispatcher($eventBus), + $eventBus, ); $result = $database->withinTransaction(function () { @@ -73,7 +72,7 @@ public function test_it_rolls_back_transactions_on_failure(): void $connection, new GenericTransactionManager($connection), new SerializerFactory(new GenericContainer()), - new QueryEventDispatcher($eventBus), + $eventBus, ); $result = $database->withinTransaction(function (): never { diff --git a/packages/database/tests/QueryAnalyzerTest.php b/packages/database/tests/QueryAnalyzerTest.php index 31963a558..216e7fb4a 100644 --- a/packages/database/tests/QueryAnalyzerTest.php +++ b/packages/database/tests/QueryAnalyzerTest.php @@ -13,7 +13,6 @@ use Tempest\Database\GenericDatabase; use Tempest\Database\Query; use Tempest\Database\QueryAnalyzer; -use Tempest\Database\QueryEventDispatcher; use Tempest\Database\QueryExecuted; use Tempest\Database\Transactions\GenericTransactionManager; use Tempest\EventBus\EventBusConfig; @@ -48,7 +47,7 @@ protected function setUp(): void $connection, new GenericTransactionManager($connection), new SerializerFactory(new GenericContainer()), - new QueryEventDispatcher($this->eventBus), + $this->eventBus, ); $container = new GenericContainer(); diff --git a/packages/database/tests/QueryExecutedTest.php b/packages/database/tests/QueryExecutedTest.php index 4768dcb6d..82daf21a1 100644 --- a/packages/database/tests/QueryExecutedTest.php +++ b/packages/database/tests/QueryExecutedTest.php @@ -13,7 +13,6 @@ use Tempest\Database\Exceptions\QueryWasInvalid; use Tempest\Database\GenericDatabase; use Tempest\Database\Query; -use Tempest\Database\QueryEventDispatcher; use Tempest\Database\QueryExecuted; use Tempest\Database\Transactions\GenericTransactionManager; use Tempest\EventBus\EventBusConfig; @@ -53,7 +52,7 @@ private function createDatabase(FakeEventBus $eventBus, ?string $tag = null): Ge $connection, new GenericTransactionManager($connection), new SerializerFactory(new GenericContainer()), - new QueryEventDispatcher($eventBus), + $eventBus, ); $container = new GenericContainer(); diff --git a/tests/Integration/TestingDatabaseInitializer.php b/tests/Integration/TestingDatabaseInitializer.php index 5575c6be6..55cfb49ef 100644 --- a/tests/Integration/TestingDatabaseInitializer.php +++ b/tests/Integration/TestingDatabaseInitializer.php @@ -12,8 +12,8 @@ use Tempest\Database\Connection\PDOConnection; use Tempest\Database\Database; use Tempest\Database\GenericDatabase; -use Tempest\Database\QueryEventDispatcher; use Tempest\Database\Transactions\GenericTransactionManager; +use Tempest\EventBus\EventBus; use Tempest\Mapper\SerializerFactory; use Tempest\Reflection\ClassReflector; use Tempest\Support\Str; @@ -55,7 +55,7 @@ public function initialize(ClassReflector $class, null|string|UnitEnum $tag, Con $connection, new GenericTransactionManager($connection), $container->get(SerializerFactory::class), - $container->get(QueryEventDispatcher::class), + $container->get(EventBus::class), ); } }