Skip to content

New Queries Panel with Improved EXPLAIN output #1648

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Sep 6, 2024
Merged
40 changes: 40 additions & 0 deletions src/Controllers/QueriesController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace Barryvdh\Debugbar\Controllers;

use Barryvdh\Debugbar\Support\Explain;
use Exception;
use Illuminate\Http\Request;

class QueriesController extends BaseController
{
/**
* Generate explain data for query.
*/
public function explain(Request $request)
{
if (!config('debugbar.options.db.explain.enabled', false)) {
return response()->json([
'success' => false,
'message' => 'EXPLAIN is currently disabled in the Debugbar.',
], 400);
}

try {
$data = match ($request->json('mode')) {
'visual' => (new Explain())->generateVisualExplain($request->json('connection'), $request->json('query'), $request->json('bindings'), $request->json('hash')),
default => (new Explain())->generateRawExplain($request->json('connection'), $request->json('query'), $request->json('bindings'), $request->json('hash')),
};

return response()->json([
'success' => true,
'data' => $data,
]);
} catch (Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
}
182 changes: 74 additions & 108 deletions src/DataCollector/QueryCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

namespace Barryvdh\Debugbar\DataCollector;

use Barryvdh\Debugbar\Support\Explain;
use DebugBar\DataCollector\PDO\PDOCollector;
use DebugBar\DataCollector\TimeDataCollector;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

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

$sql = (string) $query->sql;
$explainResults = [];
$time = $query->time / 1000;
$endTime = microtime(true);
$startTime = $endTime - $time;
Expand All @@ -168,41 +169,6 @@ public function addQuery($query)
} catch (\Throwable $e) {
// ignore error for non-pdo laravel drivers
}
$bindings = $query->connection->prepareBindings($query->bindings);

// Run EXPLAIN on this query (if needed)
if (!$limited && $this->explainQuery && $pdo && preg_match('/^\s*(' . implode('|', $this->explainTypes) . ') /i', $sql)) {
$statement = $pdo->prepare('EXPLAIN ' . $sql);
$statement->execute($bindings);
$explainResults = $statement->fetchAll(\PDO::FETCH_CLASS);
}

$bindings = $this->getDataFormatter()->checkBindings($bindings);
if (!empty($bindings) && $this->renderSqlWithParams) {
foreach ($bindings as $key => $binding) {
// This regex matches placeholders only, not the question marks,
// nested in quotes, while we iterate through the bindings
// and substitute placeholders by suitable values.
$regex = is_numeric($key)
? "/(?<!\?)\?(?=(?:[^'\\\']*'[^'\\']*')*[^'\\\']*$)(?!\?)/"
: "/:{$key}(?=(?:[^'\\\']*'[^'\\\']*')*[^'\\\']*$)/";

// Mimic bindValue and only quote non-integer and non-float data types
if (!is_int($binding) && !is_float($binding)) {
if ($pdo) {
try {
$binding = $pdo->quote((string) $binding);
} catch (\Exception $e) {
$binding = $this->emulateQuote($binding);
}
} else {
$binding = $this->emulateQuote($binding);
}
}

$sql = preg_replace($regex, addcslashes($binding, '$'), $sql, 1);
}
}

$source = [];

Expand All @@ -213,16 +179,20 @@ public function addQuery($query)
}
}

$bindings = match (true) {
$limited && filled($query->bindings) => null,
default => $query->connection->prepareBindings($query->bindings),
};

$this->queries[] = [
'query' => $sql,
'type' => 'query',
'bindings' => !$limited ? $this->getDataFormatter()->escapeBindings($bindings) : null,
'bindings' => $bindings,
'start' => $startTime,
'time' => $time,
'memory' => $this->lastMemoryUsage ? memory_get_usage(false) - $this->lastMemoryUsage : 0,
'source' => $source,
'explain' => $explainResults,
'connection' => $query->connection->getDatabaseName(),
'connection' => $query->connection->getName(),
'driver' => $query->connection->getConfig('driver'),
'hints' => ($this->showHints && !$limited) ? $hints : null,
'show_copy' => $this->showCopyButton,
Expand Down Expand Up @@ -484,8 +454,7 @@ public function collectTransactionEvent($event, $connection)
'time' => 0,
'memory' => 0,
'source' => $source,
'explain' => [],
'connection' => $connection->getDatabaseName(),
'connection' => $connection->getName(),
'driver' => $connection->getConfig('driver'),
'hints' => null,
'show_copy' => false,
Expand Down Expand Up @@ -516,15 +485,21 @@ public function collect()
$totalTime += $query['time'];
$totalMemory += $query['memory'];

if (str_ends_with($query['connection'], '.sqlite')) {
$query['connection'] = $this->normalizeFilePath($query['connection']);
$connectionName = DB::connection($query['connection'])->getDatabaseName();
if (str_ends_with($connectionName, '.sqlite')) {
$connectionName = $this->normalizeFilePath($connectionName);
}

$canExplainQuery = match (true) {
in_array($query['driver'], ['mysql', 'pgsql']) => $query['bindings'] !== null && preg_match('/^\s*(' . implode('|', $this->explainTypes) . ') /i', $query['query']),
default => false,
};

$statements[] = [
'sql' => $this->getDataFormatter()->formatSql($query['query']),
'sql' => $this->getSqlQueryToDisplay($query),
'type' => $query['type'],
'params' => [],
'bindings' => $query['bindings'],
'bindings' => $query['bindings'] ?? [],
'hints' => $query['hints'],
'show_copy' => $query['show_copy'],
'backtrace' => array_values($query['source']),
Expand All @@ -536,69 +511,16 @@ public function collect()
'filename' => $this->getDataFormatter()->formatSource($source, true),
'source' => $this->getDataFormatter()->formatSource($source),
'xdebug_link' => is_object($source) ? $this->getXdebugLink($source->file ?: '', $source->line) : null,
'connection' => $query['connection'],
'connection' => $connectionName,
'explain' => $this->explainQuery && $canExplainQuery ? [
'url' => route('debugbar.queries.explain'),
'visual-confirm' => (new Explain())->confirm($query['connection']),
'driver' => $query['driver'],
'connection' => $query['connection'],
'query' => $query['query'],
'hash' => (new Explain())->hash($query['connection'], $query['query'], $query['bindings']),
] : null,
];

if ($query['explain']) {
// Add the results from the EXPLAIN as new rows
if ($query['driver'] === 'pgsql') {
$explainer = trim(implode("\n", array_map(function ($explain) {
return $explain->{'QUERY PLAN'};
}, $query['explain'])));

if ($explainer) {
$statements[] = [
'sql' => " - EXPLAIN: {$explainer}",
'type' => 'explain',
];
}
} elseif ($query['driver'] === 'sqlite') {
$vmi = '<table style="margin:-5px -11px !important;width: 100% !important">';
$vmi .= "<thead><tr>
<td>Address</td>
<td>Opcode</td>
<td>P1</td>
<td>P2</td>
<td>P3</td>
<td>P4</td>
<td>P5</td>
<td>Comment</td>
</tr></thead>";

foreach ($query['explain'] as $explain) {
$vmi .= "<tr>
<td>{$explain->addr}</td>
<td>{$explain->opcode}</td>
<td>{$explain->p1}</td>
<td>{$explain->p2}</td>
<td>{$explain->p3}</td>
<td>{$explain->p4}</td>
<td>{$explain->p5}</td>
<td>{$explain->comment}</td>
</tr>";
}

$vmi .= '</table>';

$statements[] = [
'sql' => " - EXPLAIN:",
'type' => 'explain',
'params' => [
'Virtual Machine Instructions' => $vmi,
]
];
} else {
foreach ($query['explain'] as $explain) {
$statements[] = [
'sql' => " - EXPLAIN # {$explain->id}: `{$explain->table}` ({$explain->select_type})",
'type' => 'explain',
'params' => $explain,
'row_count' => $explain->rows,
'stmt_id' => $explain->id,
];
}
}
}
}

if ($this->durationBackground) {
Expand Down Expand Up @@ -676,7 +598,7 @@ public function getWidgets()
return [
"queries" => [
"icon" => "database",
"widget" => "PhpDebugBar.Widgets.SQLQueriesWidget",
"widget" => "PhpDebugBar.Widgets.LaravelQueriesWidget",
"map" => "queries",
"default" => "[]"
],
Expand All @@ -686,4 +608,48 @@ public function getWidgets()
]
];
}

private function getSqlQueryToDisplay(array $query): string
{
$sql = $query['query'];
if ($query['type'] === 'query' && $this->renderSqlWithParams && method_exists(DB::connection($query['connection'])->getQueryGrammar(), 'substituteBindingsIntoRawSql')) {
$sql = DB::connection($query['connection'])->getQueryGrammar()->substituteBindingsIntoRawSql($sql, $query['bindings'] ?? []);
} elseif ($query['type'] === 'query' && $this->renderSqlWithParams) {
$bindings = $this->getDataFormatter()->checkBindings($query['bindings']);
if (!empty($bindings)) {
$pdo = null;
try {
$pdo = $query->connection->getPdo();
} catch (\Throwable) {
// ignore error for non-pdo laravel drivers
}

foreach ($bindings as $key => $binding) {
// This regex matches placeholders only, not the question marks,
// nested in quotes, while we iterate through the bindings
// and substitute placeholders by suitable values.
$regex = is_numeric($key)
? "/(?<!\?)\?(?=(?:[^'\\\']*'[^'\\']*')*[^'\\\']*$)(?!\?)/"
: "/:{$key}(?=(?:[^'\\\']*'[^'\\\']*')*[^'\\\']*$)/";

// Mimic bindValue and only quote non-integer and non-float data types
if (!is_int($binding) && !is_float($binding)) {
if ($pdo) {
try {
$binding = $pdo->quote((string) $binding);
} catch (\Exception $e) {
$binding = $this->emulateQuote($binding);
}
} else {
$binding = $this->emulateQuote($binding);
}
}

$sql = preg_replace($regex, addcslashes($binding, '$'), $sql, 1);
}
}
}

return $this->getDataFormatter()->formatSql($sql);
}
}
15 changes: 0 additions & 15 deletions src/DataFormatter/QueryFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,21 +47,6 @@ public function checkBindings($bindings)
return $bindings;
}

/**
* Make the bindings safe for outputting.
*
* @param array $bindings
* @return array
*/
public function escapeBindings($bindings)
{
foreach ($bindings as &$binding) {
$binding = htmlentities((string) $binding, ENT_QUOTES, 'UTF-8', false);
}

return $bindings;
}

/**
* Format a source object.
*
Expand Down
1 change: 1 addition & 0 deletions src/JavascriptRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public function __construct(DebugBar $debugBar, $baseUrl = null, $basePath = nul

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

$theme = config('debugbar.theme', 'auto');
switch ($theme) {
Expand Down
67 changes: 67 additions & 0 deletions src/Resources/laravel-debugbar.css
Original file line number Diff line number Diff line change
Expand Up @@ -924,3 +924,70 @@ pre.phpdebugbar-widgets-code-block ul.phpdebugbar-widgets-numbered-code li {
color: var(--color-gray-100) !important;
}
}

div.phpdebugbar-widgets-sqlqueries span.phpdebugbar-widgets-copy-clipboard {
float: none !important;
}

.phpdebugbar-widgets-bg-measure .phpdebugbar-widgets-value {
height: 2px !important;
}

div.phpdebugbar-widgets-sqlqueries table.phpdebugbar-widgets-params td.phpdebugbar-widgets-name {
width: 150px;
}

div.phpdebugbar-widgets-sqlqueries a.phpdebugbar-widgets-connection {
font-size: 12px;
padding: 2px 4px;
background: #737373;
margin-left: 6px;
border-radius: 4px;
color: #fff !important;
}

div.phpdebugbar-widgets-sqlqueries button.phpdebugbar-widgets-explain-btn {
cursor: pointer;
background: #383838;
color: #fff;
font-size: 13px;
padding: 0 8px;
border-radius: 4px;
line-height: 1.25rem;
}

div.phpdebugbar-widgets-sqlqueries table.phpdebugbar-widgets-explain {
margin: 0 !important;
}

div.phpdebugbar-widgets-sqlqueries table.phpdebugbar-widgets-explain th {
border: 1px solid #ddd;
text-align: center;
}

div.phpdebugbar-widgets-sqlqueries a.phpdebugbar-widgets-visual-explain {
display: inline-block;
font-weight: bold;
text-decoration: underline;
margin-top: 6px;
}

div.phpdebugbar-widgets-sqlqueries a.phpdebugbar-widgets-visual-link {
color: #888;
margin-left: 6px;
}

div.phpdebugbar-widgets-sqlqueries a.phpdebugbar-widgets-visual-explain:after {
content: "\f08e";
font-family: PhpDebugbarFontAwesome;
margin-left: 4px;
font-size: 12px;
}

div.phpdebugbar-widgets-sqlqueries li.phpdebugbar-widgets-list-item.phpdebugbar-widgets-expandable {
cursor: pointer;
}

div.phpdebugbar-widgets-sqlqueries li.phpdebugbar-widgets-list-item .phpdebugbar-widgets-params {
cursor: default;
}
Loading
Loading