diff --git a/flight/Engine.php b/flight/Engine.php index f75f42a..c386424 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -30,6 +30,9 @@ use flight\net\Route; * @method void stop() Stops framework and outputs current response * @method void halt(int $code = 200, string $message = '', bool $actuallyExit = true) Stops processing and returns a given response. * + * # Class registration + * @method EventDispatcher eventDispatcher() Gets event dispatcher + * * # Routing * @method Route route(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') * Routes a URL to a callback function with all applicable methods @@ -110,7 +113,6 @@ class Engine { $this->loader = new Loader(); $this->dispatcher = new Dispatcher(); - $this->eventDispatcher = new EventDispatcher(); $this->init(); } @@ -160,6 +162,9 @@ class Engine $this->dispatcher->setEngine($this); // Register default components + $this->map('eventDispatcher', function () { + return EventDispatcher::getInstance(); + }); $this->loader->register('request', Request::class); $this->loader->register('response', Response::class); $this->loader->register('router', Router::class); @@ -460,7 +465,9 @@ class Engine // Here is the array callable $middlewareObject that we created earlier. // It looks bizarre but it's really calling [ $class, $method ]($params) // Which loosely translates to $class->$method($params) + $start = microtime(true); $middlewareResult = $middlewareObject($params); + $this->triggerEvent('flight.middleware.executed', $route, $middleware, microtime(true) - $start); if ($useV3OutputBuffering === true) { $this->response()->write(ob_get_clean()); @@ -573,12 +580,12 @@ class Engine } // Call route handler + $routeStart = microtime(true); $continue = $this->dispatcher->execute( $route->callback, $params ); - $this->triggerEvent('flight.route.executed', $route); - + $this->triggerEvent('flight.route.executed', $route, microtime(true) - $routeStart); if ($useV3OutputBuffering === true) { $response->write(ob_get_clean()); } @@ -631,6 +638,7 @@ class Engine */ public function _error(Throwable $e): void { + $this->triggerEvent('flight.error', $e); $msg = sprintf( <<<'HTML' <h1>500 Internal Server Error</h1> @@ -678,8 +686,6 @@ class Engine } $response->send(); - - $this->triggerEvent('flight.response.sent', $response); } } @@ -831,6 +837,8 @@ class Engine $url = $base . preg_replace('#/+#', '/', '/' . $url); } + $this->triggerEvent('flight.redirect', $url, $code); + $this->response() ->clearBody() ->status($code) @@ -854,7 +862,9 @@ class Engine return; } + $start = microtime(true); $this->view()->render($file, $data); + $this->triggerEvent('flight.view.rendered', $file, microtime(true) - $start); } /** @@ -1019,7 +1029,7 @@ class Engine */ public function _onEvent(string $eventName, callable $callback): void { - $this->eventDispatcher->on($eventName, $callback); + $this->eventDispatcher()->on($eventName, $callback); } /** @@ -1030,6 +1040,6 @@ class Engine */ public function _triggerEvent(string $eventName, ...$args): void { - $this->eventDispatcher->trigger($eventName, ...$args); + $this->eventDispatcher()->trigger($eventName, ...$args); } } diff --git a/flight/Flight.php b/flight/Flight.php index fb35427..7e43877 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -8,6 +8,7 @@ use flight\net\Response; use flight\net\Router; use flight\template\View; use flight\net\Route; +use flight\core\EventDispatcher; require_once __DIR__ . '/autoload.php'; @@ -29,6 +30,9 @@ require_once __DIR__ . '/autoload.php'; * Unregisters a class to a framework method. * @method static void registerContainerHandler(callable|object $containerHandler) Registers a container handler. * + * # Class registration + * @method EventDispatcher eventDispatcher() Gets event dispatcher + * * # Routing * @method static Route route(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') * Maps a URL pattern to a callback with all applicable methods. diff --git a/flight/core/EventDispatcher.php b/flight/core/EventDispatcher.php index b80eb42..ea809b2 100644 --- a/flight/core/EventDispatcher.php +++ b/flight/core/EventDispatcher.php @@ -6,9 +6,25 @@ namespace flight\core; class EventDispatcher { + /** @var self|null Singleton instance of the EventDispatcher */ + private static ?self $instance = null; + /** @var array<string, array<int, callable>> */ protected array $listeners = []; + /** + * Singleton instance of the EventDispatcher. + * + * @return self + */ + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self(); + } + return self::$instance; + } + /** * Register a callback for an event. * @@ -42,4 +58,80 @@ class EventDispatcher } } } + + /** + * Check if an event has any registered listeners. + * + * @param string $event Event name + * + * @return bool True if the event has listeners, false otherwise + */ + public function hasListeners(string $event): bool + { + return isset($this->listeners[$event]) === true && count($this->listeners[$event]) > 0; + } + + /** + * Get all listeners registered for a specific event. + * + * @param string $event Event name + * + * @return array<int, callable> Array of callbacks registered for the event + */ + public function getListeners(string $event): array + { + return $this->listeners[$event] ?? []; + } + + /** + * Get a list of all events that have registered listeners. + * + * @return array<int, string> Array of event names + */ + public function getAllRegisteredEvents(): array + { + return array_keys($this->listeners); + } + + /** + * Remove a specific listener for an event. + * + * @param string $event the event name + * @param callable $callback the exact callback to remove + * + * @return void + */ + public function removeListener(string $event, callable $callback): void + { + if (isset($this->listeners[$event]) === true && count($this->listeners[$event]) > 0) { + $this->listeners[$event] = array_filter($this->listeners[$event], function ($listener) use ($callback) { + return $listener !== $callback; + }); + $this->listeners[$event] = array_values($this->listeners[$event]); // Re-index the array + } + } + + /** + * Remove all listeners for a specific event. + * + * @param string $event the event name + * + * @return void + */ + public function removeAllListeners(string $event): void + { + if (isset($this->listeners[$event]) === true) { + unset($this->listeners[$event]); + } + } + + /** + * Remove the current singleton instance of the EventDispatcher. + * + * @return void + */ + public static function resetInstance(): void + { + self::$instance = null; + } } diff --git a/flight/database/PdoWrapper.php b/flight/database/PdoWrapper.php index 297121a..7cf74cf 100644 --- a/flight/database/PdoWrapper.php +++ b/flight/database/PdoWrapper.php @@ -4,12 +4,40 @@ declare(strict_types=1); namespace flight\database; +use flight\core\EventDispatcher; use flight\util\Collection; use PDO; use PDOStatement; class PdoWrapper extends PDO { + /** @var bool $trackApmQueries Whether to track application performance metrics (APM) for queries. */ + protected bool $trackApmQueries = false; + + /** @var array<int,array<string,mixed>> $queryMetrics Metrics related to the database connection. */ + protected array $queryMetrics = []; + + /** @var array<string,string> $connectionMetrics Metrics related to the database connection. */ + protected array $connectionMetrics = []; + + /** + * Constructor for the PdoWrapper class. + * + * @param string $dsn The Data Source Name (DSN) for the database connection. + * @param string|null $username The username for the database connection. + * @param string|null $password The password for the database connection. + * @param array<string, mixed>|null $options An array of options for the PDO connection. + * @param bool $trackApmQueries Whether to track application performance metrics (APM) for queries. + */ + public function __construct(?string $dsn = null, ?string $username = '', ?string $password = '', ?array $options = null, bool $trackApmQueries = false) + { + parent::__construct($dsn, $username, $password, $options); + $this->trackApmQueries = $trackApmQueries; + if ($this->trackApmQueries === true) { + $this->connectionMetrics = $this->pullDataFromDsn($dsn); + } + } + /** * Use this for INSERTS, UPDATES, or if you plan on using a SELECT in a while loop * @@ -31,8 +59,19 @@ class PdoWrapper extends PDO $processed_sql_data = $this->processInStatementSql($sql, $params); $sql = $processed_sql_data['sql']; $params = $processed_sql_data['params']; + $start = $this->trackApmQueries === true ? microtime(true) : 0; + $memory_start = $this->trackApmQueries === true ? memory_get_usage() : 0; $statement = $this->prepare($sql); $statement->execute($params); + if ($this->trackApmQueries === true) { + $this->queryMetrics[] = [ + 'sql' => $sql, + 'params' => $params, + 'execution_time' => microtime(true) - $start, + 'row_count' => $statement->rowCount(), + 'memory_usage' => memory_get_usage() - $memory_start + ]; + } return $statement; } @@ -88,9 +127,20 @@ class PdoWrapper extends PDO $processed_sql_data = $this->processInStatementSql($sql, $params); $sql = $processed_sql_data['sql']; $params = $processed_sql_data['params']; + $start = $this->trackApmQueries === true ? microtime(true) : 0; + $memory_start = $this->trackApmQueries === true ? memory_get_usage() : 0; $statement = $this->prepare($sql); $statement->execute($params); $results = $statement->fetchAll(); + if ($this->trackApmQueries === true) { + $this->queryMetrics[] = [ + 'sql' => $sql, + 'params' => $params, + 'execution_time' => microtime(true) - $start, + 'row_count' => $statement->rowCount(), + 'memory_usage' => memory_get_usage() - $memory_start + ]; + } if (is_array($results) === true && count($results) > 0) { foreach ($results as &$result) { $result = new Collection($result); @@ -101,6 +151,56 @@ class PdoWrapper extends PDO return $results; } + /** + * Pulls the engine, database, and host from the DSN string. + * + * @param string $dsn The Data Source Name (DSN) string. + * + * @return array<string,string> An associative array containing the engine, database, and host. + */ + protected function pullDataFromDsn(string $dsn): array + { + // pull the engine from the dsn (sqlite, mysql, pgsql, etc) + preg_match('/^([a-zA-Z]+):/', $dsn, $matches); + $engine = $matches[1] ?? 'unknown'; + + if ($engine === 'sqlite') { + // pull the path from the dsn + preg_match('/sqlite:(.*)/', $dsn, $matches); + $dbname = basename($matches[1] ?? 'unknown'); + $host = 'localhost'; + } else { + // pull the database from the dsn + preg_match('/dbname=([^;]+)/', $dsn, $matches); + $dbname = $matches[1] ?? 'unknown'; + // pull the host from the dsn + preg_match('/host=([^;]+)/', $dsn, $matches); + $host = $matches[1] ?? 'unknown'; + } + + return [ + 'engine' => $engine, + 'database' => $dbname, + 'host' => $host + ]; + } + + /** + * Logs the executed queries through the event dispatcher. + * + * This method enables logging of all the queries executed by the PDO wrapper. + * It can be useful for debugging and monitoring purposes. + * + * @return void + */ + public function logQueries(): void + { + if ($this->trackApmQueries === true && $this->connectionMetrics !== [] && $this->queryMetrics !== []) { + EventDispatcher::getInstance()->trigger('flight.db.queries', $this->connectionMetrics, $this->queryMetrics); + $this->queryMetrics = []; // Reset after logging + } + } + /** * Don't worry about this guy. Converts stuff for IN statements * diff --git a/flight/net/Response.php b/flight/net/Response.php index 9e4d58b..f9ee9a2 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace flight\net; use Exception; +use flight\core\EventDispatcher; /** * The Response class represents an HTTP response. The object @@ -426,6 +427,7 @@ class Response } } + $start = microtime(true); // Only for the v3 output buffering. if ($this->v2_output_buffering === false) { $this->processResponseCallbacks(); @@ -436,8 +438,9 @@ class Response } echo $this->body; - $this->sent = true; + + EventDispatcher::getInstance()->trigger('flight.response.sent', $this, microtime(true) - $start); } /** diff --git a/tests/DispatcherTest.php b/tests/DispatcherTest.php index 418897d..6873aa6 100644 --- a/tests/DispatcherTest.php +++ b/tests/DispatcherTest.php @@ -13,6 +13,7 @@ use InvalidArgumentException; use PharIo\Manifest\InvalidEmailException; use tests\classes\Hello; use PHPUnit\Framework\TestCase; +use tests\classes\ClassWithExceptionInConstruct; use tests\classes\ContainerDefault; use tests\classes\TesterClass; use TypeError; @@ -329,4 +330,17 @@ class DispatcherTest extends TestCase $this->expectExceptionMessageMatches('#tests\\\\classes\\\\ContainerDefault::__construct\(\).+flight\\\\Engine, null given#'); $result = $this->dispatcher->execute([ContainerDefault::class, 'testTheContainer']); } + + public function testContainerDicePdoWrapperTestBadParams() + { + $dice = new \Dice\Dice(); + $this->dispatcher->setContainerHandler(function ($class, $params) use ($dice) { + return $dice->create($class, $params); + }); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('This is an exception in the constructor'); + + $this->dispatcher->invokeCallable([ ClassWithExceptionInConstruct::class, '__construct' ]); + } } diff --git a/tests/EngineTest.php b/tests/EngineTest.php index fc2e8ff..737f38d 100644 --- a/tests/EngineTest.php +++ b/tests/EngineTest.php @@ -845,31 +845,6 @@ class EngineTest extends TestCase $this->expectOutputString('You got it boss!'); } - public function testContainerDicePdoWrapperTestBadParams() { - $engine = new Engine(); - $dice = new \Dice\Dice(); - $engine->registerContainerHandler(function ($class, $params) use ($dice) { - return $dice->create($class, $params); - }); - - $engine->route('/container', Container::class.'->testThePdoWrapper'); - $engine->request()->url = '/container'; - - // php 7.4 will throw a PDO exception, but php 8 will throw an ErrorException - if(version_compare(PHP_VERSION, '8.1.0') >= 0) { - $this->expectException(ErrorException::class); - $this->expectExceptionMessageMatches("/Passing null to parameter/"); - } elseif(version_compare(PHP_VERSION, '8.0.0') >= 0) { - $this->expectException(PDOException::class); - $this->expectExceptionMessageMatches("/must be a valid data source name/"); - } else { - $this->expectException(PDOException::class); - $this->expectExceptionMessageMatches("/invalid data source name/"); - } - - $engine->start(); - } - public function testContainerDiceBadClass() { $engine = new Engine(); $dice = new \Dice\Dice(); diff --git a/tests/EventSystemTest.php b/tests/EventSystemTest.php index 6dba2c7..f95302a 100644 --- a/tests/EventSystemTest.php +++ b/tests/EventSystemTest.php @@ -16,6 +16,7 @@ class EventSystemTest extends TestCase // Reset the Flight engine before each test to ensure a clean state Flight::setEngine(new Engine()); Flight::app()->init(); + Flight::eventDispatcher()->resetInstance(); // Clear any existing listeners } /** @@ -224,4 +225,124 @@ class EventSystemTest extends TestCase $this->assertTrue($secondCalled, 'Second listener should be called'); $this->assertFalse($thirdCalled, 'Third listener should not be called after propagation stopped'); } + + /** + * Test that hasListeners() correctly identifies events with listeners. + */ + public function testHasListeners() + { + $this->assertFalse(Flight::eventDispatcher()->hasListeners('test.event'), 'Event should not have listeners before registration'); + + Flight::onEvent('test.event', function () { + }); + + $this->assertTrue(Flight::eventDispatcher()->hasListeners('test.event'), 'Event should have listeners after registration'); + } + + /** + * Test that getListeners() returns the correct listeners for an event. + */ + public function testGetListeners() + { + $callback1 = function () { + }; + $callback2 = function () { + }; + + $this->assertEmpty(Flight::eventDispatcher()->getListeners('test.event'), 'Event should have no listeners before registration'); + + Flight::onEvent('test.event', $callback1); + Flight::onEvent('test.event', $callback2); + + $listeners = Flight::eventDispatcher()->getListeners('test.event'); + $this->assertCount(2, $listeners, 'Event should have two registered listeners'); + $this->assertSame($callback1, $listeners[0], 'First listener should match the first callback'); + $this->assertSame($callback2, $listeners[1], 'Second listener should match the second callback'); + } + + /** + * Test that getListeners() returns an empty array for events with no listeners. + */ + public function testGetListenersForNonexistentEvent() + { + $listeners = Flight::eventDispatcher()->getListeners('nonexistent.event'); + $this->assertIsArray($listeners, 'Should return an array for nonexistent events'); + $this->assertEmpty($listeners, 'Should return an empty array for nonexistent events'); + } + + /** + * Test that getAllRegisteredEvents() returns all event names with registered listeners. + */ + public function testGetAllRegisteredEvents() + { + $this->assertEmpty(Flight::eventDispatcher()->getAllRegisteredEvents(), 'No events should be registered initially'); + + Flight::onEvent('test.event1', function () { + }); + Flight::onEvent('test.event2', function () { + }); + + $events = Flight::eventDispatcher()->getAllRegisteredEvents(); + $this->assertCount(2, $events, 'Should return all registered event names'); + $this->assertContains('test.event1', $events, 'Should contain the first event'); + $this->assertContains('test.event2', $events, 'Should contain the second event'); + } + + /** + * Test that removeListener() correctly removes a specific listener from an event. + */ + public function testRemoveListener() + { + $callback1 = function () { + return 'callback1'; + }; + $callback2 = function () { + return 'callback2'; + }; + + Flight::onEvent('test.event', $callback1); + Flight::onEvent('test.event', $callback2); + + $this->assertCount(2, Flight::eventDispatcher()->getListeners('test.event'), 'Event should have two listeners initially'); + + Flight::eventDispatcher()->removeListener('test.event', $callback1); + + $listeners = Flight::eventDispatcher()->getListeners('test.event'); + $this->assertCount(1, $listeners, 'Event should have one listener after removal'); + $this->assertSame($callback2, $listeners[0], 'Remaining listener should be the second callback'); + } + + /** + * Test that removeAllListeners() correctly removes all listeners for an event. + */ + public function testRemoveAllListeners() + { + Flight::onEvent('test.event', function () { + }); + Flight::onEvent('test.event', function () { + }); + Flight::onEvent('another.event', function () { + }); + + $this->assertTrue(Flight::eventDispatcher()->hasListeners('test.event'), 'Event should have listeners before removal'); + $this->assertTrue(Flight::eventDispatcher()->hasListeners('another.event'), 'Another event should have listeners'); + + Flight::eventDispatcher()->removeAllListeners('test.event'); + + $this->assertFalse(Flight::eventDispatcher()->hasListeners('test.event'), 'Event should have no listeners after removal'); + $this->assertTrue(Flight::eventDispatcher()->hasListeners('another.event'), 'Another event should still have listeners'); + } + + /** + * Test that trying to remove listeners for nonexistent events doesn't cause errors. + */ + public function testRemoveListenersForNonexistentEvent() + { + // Should not throw any errors + Flight::eventDispatcher()->removeListener('nonexistent.event', function () { + }); + Flight::eventDispatcher()->removeAllListeners('nonexistent.event'); + + $this->assertTrue(true, 'Removing listeners for nonexistent events should not throw errors'); + } } diff --git a/tests/PdoWrapperTest.php b/tests/PdoWrapperTest.php index 0f41a92..4e21a1b 100644 --- a/tests/PdoWrapperTest.php +++ b/tests/PdoWrapperTest.php @@ -5,8 +5,10 @@ declare(strict_types=1); namespace tests; use flight\database\PdoWrapper; +use flight\core\EventDispatcher; use PDOStatement; use PHPUnit\Framework\TestCase; +use ReflectionClass; class PdoWrapperTest extends TestCase { @@ -120,4 +122,90 @@ class PdoWrapperTest extends TestCase $rows = $this->pdo_wrapper->fetchAll('SELECT id FROM test WHERE id > ? AND name IN( ?) ', [ 0, 'one,two' ]); $this->assertEquals(2, count($rows)); } + + public function testPullDataFromDsn() + { + // Testing protected method using reflection + $reflection = new ReflectionClass($this->pdo_wrapper); + $method = $reflection->getMethod('pullDataFromDsn'); + $method->setAccessible(true); + + // Test SQLite DSN + $sqliteDsn = 'sqlite::memory:'; + $sqliteResult = $method->invoke($this->pdo_wrapper, $sqliteDsn); + $this->assertEquals([ + 'engine' => 'sqlite', + 'database' => ':memory:', + 'host' => 'localhost' + ], $sqliteResult); + + // Test MySQL DSN + $mysqlDsn = 'mysql:host=localhost;dbname=testdb;charset=utf8'; + $mysqlResult = $method->invoke($this->pdo_wrapper, $mysqlDsn); + $this->assertEquals([ + 'engine' => 'mysql', + 'database' => 'testdb', + 'host' => 'localhost' + ], $mysqlResult); + + // Test PostgreSQL DSN + $pgsqlDsn = 'pgsql:host=127.0.0.1;dbname=postgres'; + $pgsqlResult = $method->invoke($this->pdo_wrapper, $pgsqlDsn); + $this->assertEquals([ + 'engine' => 'pgsql', + 'database' => 'postgres', + 'host' => '127.0.0.1' + ], $pgsqlResult); + } + + public function testLogQueries() + { + // Create a new PdoWrapper with tracking enabled + $trackingPdo = new PdoWrapper('sqlite::memory:', null, null, null, true); + + // Create test table + $trackingPdo->exec('CREATE TABLE test_log (id INTEGER PRIMARY KEY, name TEXT)'); + + // Run some queries to populate metrics + $trackingPdo->runQuery('INSERT INTO test_log (name) VALUES (?)', ['test1']); + $trackingPdo->fetchAll('SELECT * FROM test_log'); + + // Setup event listener to capture triggered event + $eventTriggered = false; + $connectionData = null; + $queriesData = null; + + $dispatcher = EventDispatcher::getInstance(); + $dispatcher->on('flight.db.queries', function ($conn, $queries) use (&$eventTriggered, &$connectionData, &$queriesData) { + $eventTriggered = true; + $connectionData = $conn; + $queriesData = $queries; + }); + + // Call the logQueries method + $trackingPdo->logQueries(); + + // Assert that event was triggered + $this->assertTrue($eventTriggered); + $this->assertIsArray($connectionData); + $this->assertEquals('sqlite', $connectionData['engine']); + $this->assertIsArray($queriesData); + $this->assertCount(2, $queriesData); // Should have 2 queries (INSERT and SELECT) + + // Verify query metrics structure for the first query + $this->assertArrayHasKey('sql', $queriesData[0]); + $this->assertArrayHasKey('params', $queriesData[0]); + $this->assertArrayHasKey('execution_time', $queriesData[0]); + $this->assertArrayHasKey('row_count', $queriesData[0]); + $this->assertArrayHasKey('memory_usage', $queriesData[0]); + + // Clean up + $trackingPdo->exec('DROP TABLE test_log'); + + // Verify metrics are reset after logging + $reflection = new ReflectionClass($trackingPdo); + $property = $reflection->getProperty('queryMetrics'); + $property->setAccessible(true); + $this->assertCount(0, $property->getValue($trackingPdo)); + } } diff --git a/tests/classes/ClassWithExceptionInConstruct.php b/tests/classes/ClassWithExceptionInConstruct.php new file mode 100644 index 0000000..32456c2 --- /dev/null +++ b/tests/classes/ClassWithExceptionInConstruct.php @@ -0,0 +1,13 @@ +<?php + +declare(strict_types=1); + +namespace tests\classes; + +class ClassWithExceptionInConstruct +{ + public function __construct() + { + throw new \Exception('This is an exception in the constructor'); + } +}