Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ All notable changes to `phalcon/debugbar` are documented here. The format is bas
- `ResponseListener::handle()` injects the bar with no event. [#39](https://github.com/phalcon/debugbar/issues/39)
- `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)

### Fixed

Expand Down
15 changes: 10 additions & 5 deletions src/DebugBar/Collector/CacheCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
/**
* Records cache operations by subscribing to the `cache:after*` events. The key
* is the event data, so it is read straight off the event - the `cache` service
* is never resolved.
* is never resolved. `addOperation()` records an operation with no events manager.
*
* @phpstan-import-type list_envelope from DebugBarTypes
* @phpstan-import-type list_panel from DebugBarTypes
Expand All @@ -44,6 +44,14 @@ final class CacheCollector extends AbstractCollector implements Subscriber
*/
private array $operations = [];

public function addOperation(string $operation, string $key): void
{
$this->operations[] = [
'label' => $operation,
'message' => $key,
];
}

/**
* @return list_envelope
*/
Expand All @@ -68,10 +76,7 @@ public function subscribe(ManagerInterface $eventsManager): void
$eventsManager->attach(
$eventName,
function (EventInterface $event, mixed $cache, mixed $key) use ($operation): void {
$this->operations[] = [
'label' => $operation,
'message' => is_string($key) ? $key : '',
];
$this->addOperation($operation, is_string($key) ? $key : '');
}
);
}
Expand Down
33 changes: 26 additions & 7 deletions src/DebugBar/Collector/DatabaseCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
/**
* Records SQL queries by subscribing to `db:beforeQuery`/`db:afterQuery`. The
* connection is the event's source, so the statement, bindings, and timing are
* read straight off it - no `db` service is resolved.
* read straight off it - no `db` service is resolved. `startQuery()` and
* `finishQuery()` record a query with no events manager.
*
* @phpstan-import-type db_query from DebugBarTypes
* @phpstan-import-type database_envelope from DebugBarTypes
Expand Down Expand Up @@ -93,24 +94,42 @@ public function collect(): array
];
}

/**
* Records a query. Call `startQuery()` first: the time starts there.
*
* @param array<array-key, mixed> $bindings
*/
public function finishQuery(string $sql, array $bindings = []): void
{
$this->queries[] = [
'sql' => $sql,
'bindings' => $bindings,
'time' => hrtime(true) - $this->started,
];
}

public function startQuery(): void
{
$this->started = hrtime(true);
}

public function subscribe(ManagerInterface $eventsManager): void
{
$eventsManager->attach(
'db:beforeQuery',
function (): void {
$this->started = hrtime(true);
$this->startQuery();
}
);

$eventsManager->attach(
'db:afterQuery',
function (EventInterface $event, mixed $connection): void {
if ($connection instanceof AbstractAdapter) {
$this->queries[] = [
'sql' => $connection->getSQLStatement(),
'bindings' => $connection->getSQLVariables(),
'time' => hrtime(true) - $this->started,
];
$this->finishQuery(
$connection->getSQLStatement(),
$connection->getSQLVariables()
);
}
}
);
Expand Down
46 changes: 29 additions & 17 deletions src/DebugBar/Collector/RouteCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

namespace Phalcon\DebugBar\Collector;

use Closure;
use Phalcon\DebugBar\Contracts\Subscriber;
use Phalcon\DebugBar\DebugBarTypes;
use Phalcon\Events\EventInterface;
Expand All @@ -23,9 +24,11 @@
* Reports the matched route. The router is the source of `router:matchedRoute`,
* so it is captured there and read at `collect()` - by response time it has been
* fully resolved (module/controller/action/params), which is not yet true when
* the event fires. Nothing is resolved from the container.
* the event fires. Nothing is resolved from the container. `setRoute()` gives the
* panel values with no events manager.
*
* @phpstan-import-type grid_envelope from DebugBarTypes
* @phpstan-import-type grid_panel from DebugBarTypes
*/
final class RouteCollector extends AbstractCollector implements Subscriber
{
Expand All @@ -39,39 +42,48 @@ final class RouteCollector extends AbstractCollector implements Subscriber

protected string $panel = 'grid';

private ?RouterInterface $router = null;
/**
* @var (Closure(): grid_panel)|grid_panel
*/
private array | Closure $route = [];

/**
* @return grid_envelope
*/
public function collect(): array
{
if (null === $this->router) {
return [
'panel' => [],
'badge' => null,
];
}

return [
'panel' => [
'Module' => $this->router->getModuleName(),
'Namespace' => $this->router->getNamespaceName(),
'Controller' => $this->router->getControllerName(),
'Action' => $this->router->getActionName(),
'Params' => $this->jsonOrEmpty($this->router->getParams()),
],
'panel' => $this->route instanceof Closure ? ($this->route)() : $this->route,
'badge' => null,
];
}

/**
* Takes the panel values, or a closure that returns them. The collector calls
* the closure at `collect()`.
*
* @param (Closure(): grid_panel)|grid_panel $route
*/
public function setRoute(array | Closure $route): void
{
$this->route = $route;
}

public function subscribe(ManagerInterface $eventsManager): void
{
$eventsManager->attach(
'router:matchedRoute',
function (EventInterface $event, mixed $router): void {
if ($router instanceof RouterInterface) {
$this->router = $router;
$this->setRoute(
fn (): array => [
'Module' => $router->getModuleName(),
'Namespace' => $router->getNamespaceName(),
'Controller' => $router->getControllerName(),
'Action' => $router->getActionName(),
'Params' => $this->jsonOrEmpty($router->getParams()),
]
);
}
}
);
Expand Down
43 changes: 29 additions & 14 deletions src/DebugBar/Collector/ViewCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
* Records rendered views by subscribing to `view:beforeRenderView`/
* `view:afterRenderView`. The path is the event data on the "before" event, so
* nothing is resolved from the container. A stack pairs each "before" with its
* matching "after", so nested partials are timed correctly.
* matching "after", so nested partials are timed correctly. `startRender()` and
* `finishRender()` record a render with no events manager.
*
* @phpstan-import-type list_envelope from DebugBarTypes
* @phpstan-import-type view_pending from DebugBarTypes
Expand Down Expand Up @@ -74,30 +75,44 @@ public function collect(): array
];
}

/**
* Records the last render that `startRender()` started. It does nothing when
* no render is pending.
*/
public function finishRender(): void
{
$entry = array_pop($this->pending);
if (null === $entry) {
return;
}

$this->rendered[] = [
'path' => $entry['path'],
'time' => hrtime(true) - $entry['start'],
];
}

public function startRender(string $path): void
{
$this->pending[] = [
'path' => $path,
'start' => hrtime(true),
];
}

public function subscribe(ManagerInterface $eventsManager): void
{
$eventsManager->attach(
'view:beforeRenderView',
function (EventInterface $event, mixed $view, mixed $path): void {
$this->pending[] = [
'path' => is_string($path) ? $path : '',
'start' => hrtime(true),
];
$this->startRender(is_string($path) ? $path : '');
}
);

$eventsManager->attach(
'view:afterRenderView',
function (): void {
$entry = array_pop($this->pending);
if (null === $entry) {
return;
}

$this->rendered[] = [
'path' => $entry['path'],
'time' => hrtime(true) - $entry['start'],
];
$this->finishRender();
}
);
}
Expand Down
12 changes: 12 additions & 0 deletions tests/Unit/DebugBar/Collector/CacheCollectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ final class CacheCollectorTest extends AbstractUnitTestCase
{
use PanelContractTrait;

public function testAddOperationRecordsWithNoEventsManager(): void
{
$collector = new CacheCollector();

$collector->addOperation('Get', 'user_42');

$envelope = $collector->collect();

$this->assertSame(1, $envelope['badge']);
$this->assertSame([['label' => 'Get', 'message' => 'user_42']], $envelope['panel']);
}

public function testCacheOperationsAreRecordedFromEvents(): void
{
$collector = new CacheCollector();
Expand Down
48 changes: 48 additions & 0 deletions tests/Unit/DebugBar/Collector/DatabaseCollectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,38 @@ public function testEmptyCollectorHasZeroSummaryValues(): void
);
}

public function testFinishQueryMeasuresTheTimeFromStartQuery(): void
{
$collector = new DatabaseCollector();

$collector->startQuery();
$collector->finishQuery('SELECT 1');

$property = new ReflectionProperty(DatabaseCollector::class, 'queries');
$property->setAccessible(true);

/** @var array<int, array{time: float|int}> $queries */
$queries = $property->getValue($collector);
$time = $queries[0]['time'];

/**
* With no start time, `time` has the full monotonic clock value
* (billions of nanoseconds).
*/
$this->assertGreaterThanOrEqual(0, $time);
$this->assertLessThan(1000000000, $time);
}

public function testFinishQueryWithNoBindingsShowsOnlyTheStatement(): void
{
$collector = new DatabaseCollector();

$collector->startQuery();
$collector->finishQuery('SELECT 1');

$this->assertSame('SELECT 1', $collector->collect()['panel'][0]['message']);
}

public function testLabelReflectsQueryDurationInMilliseconds(): void
{
$collector = new DatabaseCollector();
Expand Down Expand Up @@ -183,6 +215,22 @@ public function testQueriesAreRecordedFromEvents(): void
$this->assertSame('SELECT 1', $envelope['panel'][0]['message']);
}

public function testStartAndFinishRecordAQueryWithNoEventsManager(): void
{
$collector = new DatabaseCollector();

$collector->startQuery();
$collector->finishQuery('SELECT * FROM users WHERE id = :id', ['id' => 5]);

$envelope = $collector->collect();

$this->assertSame(1, $envelope['badge']);
$this->assertSame(
'SELECT * FROM users WHERE id = :id {"id":5}',
$envelope['panel'][0]['message']
);
}

public function testSummaryReportsQueryCountDuplicatesAndTotalDuration(): void
{
$collector = new DatabaseCollector();
Expand Down
48 changes: 48 additions & 0 deletions tests/Unit/DebugBar/Collector/RouteCollectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,21 @@ public function testNonRouterSourceIsIgnored(): void
$this->assertSame([], $collector->collect()['panel']);
}

public function testRouteClosureIsCalledAtCollectTime(): void
{
$collector = new RouteCollector();
$action = 'before';

$collector->setRoute(
function () use (&$action): array {
return ['Action' => $action];
}
);
$action = 'after';

$this->assertSame(['Action' => 'after'], $collector->collect()['panel']);
}

public function testRouteIsSnapshotFromTheRouter(): void
{
$collector = new RouteCollector();
Expand All @@ -67,4 +82,37 @@ public function testRouteIsSnapshotFromTheRouter(): void
$this->assertSame('login', $panel['Action']);
$this->assertSame('{"id":5}', $panel['Params']);
}

public function testRouterIsReadAtCollectTime(): void
{
$collector = new RouteCollector();
$eventsManager = new Manager();
$collector->subscribe($eventsManager);

$action = 'before';
$router = $this->createMock(RouterInterface::class);
$router->method('getActionName')->willReturnCallback(
function () use (&$action): string {
return $action;
}
);

$eventsManager->fire('router:matchedRoute', $router, null);
$action = 'after';

$this->assertSame('after', $collector->collect()['panel']['Action']);
}

public function testRouteValuesAreShownInThePanel(): void
{
$collector = new RouteCollector();
$values = ['Action' => 'App\Action\GetPage', 'Attributes' => '{"id":5}'];

$collector->setRoute($values);

$envelope = $collector->collect();

$this->assertSame($values, $envelope['panel']);
$this->assertNull($envelope['badge']);
}
}
Loading
Loading