diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5297a12..a3ac5e9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -11,7 +11,7 @@ on: env: # Phalcon v5 C extension constraint (installed from source via PIE) PHALCON_CONSTRAINT: "^5.0" - EXTENSIONS: "fileinfo, json, mbstring, pdo, xml" + EXTENSIONS: "fileinfo, json, mbstring, pdo, pdo_sqlite, xml" permissions: { } diff --git a/CHANGELOG.md b/CHANGELOG.md index 7456532..cf5f78d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to `phalcon/debugbar` are documented here. The format is bas - `Core` boots the bar with no MVC application and no DI container. [#40](https://github.com/phalcon/debugbar/issues/40) - `ADR\Provider` boots the bar in `Phalcon\ADR` applications (Phalcon `v5.18.0` or later, or Phalcon 6). [#14](https://github.com/phalcon/debugbar/issues/14) - The database, view, cache and route collectors take data through public methods, with no events manager. [#44](https://github.com/phalcon/debugbar/issues/44) +- The Database panel shows `Phalcon\DataMapper` queries in ADR applications (Phalcon `v5.19.0` or later, or Phalcon 6). [#16](https://github.com/phalcon/debugbar/issues/16) ### Fixed diff --git a/docs/index.md b/docs/index.md index d3efc45..beeb18e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,7 +10,7 @@ The bar runs only in development. It refuses to boot when the environment is und ## Requirements - PHP 8.1 or later. -- A Phalcon runtime: the Phalcon 5 C extension, or the Phalcon 6 PHP implementation (`phalcon/phalcon`). ADR support needs Phalcon `v5.18.0` or later, or Phalcon 6. +- A Phalcon runtime: the Phalcon 5 C extension, or the Phalcon 6 PHP implementation (`phalcon/phalcon`). ADR support needs Phalcon `v5.18.0` or later, or Phalcon 6. The Database panel in an ADR application needs Phalcon `v5.19.0` or later, or Phalcon 6. - `matthiasmullie/minify`, pulled in automatically by Composer. It minifies the bar's CSS and JavaScript before they are inlined. ## Installation @@ -91,11 +91,32 @@ The provider reads the events manager (`Phalcon\Contracts\Events\Manager`) and t Register the provider after all other providers. It resolves the events manager and the request during registration. After that, `extend()` on these services throws `CannotExtendResolved`, and `set()` has no effect. In a blocked environment, the provider resolves no service. +The `database` panel reads the `dm:*` events of `Phalcon\DataMapper\Pdo\Connection`. It needs Phalcon `v5.19.0` or later, or Phalcon 6. A connection fires these events only when it has an events manager. In `registerProviders()`, give the connection the events manager from the container: + +```php +set( + Connection::class, + function () use ($container): Connection { + $connection = new Connection('mysql:host=localhost;dbname=app', 'user', 'secret'); + $connection->setEventsManager($container->get(EventsManager::class)); + + return $connection; + } +); +``` + +The panel shows the values that you give to `perform()` and to the `fetch*()` methods. `exec()` and `query()` take no values, so the panel shows only their statements. + These limits apply in an ADR application: - **One request for each process.** `Phalcon\ADR\Application::handle()` supports a reused request in a long-lived worker, but collectors have no reset method. In a worker, their data grows from request to request. `HistoryCookie::fromGlobals()` also reads only at boot. - **Events manager.** The events manager must implement `Phalcon\Events\ManagerInterface`. The default manager does. A manager that implements only `Phalcon\Contracts\Events\Manager` gets no listener, so the bar is not injected. -- **Empty panels.** The `database`, `route` and `exceptions` panels have no ADR source yet. The `view` and `cache` panels get data only when the application gives the events manager to these services. The provider reads no `config` service, so the `config` panel is empty. +- **Empty panels.** The `route` and `exceptions` panels have no ADR source yet. The `database`, `view` and `cache` panels get data only when the application gives the events manager to these services. The provider reads no `config` service, so the `config` panel is empty. - **History.** The provider does not read the `history` keys. Request history works only in MVC. ### A Host with No Events Manager diff --git a/src/DebugBar/ADR/Bridge.php b/src/DebugBar/ADR/Bridge.php new file mode 100644 index 0000000..b1f9d9d --- /dev/null +++ b/src/DebugBar/ADR/Bridge.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\DebugBar\ADR; + +use Phalcon\DebugBar\Collector\DatabaseCollector; +use Phalcon\DebugBar\DebugBar; +use Phalcon\Events\EventInterface; +use Phalcon\Events\ManagerInterface; + +use function is_array; +use function is_string; + +/** + * Feeds the collectors from the events of an ADR application. The event names are + * strings because `Phalcon\DataMapper\Pdo\Events` is not in versions before + * `v5.19.0`. A `before` listener must not return `false`: this cancels the operation. + */ +final class Bridge +{ + private const AFTER_QUERY_EVENTS = ['dm:afterExec', 'dm:afterPerform', 'dm:afterQuery']; + + private const BEFORE_QUERY_EVENTS = ['dm:beforeExec', 'dm:beforePerform', 'dm:beforeQuery']; + + public function __construct(private readonly DebugBar $bar) + { + } + + public function subscribe(ManagerInterface $eventsManager): void + { + $collector = $this->bar->getCollectors()[DatabaseCollector::NAME] ?? null; + if (!$collector instanceof DatabaseCollector) { + return; + } + + foreach (self::BEFORE_QUERY_EVENTS as $eventName) { + $eventsManager->attach( + $eventName, + function () use ($collector): void { + $collector->startQuery(); + } + ); + } + + foreach (self::AFTER_QUERY_EVENTS as $eventName) { + $eventsManager->attach( + $eventName, + function (EventInterface $event, mixed $connection, mixed $data) use ($collector): void { + if (!is_array($data) || !is_string($data['statement'] ?? null)) { + return; + } + + $values = $data['values'] ?? []; + $collector->finishQuery($data['statement'], is_array($values) ? $values : []); + } + ); + } + } +} diff --git a/src/DebugBar/ADR/Provider.php b/src/DebugBar/ADR/Provider.php index 3c8a480..5ff9d8f 100644 --- a/src/DebugBar/ADR/Provider.php +++ b/src/DebugBar/ADR/Provider.php @@ -18,6 +18,7 @@ use Phalcon\Contracts\Events\Manager as ManagerContract; use Phalcon\Contracts\Http\AttributeRequest; use Phalcon\DebugBar\Core; +use Phalcon\DebugBar\Debug; use Phalcon\DebugBar\DebugBarTypes; use Phalcon\DebugBar\Exceptions\CannotUseInProduction; use Phalcon\Events\ManagerInterface; @@ -27,7 +28,8 @@ * Boots the debug bar in an ADR application. Register it after all other * providers: it resolves the events manager and the request, and the container * cannot change a service after that. The ADR application emits the response - * after `application:afterHandle`, so the bar is injected on this event. + * after `application:afterHandle`, so the bar is injected on this event. `Bridge` + * feeds the collectors from the other events of the application. * * @phpstan-import-type provider_config from DebugBarTypes */ @@ -59,12 +61,14 @@ public function provide(Collection $services): void $eventsManager, $request instanceof RequestInterface ? $request : null ); + $bar = Debug::getBar(); - if (null === $eventsManager || null === $listener) { + if (null === $eventsManager || null === $listener || null === $bar) { return; } $eventsManager->attach('application:afterHandle', $listener); + (new Bridge($bar))->subscribe($eventsManager); } private function resolve(Collection $services, string $name): mixed diff --git a/tests/Unit/DebugBar/ADR/BridgeTest.php b/tests/Unit/DebugBar/ADR/BridgeTest.php new file mode 100644 index 0000000..761575a --- /dev/null +++ b/tests/Unit/DebugBar/ADR/BridgeTest.php @@ -0,0 +1,132 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Unit\DebugBar\ADR; + +use Closure; +use Phalcon\DataMapper\Pdo\Connection; +use Phalcon\DebugBar\ADR\Bridge; +use Phalcon\DebugBar\Collector\DatabaseCollector; +use Phalcon\DebugBar\DebugBar; +use Phalcon\Events\Manager; +use Phalcon\Events\ManagerInterface; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; + +#[RequiresPhpExtension('pdo_sqlite')] +final class BridgeTest extends AbstractUnitTestCase +{ + /** + * @return array + */ + public static function operations(): array + { + return [ + 'exec' => [static fn (Connection $connection): mixed => $connection->exec('SELECT 1')], + 'perform' => [static fn (Connection $connection): mixed => $connection->perform('SELECT 1')], + 'query' => [static fn (Connection $connection): mixed => $connection->query('SELECT 1')], + ]; + } + + public function testAfterEventWithInvalidValuesShowsOnlyTheStatement(): void + { + $manager = new Manager(); + $collector = $this->collector($manager); + + $manager->fire('dm:afterPerform', $this, ['statement' => 'SELECT 1', 'values' => 'invalid']); + + $this->assertSame('SELECT 1', $collector->collect()['panel'][0]['message']); + } + + public function testAfterEventWithNoStatementIsIgnored(): void + { + $manager = new Manager(); + $collector = $this->collector($manager); + + $manager->fire('dm:afterPerform', $this, ['values' => []]); + $manager->fire('dm:afterPerform', $this, ['statement' => 42]); + $manager->fire('dm:afterPerform', $this); + + $this->assertSame(0, $collector->collect()['badge']); + } + + public function testBarWithNoDatabaseCollectorAttachesNothing(): void + { + $manager = new Manager(); + + (new Bridge(new DebugBar()))->subscribe($manager); + + foreach (['Exec', 'Perform', 'Query'] as $operation) { + $this->assertFalse($manager->hasListeners('dm:before' . $operation)); + $this->assertFalse($manager->hasListeners('dm:after' . $operation)); + } + } + + public function testFetchAllRecordsOneQuery(): void + { + $manager = new Manager(); + $collector = $this->collector($manager); + + $this->connection($manager)->fetchAll('SELECT :value AS answer', ['value' => 42]); + + $this->assertSame(1, $collector->collect()['badge']); + } + + public function testPerformShowsTheStatementAndTheBindings(): void + { + $manager = new Manager(); + $collector = $this->collector($manager); + + $this->connection($manager)->perform('SELECT :value', ['value' => 42]); + + $this->assertSame('SELECT :value {"value":42}', $collector->collect()['panel'][0]['message']); + } + + /** + * With no `before` listener the time is the full clock value, not the time of + * the operation. Each case has a new collector, so no earlier start can hide this. + * + * @param Closure(Connection): mixed $operation + */ + #[DataProvider('operations')] + public function testRunsAndRecordsTheOperationWithItsTime(Closure $operation): void + { + $manager = new Manager(); + $collector = $this->collector($manager); + + $operation($this->connection($manager)); + + $row = $collector->collect()['panel'][0]; + + $this->assertSame('SELECT 1', $row['message']); + $this->assertLessThan(60000.0, (float) $row['label']); + } + + private function collector(ManagerInterface $manager): DatabaseCollector + { + $collector = new DatabaseCollector(); + + (new Bridge((new DebugBar())->addCollector($collector)))->subscribe($manager); + + return $collector; + } + + private function connection(ManagerInterface $manager): Connection + { + $connection = new Connection('sqlite::memory:'); + $connection->setEventsManager($manager); + + return $connection; + } +} diff --git a/tests/Unit/DebugBar/ADR/ProviderTest.php b/tests/Unit/DebugBar/ADR/ProviderTest.php index a60ce47..c3039e1 100644 --- a/tests/Unit/DebugBar/ADR/ProviderTest.php +++ b/tests/Unit/DebugBar/ADR/ProviderTest.php @@ -26,9 +26,11 @@ use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; use Phalcon\Tests\Support\DebugBar\ADR\Action\Data\GetData; use Phalcon\Tests\Support\DebugBar\ADR\Action\Page\GetPage; +use Phalcon\Tests\Support\DebugBar\ADR\Action\Queries\GetQueries; use Phalcon\Tests\Support\DebugBar\ADR\Front; use Phalcon\Tests\Support\DebugBar\ADR\RecordingEmitter; use PHPUnit\Framework\Attributes\BackupGlobals; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; use function is_array; @@ -155,6 +157,24 @@ function () use ($manager): ManagerContract { $this->assertInstanceOf(DebugBar::class, Debug::getBar()); } + #[RequiresPhpExtension('pdo_sqlite')] + public function testQueryReachesTheDatabasePanel(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + + $response = $this->dispatch('/queries'); + + $database = $this->bootedBar()->collect()['data']['database']; + $panel = $database['panel']; + if (!is_array($panel) || !is_array($panel[0] ?? null)) { + $this->fail('The database panel should have a row.'); + } + + $this->assertSame(1, $database['badge']); + $this->assertSame(GetQueries::STATEMENT . ' {"value":42}', $panel[0]['message']); + $this->assertStringContainsString(GetQueries::STATEMENT, $response->getContent()); + } + public function testRequestReachesTheCollector(): void { $_ENV[self::ENV_VAR] = 'dev'; diff --git a/tests/support/DebugBar/ADR/Action/Queries/GetQueries.php b/tests/support/DebugBar/ADR/Action/Queries/GetQueries.php new file mode 100644 index 0000000..c25eb75 --- /dev/null +++ b/tests/support/DebugBar/ADR/Action/Queries/GetQueries.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Support\DebugBar\ADR\Action\Queries; + +use Phalcon\Contracts\ADR\Action; +use Phalcon\Contracts\Http\AttributeRequest; +use Phalcon\DataMapper\Pdo\Connection; +use Phalcon\Http\Response; +use Phalcon\Http\ResponseInterface; + +/** + * Runs a query and returns an HTML response for `GET /queries`. + */ +final class GetQueries implements Action +{ + public const STATEMENT = 'SELECT :value AS answer'; + + public function __construct(private readonly Connection $connection) + { + } + + public function __invoke(AttributeRequest $request): ResponseInterface + { + $this->connection->fetchAll(self::STATEMENT, ['value' => 42]); + + $response = new Response(); + $response->setContentType('text/html'); + $response->setContent('queries'); + + return $response; + } +} diff --git a/tests/support/DebugBar/ADR/Front.php b/tests/support/DebugBar/ADR/Front.php index 996d80e..57d2df2 100644 --- a/tests/support/DebugBar/ADR/Front.php +++ b/tests/support/DebugBar/ADR/Front.php @@ -18,8 +18,11 @@ use Phalcon\Container\Container; use Phalcon\Contracts\ADR\Application as ApplicationInterface; use Phalcon\Contracts\ADR\Emitter\Emitter; +use Phalcon\Contracts\Events\Manager as ManagerContract; +use Phalcon\DataMapper\Pdo\Connection; use Phalcon\DebugBar\ADR\Provider; use Phalcon\DebugBar\DebugBarTypes; +use Phalcon\Events\ManagerInterface; /** * An ADR front controller that registers the debug bar provider last, as an @@ -58,6 +61,19 @@ function () use ($emitter): Emitter { } ); + $container->set( + Connection::class, + function () use ($container): Connection { + $connection = new Connection('sqlite::memory:'); + $eventsManager = $container->get(ManagerContract::class); + if ($eventsManager instanceof ManagerInterface) { + $connection->setEventsManager($eventsManager); + } + + return $connection; + } + ); + (new Provider($this->config))->provide($container); } }