diff --git a/CHANGELOG.md b/CHANGELOG.md index 3eef982..cc2cd86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,3 +17,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - fix(toolbar): follow debug tags through adapter query URLs and enforce complete JavaScript mutation coverage. - feat(panel): add `UserRbacRow` typed view-model so adapters render RBAC role and permission rows from a single normalized shape. - feat: add shared UI parity contracts, Asset composition, and cross-adapter acceptance documentation. +- feat(ui): share database EXPLAIN markup across debugger adapters. diff --git a/src/Panel/Db/DbExplainRenderer.php b/src/Panel/Db/DbExplainRenderer.php new file mode 100644 index 0000000..5559ed6 --- /dev/null +++ b/src/Panel/Db/DbExplainRenderer.php @@ -0,0 +1,109 @@ +> $results Driver result rows. + */ + public static function render(string $query, array $results): string + { + return self::renderPlan($query, $results, null); + } + + /** + * Renders a failed EXPLAIN attempt without escaping the shared panel contract. + */ + public static function renderError(string $query, string $message): string + { + return self::renderPlan($query, [], $message); + } + + /** + * @param array> $results Driver result rows. + */ + private static function renderPlan(string $query, array $results, string|null $error): string + { + $resultList = array_values($results); + + $columns = $resultList === [] ? [] : array_keys($resultList[0]); + + $children = [ + H1::tag() + ->class('yii-debug-explain-title') + ->content('EXPLAIN'), + ]; + + if ($query !== '') { + $children[] = Pre::tag() + ->class('yii-debug-explain-query') + ->html(SqlHighlighter::highlight($query)); + } + + if ($error !== null) { + $children[] = P::tag() + ->class('yii-debug-explain-empty') + ->content("EXPLAIN failed: {$error}"); + } elseif ($results === []) { + $children[] = P::tag() + ->class('yii-debug-explain-empty') + ->content('EXPLAIN returned no rows.'); + } else { + $headerCells = []; + + foreach ($columns as $column) { + $headerCells[] = Th::tag()->scope('col')->content((string) $column); + } + + $bodyRows = []; + + foreach ($results as $row) { + $cells = []; + + foreach ($columns as $column) { + $value = $row[$column] ?? null; + $cells[] = $value === null + ? Td::tag()->html(Em::tag()->content('NULL')) + : Td::tag()->content(is_scalar($value) ? (string) $value : Dump::export($value)); + } + + $bodyRows[] = Tr::tag()->html(...$cells); + } + + $children[] = Div::tag() + ->class('yii-debug-explain-scroll') + ->html( + Table::tag() + ->class('yii-debug-table yii-debug-explain-table') + ->html( + Thead::tag()->html(Tr::tag()->html(...$headerCells)), + Tbody::tag()->html(...$bodyRows), + ), + ); + } + + return Div::tag() + ->class('yii-debug-explain') + ->html(...$children) + ->render(); + } +} diff --git a/tests/Panel/Db/DbExplainRendererTest.php b/tests/Panel/Db/DbExplainRendererTest.php new file mode 100644 index 0000000..5428f45 --- /dev/null +++ b/tests/Panel/Db/DbExplainRendererTest.php @@ -0,0 +1,111 @@ + +

+ EXPLAIN +

+ EXPLAIN returned no rows. +

+ + HTML, + DbExplainRenderer::render('', []), + 'Empty plans must keep the shared title and empty-state contract.', + ); + } + + public function testRenderError(): void + { + self::assertSame( + <<<'HTML' +
+

+ EXPLAIN +

+            SELECT 1
+            

+ EXPLAIN failed: database <unavailable> +

+
+ HTML, + DbExplainRenderer::renderError('SELECT 1', 'database '), + 'Failed plans must preserve the query and escape the driver error exactly.', + ); + } + + public function testRenderQueryAndResultTable(): void + { + self::assertSame( + <<<'HTML' +
+

+ EXPLAIN +

+            SELECT * FROM users
+            
+ + + + + + + + + + + + + + +
+ id + + detail +
+ 1 + + SCAN users +
+ NULL + + SEARCH users +
+ 2 + + [ + 'operator' => '<scan>', + ] +
+
+
+ HTML, + DbExplainRenderer::render( + 'SELECT * FROM users', + [ + ['id' => 1, 'detail' => 'SCAN users'], + ['id' => null, 'detail' => 'SEARCH users'], + ['id' => 2, 'detail' => ['operator' => '']], + ], + ), + 'A populated plan must render scalar, null, and escaped non-scalar cells exactly.', + ); + } +}