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..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,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 @@ -46,17 +48,13 @@ public function execute(BuildsQuery|Query $query): void $query = $query->build(); } - $bindings = $this->resolveBindings($query); - - try { - $statement = $this->connection->prepare($query->compile()->toString()); + $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; - } catch (PDOException $pdoException) { - throw new QueryWasInvalid($query, $bindings, $pdoException); - } + }); } public function getLastInsertId(): ?PrimaryKey @@ -90,16 +88,12 @@ public function fetch(BuildsQuery|Query $query): array $query = $query->build(); } - $bindings = $this->resolveBindings($query); - - try { - $pdoQuery = $this->connection->prepare($query->compile()->toString()); + return $this->runQuery($query, function (string $sql, array $bindings): array { + $pdoQuery = $this->connection->prepare($sql); $pdoQuery->execute($bindings); return $pdoQuery->fetchAll(PDO::FETCH_NAMED); - } catch (PDOException $pdoException) { - throw new QueryWasInvalid($query, $bindings, $pdoException); - } + }); } public function fetchFirst(BuildsQuery|Query $query): ?array @@ -158,4 +152,33 @@ 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 { + 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/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/src/QueryExecuted.php b/packages/database/src/QueryExecuted.php new file mode 100644 index 000000000..2f1427ddc --- /dev/null +++ b/packages/database/src/QueryExecuted.php @@ -0,0 +1,50 @@ + 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 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'; + } +} 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/QueryAnalyzerTest.php b/packages/database/tests/QueryAnalyzerTest.php new file mode 100644 index 000000000..216e7fb4a --- /dev/null +++ b/packages/database/tests/QueryAnalyzerTest.php @@ -0,0 +1,199 @@ +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()), + $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()); + } +} diff --git a/packages/database/tests/QueryExecutedTest.php b/packages/database/tests/QueryExecutedTest.php new file mode 100644 index 000000000..82daf21a1 --- /dev/null +++ b/packages/database/tests/QueryExecutedTest.php @@ -0,0 +1,226 @@ +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); + } + + #[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); + } +} 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 @@ +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/Database/QueryExecutedTest.php b/tests/Integration/Database/QueryExecutedTest.php new file mode 100644 index 000000000..7ff1d2035 --- /dev/null +++ b/tests/Integration/Database/QueryExecutedTest.php @@ -0,0 +1,140 @@ +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), + ); + } +} 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), ); } }