Skip to content

Commit ed37d1a

Browse files
authored
New Queries Panel with Improved EXPLAIN output (#1648)
* improved queries panel * only run explain when requested to do so * escape bindings on client side * send original sql query and one with bindings embedded * keep empty bindings in limit because they don't add overhead * fix: backtraces had been broken * reuse bindings array * only use the current app.key for verification * fix: revert tests to their original implementation * removed useless local data attribute * fix: xdebug logic was different to original
1 parent b67ad61 commit ed37d1a

File tree

8 files changed

+680
-123
lines changed

8 files changed

+680
-123
lines changed

src/Controllers/QueriesController.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
namespace Barryvdh\Debugbar\Controllers;
4+
5+
use Barryvdh\Debugbar\Support\Explain;
6+
use Exception;
7+
use Illuminate\Http\Request;
8+
9+
class QueriesController extends BaseController
10+
{
11+
/**
12+
* Generate explain data for query.
13+
*/
14+
public function explain(Request $request)
15+
{
16+
if (!config('debugbar.options.db.explain.enabled', false)) {
17+
return response()->json([
18+
'success' => false,
19+
'message' => 'EXPLAIN is currently disabled in the Debugbar.',
20+
], 400);
21+
}
22+
23+
try {
24+
$data = match ($request->json('mode')) {
25+
'visual' => (new Explain())->generateVisualExplain($request->json('connection'), $request->json('query'), $request->json('bindings'), $request->json('hash')),
26+
default => (new Explain())->generateRawExplain($request->json('connection'), $request->json('query'), $request->json('bindings'), $request->json('hash')),
27+
};
28+
29+
return response()->json([
30+
'success' => true,
31+
'data' => $data,
32+
]);
33+
} catch (Exception $e) {
34+
return response()->json([
35+
'success' => false,
36+
'message' => $e->getMessage(),
37+
], 400);
38+
}
39+
}
40+
}

src/DataCollector/QueryCollector.php

Lines changed: 74 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
namespace Barryvdh\Debugbar\DataCollector;
44

5+
use Barryvdh\Debugbar\Support\Explain;
56
use DebugBar\DataCollector\PDO\PDOCollector;
67
use DebugBar\DataCollector\TimeDataCollector;
8+
use Illuminate\Support\Facades\DB;
79
use Illuminate\Support\Str;
810

911
/**
@@ -152,7 +154,6 @@ public function addQuery($query)
152154
$limited = $this->softLimit && $this->queryCount > $this->softLimit;
153155

154156
$sql = (string) $query->sql;
155-
$explainResults = [];
156157
$time = $query->time / 1000;
157158
$endTime = microtime(true);
158159
$startTime = $endTime - $time;
@@ -168,41 +169,6 @@ public function addQuery($query)
168169
} catch (\Throwable $e) {
169170
// ignore error for non-pdo laravel drivers
170171
}
171-
$bindings = $query->connection->prepareBindings($query->bindings);
172-
173-
// Run EXPLAIN on this query (if needed)
174-
if (!$limited && $this->explainQuery && $pdo && preg_match('/^\s*(' . implode('|', $this->explainTypes) . ') /i', $sql)) {
175-
$statement = $pdo->prepare('EXPLAIN ' . $sql);
176-
$statement->execute($bindings);
177-
$explainResults = $statement->fetchAll(\PDO::FETCH_CLASS);
178-
}
179-
180-
$bindings = $this->getDataFormatter()->checkBindings($bindings);
181-
if (!empty($bindings) && $this->renderSqlWithParams) {
182-
foreach ($bindings as $key => $binding) {
183-
// This regex matches placeholders only, not the question marks,
184-
// nested in quotes, while we iterate through the bindings
185-
// and substitute placeholders by suitable values.
186-
$regex = is_numeric($key)
187-
? "/(?<!\?)\?(?=(?:[^'\\\']*'[^'\\']*')*[^'\\\']*$)(?!\?)/"
188-
: "/:{$key}(?=(?:[^'\\\']*'[^'\\\']*')*[^'\\\']*$)/";
189-
190-
// Mimic bindValue and only quote non-integer and non-float data types
191-
if (!is_int($binding) && !is_float($binding)) {
192-
if ($pdo) {
193-
try {
194-
$binding = $pdo->quote((string) $binding);
195-
} catch (\Exception $e) {
196-
$binding = $this->emulateQuote($binding);
197-
}
198-
} else {
199-
$binding = $this->emulateQuote($binding);
200-
}
201-
}
202-
203-
$sql = preg_replace($regex, addcslashes($binding, '$'), $sql, 1);
204-
}
205-
}
206172

207173
$source = [];
208174

@@ -213,16 +179,20 @@ public function addQuery($query)
213179
}
214180
}
215181

182+
$bindings = match (true) {
183+
$limited && filled($query->bindings) => null,
184+
default => $query->connection->prepareBindings($query->bindings),
185+
};
186+
216187
$this->queries[] = [
217188
'query' => $sql,
218189
'type' => 'query',
219-
'bindings' => !$limited ? $this->getDataFormatter()->escapeBindings($bindings) : null,
190+
'bindings' => $bindings,
220191
'start' => $startTime,
221192
'time' => $time,
222193
'memory' => $this->lastMemoryUsage ? memory_get_usage(false) - $this->lastMemoryUsage : 0,
223194
'source' => $source,
224-
'explain' => $explainResults,
225-
'connection' => $query->connection->getDatabaseName(),
195+
'connection' => $query->connection->getName(),
226196
'driver' => $query->connection->getConfig('driver'),
227197
'hints' => ($this->showHints && !$limited) ? $hints : null,
228198
'show_copy' => $this->showCopyButton,
@@ -484,8 +454,7 @@ public function collectTransactionEvent($event, $connection)
484454
'time' => 0,
485455
'memory' => 0,
486456
'source' => $source,
487-
'explain' => [],
488-
'connection' => $connection->getDatabaseName(),
457+
'connection' => $connection->getName(),
489458
'driver' => $connection->getConfig('driver'),
490459
'hints' => null,
491460
'show_copy' => false,
@@ -516,15 +485,21 @@ public function collect()
516485
$totalTime += $query['time'];
517486
$totalMemory += $query['memory'];
518487

519-
if (str_ends_with($query['connection'], '.sqlite')) {
520-
$query['connection'] = $this->normalizeFilePath($query['connection']);
488+
$connectionName = DB::connection($query['connection'])->getDatabaseName();
489+
if (str_ends_with($connectionName, '.sqlite')) {
490+
$connectionName = $this->normalizeFilePath($connectionName);
521491
}
522492

493+
$canExplainQuery = match (true) {
494+
in_array($query['driver'], ['mysql', 'pgsql']) => $query['bindings'] !== null && preg_match('/^\s*(' . implode('|', $this->explainTypes) . ') /i', $query['query']),
495+
default => false,
496+
};
497+
523498
$statements[] = [
524-
'sql' => $this->getDataFormatter()->formatSql($query['query']),
499+
'sql' => $this->getSqlQueryToDisplay($query),
525500
'type' => $query['type'],
526501
'params' => [],
527-
'bindings' => $query['bindings'],
502+
'bindings' => $query['bindings'] ?? [],
528503
'hints' => $query['hints'],
529504
'show_copy' => $query['show_copy'],
530505
'backtrace' => array_values($query['source']),
@@ -536,69 +511,16 @@ public function collect()
536511
'filename' => $this->getDataFormatter()->formatSource($source, true),
537512
'source' => $this->getDataFormatter()->formatSource($source),
538513
'xdebug_link' => is_object($source) ? $this->getXdebugLink($source->file ?: '', $source->line) : null,
539-
'connection' => $query['connection'],
514+
'connection' => $connectionName,
515+
'explain' => $this->explainQuery && $canExplainQuery ? [
516+
'url' => route('debugbar.queries.explain'),
517+
'visual-confirm' => (new Explain())->confirm($query['connection']),
518+
'driver' => $query['driver'],
519+
'connection' => $query['connection'],
520+
'query' => $query['query'],
521+
'hash' => (new Explain())->hash($query['connection'], $query['query'], $query['bindings']),
522+
] : null,
540523
];
541-
542-
if ($query['explain']) {
543-
// Add the results from the EXPLAIN as new rows
544-
if ($query['driver'] === 'pgsql') {
545-
$explainer = trim(implode("\n", array_map(function ($explain) {
546-
return $explain->{'QUERY PLAN'};
547-
}, $query['explain'])));
548-
549-
if ($explainer) {
550-
$statements[] = [
551-
'sql' => " - EXPLAIN: {$explainer}",
552-
'type' => 'explain',
553-
];
554-
}
555-
} elseif ($query['driver'] === 'sqlite') {
556-
$vmi = '<table style="margin:-5px -11px !important;width: 100% !important">';
557-
$vmi .= "<thead><tr>
558-
<td>Address</td>
559-
<td>Opcode</td>
560-
<td>P1</td>
561-
<td>P2</td>
562-
<td>P3</td>
563-
<td>P4</td>
564-
<td>P5</td>
565-
<td>Comment</td>
566-
</tr></thead>";
567-
568-
foreach ($query['explain'] as $explain) {
569-
$vmi .= "<tr>
570-
<td>{$explain->addr}</td>
571-
<td>{$explain->opcode}</td>
572-
<td>{$explain->p1}</td>
573-
<td>{$explain->p2}</td>
574-
<td>{$explain->p3}</td>
575-
<td>{$explain->p4}</td>
576-
<td>{$explain->p5}</td>
577-
<td>{$explain->comment}</td>
578-
</tr>";
579-
}
580-
581-
$vmi .= '</table>';
582-
583-
$statements[] = [
584-
'sql' => " - EXPLAIN:",
585-
'type' => 'explain',
586-
'params' => [
587-
'Virtual Machine Instructions' => $vmi,
588-
]
589-
];
590-
} else {
591-
foreach ($query['explain'] as $explain) {
592-
$statements[] = [
593-
'sql' => " - EXPLAIN # {$explain->id}: `{$explain->table}` ({$explain->select_type})",
594-
'type' => 'explain',
595-
'params' => $explain,
596-
'row_count' => $explain->rows,
597-
'stmt_id' => $explain->id,
598-
];
599-
}
600-
}
601-
}
602524
}
603525

604526
if ($this->durationBackground) {
@@ -676,7 +598,7 @@ public function getWidgets()
676598
return [
677599
"queries" => [
678600
"icon" => "database",
679-
"widget" => "PhpDebugBar.Widgets.SQLQueriesWidget",
601+
"widget" => "PhpDebugBar.Widgets.LaravelQueriesWidget",
680602
"map" => "queries",
681603
"default" => "[]"
682604
],
@@ -686,4 +608,48 @@ public function getWidgets()
686608
]
687609
];
688610
}
611+
612+
private function getSqlQueryToDisplay(array $query): string
613+
{
614+
$sql = $query['query'];
615+
if ($query['type'] === 'query' && $this->renderSqlWithParams && method_exists(DB::connection($query['connection'])->getQueryGrammar(), 'substituteBindingsIntoRawSql')) {
616+
$sql = DB::connection($query['connection'])->getQueryGrammar()->substituteBindingsIntoRawSql($sql, $query['bindings'] ?? []);
617+
} elseif ($query['type'] === 'query' && $this->renderSqlWithParams) {
618+
$bindings = $this->getDataFormatter()->checkBindings($query['bindings']);
619+
if (!empty($bindings)) {
620+
$pdo = null;
621+
try {
622+
$pdo = $query->connection->getPdo();
623+
} catch (\Throwable) {
624+
// ignore error for non-pdo laravel drivers
625+
}
626+
627+
foreach ($bindings as $key => $binding) {
628+
// This regex matches placeholders only, not the question marks,
629+
// nested in quotes, while we iterate through the bindings
630+
// and substitute placeholders by suitable values.
631+
$regex = is_numeric($key)
632+
? "/(?<!\?)\?(?=(?:[^'\\\']*'[^'\\']*')*[^'\\\']*$)(?!\?)/"
633+
: "/:{$key}(?=(?:[^'\\\']*'[^'\\\']*')*[^'\\\']*$)/";
634+
635+
// Mimic bindValue and only quote non-integer and non-float data types
636+
if (!is_int($binding) && !is_float($binding)) {
637+
if ($pdo) {
638+
try {
639+
$binding = $pdo->quote((string) $binding);
640+
} catch (\Exception $e) {
641+
$binding = $this->emulateQuote($binding);
642+
}
643+
} else {
644+
$binding = $this->emulateQuote($binding);
645+
}
646+
}
647+
648+
$sql = preg_replace($regex, addcslashes($binding, '$'), $sql, 1);
649+
}
650+
}
651+
}
652+
653+
return $this->getDataFormatter()->formatSql($sql);
654+
}
689655
}

src/DataFormatter/QueryFormatter.php

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,21 +47,6 @@ public function checkBindings($bindings)
4747
return $bindings;
4848
}
4949

50-
/**
51-
* Make the bindings safe for outputting.
52-
*
53-
* @param array $bindings
54-
* @return array
55-
*/
56-
public function escapeBindings($bindings)
57-
{
58-
foreach ($bindings as &$binding) {
59-
$binding = htmlentities((string) $binding, ENT_QUOTES, 'UTF-8', false);
60-
}
61-
62-
return $bindings;
63-
}
64-
6550
/**
6651
* Format a source object.
6752
*

src/JavascriptRenderer.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public function __construct(DebugBar $debugBar, $baseUrl = null, $basePath = nul
2121

2222
$this->cssFiles['laravel'] = __DIR__ . '/Resources/laravel-debugbar.css';
2323
$this->jsFiles['laravel-cache'] = __DIR__ . '/Resources/cache/widget.js';
24+
$this->jsFiles['laravel-queries'] = __DIR__ . '/Resources/queries/widget.js';
2425

2526
$theme = config('debugbar.theme', 'auto');
2627
switch ($theme) {

src/Resources/laravel-debugbar.css

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -924,3 +924,70 @@ pre.phpdebugbar-widgets-code-block ul.phpdebugbar-widgets-numbered-code li {
924924
color: var(--color-gray-100) !important;
925925
}
926926
}
927+
928+
div.phpdebugbar-widgets-sqlqueries span.phpdebugbar-widgets-copy-clipboard {
929+
float: none !important;
930+
}
931+
932+
.phpdebugbar-widgets-bg-measure .phpdebugbar-widgets-value {
933+
height: 2px !important;
934+
}
935+
936+
div.phpdebugbar-widgets-sqlqueries table.phpdebugbar-widgets-params td.phpdebugbar-widgets-name {
937+
width: 150px;
938+
}
939+
940+
div.phpdebugbar-widgets-sqlqueries a.phpdebugbar-widgets-connection {
941+
font-size: 12px;
942+
padding: 2px 4px;
943+
background: #737373;
944+
margin-left: 6px;
945+
border-radius: 4px;
946+
color: #fff !important;
947+
}
948+
949+
div.phpdebugbar-widgets-sqlqueries button.phpdebugbar-widgets-explain-btn {
950+
cursor: pointer;
951+
background: #383838;
952+
color: #fff;
953+
font-size: 13px;
954+
padding: 0 8px;
955+
border-radius: 4px;
956+
line-height: 1.25rem;
957+
}
958+
959+
div.phpdebugbar-widgets-sqlqueries table.phpdebugbar-widgets-explain {
960+
margin: 0 !important;
961+
}
962+
963+
div.phpdebugbar-widgets-sqlqueries table.phpdebugbar-widgets-explain th {
964+
border: 1px solid #ddd;
965+
text-align: center;
966+
}
967+
968+
div.phpdebugbar-widgets-sqlqueries a.phpdebugbar-widgets-visual-explain {
969+
display: inline-block;
970+
font-weight: bold;
971+
text-decoration: underline;
972+
margin-top: 6px;
973+
}
974+
975+
div.phpdebugbar-widgets-sqlqueries a.phpdebugbar-widgets-visual-link {
976+
color: #888;
977+
margin-left: 6px;
978+
}
979+
980+
div.phpdebugbar-widgets-sqlqueries a.phpdebugbar-widgets-visual-explain:after {
981+
content: "\f08e";
982+
font-family: PhpDebugbarFontAwesome;
983+
margin-left: 4px;
984+
font-size: 12px;
985+
}
986+
987+
div.phpdebugbar-widgets-sqlqueries li.phpdebugbar-widgets-list-item.phpdebugbar-widgets-expandable {
988+
cursor: pointer;
989+
}
990+
991+
div.phpdebugbar-widgets-sqlqueries li.phpdebugbar-widgets-list-item .phpdebugbar-widgets-params {
992+
cursor: default;
993+
}

0 commit comments

Comments
 (0)