Skip to content

Commit 976c23e

Browse files
Merge pull request #1230 from QueryaHub/feat/1164-plan-node-details
feat(explain): a click on a plan node shows every field the driver reported (#1164)
2 parents cde7ce8 + 029f83e commit 976c23e

2 files changed

Lines changed: 130 additions & 6 deletions

File tree

‎lib/features/workspace/plan_tree_view.dart‎

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,23 @@ import 'package:querya_desktop/features/workspace/query_plan.dart';
33
import 'package:querya_desktop/shared/widgets/widgets.dart';
44

55
/// The plan as a tree, top-down. The node with the largest own cost is
6-
/// highlighted; each node shows its share of the total cost as a bar.
7-
class PlanTreeView extends material.StatelessWidget {
6+
/// highlighted; each node shows its share of the total cost as a bar. A click
7+
/// on a node shows every field the driver reported for it.
8+
class PlanTreeView extends material.StatefulWidget {
89
const PlanTreeView({super.key, required this.root});
910

1011
final PlanNode root;
1112

13+
@override
14+
material.State<PlanTreeView> createState() => _PlanTreeViewState();
15+
}
16+
17+
class _PlanTreeViewState extends material.State<PlanTreeView> {
18+
PlanNode? _selected;
19+
1220
@override
1321
material.Widget build(material.BuildContext context) {
22+
final root = widget.root;
1423
final hottest = QueryPlanParser.hottest(root);
1524
final total = root.cost ?? _sumOwn(root);
1625
final rows = <material.Widget>[];
@@ -19,19 +28,25 @@ class PlanTreeView extends material.StatelessWidget {
1928
node: node,
2029
depth: depth,
2130
hottest: identical(node, hottest),
31+
selected: identical(node, _selected),
2232
total: total,
33+
onTap: () => setState(() => _selected = identical(node, _selected) ? null : node),
2334
));
2435
for (final child in node.children) {
2536
add(child, depth + 1);
2637
}
2738
}
2839

2940
add(root, 0);
41+
final selected = _selected;
3042
return material.SingleChildScrollView(
3143
padding: const material.EdgeInsets.all(8),
3244
child: material.Column(
3345
crossAxisAlignment: material.CrossAxisAlignment.start,
34-
children: rows,
46+
children: [
47+
...rows,
48+
if (selected != null) _DetailsPanel(node: selected),
49+
],
3550
),
3651
);
3752
}
@@ -50,13 +65,17 @@ class _PlanRow extends material.StatelessWidget {
5065
required this.node,
5166
required this.depth,
5267
required this.hottest,
68+
required this.selected,
5369
required this.total,
70+
required this.onTap,
5471
});
5572

5673
final PlanNode node;
5774
final int depth;
5875
final bool hottest;
76+
final bool selected;
5977
final double total;
78+
final material.VoidCallback onTap;
6079

6180
@override
6281
material.Widget build(material.BuildContext context) {
@@ -69,13 +88,20 @@ class _PlanRow extends material.StatelessWidget {
6988
'${node.actualRows == null ? '' : ' → ${node.actualRows!.round()}'}';
7089
return material.Padding(
7190
padding: material.EdgeInsets.only(left: depth * 18.0, bottom: 6),
72-
child: material.Container(
91+
child: material.GestureDetector(
92+
behavior: material.HitTestBehavior.opaque,
93+
onTap: onTap,
94+
child: material.Container(
7395
padding: const material.EdgeInsets.symmetric(horizontal: 10, vertical: 6),
7496
decoration: material.BoxDecoration(
7597
color: hottest ? wb.accent.withValues(alpha: 0.12) : null,
7698
border: material.Border.all(
77-
color: hottest ? wb.accent : wb.borderSubtle,
78-
width: hottest ? 1.5 : 1,
99+
color: selected
100+
? Theme.of(context).colorScheme.foreground
101+
: hottest
102+
? wb.accent
103+
: wb.borderSubtle,
104+
width: hottest || selected ? 1.5 : 1,
79105
),
80106
borderRadius: material.BorderRadius.circular(6),
81107
),
@@ -107,6 +133,57 @@ class _PlanRow extends material.StatelessWidget {
107133
],
108134
],
109135
),
136+
),
137+
),
138+
);
139+
}
140+
}
141+
142+
/// Every field the driver reported for a node, in the order it reported them.
143+
class _DetailsPanel extends material.StatelessWidget {
144+
const _DetailsPanel({required this.node});
145+
146+
final PlanNode node;
147+
148+
@override
149+
material.Widget build(material.BuildContext context) {
150+
final wb = context.workbench;
151+
final fields = <String, String>{
152+
'Operation': node.operation,
153+
if (node.relation != null) 'Relation': node.relation!,
154+
if (node.estimatedRows != null)
155+
'Estimated rows': '${node.estimatedRows!.round()}',
156+
if (node.actualRows != null) 'Actual rows': '${node.actualRows!.round()}',
157+
if (node.cost != null) 'Cost': node.cost!.toStringAsFixed(2),
158+
if (node.ownCost != null) 'Own cost': node.ownCost!.toStringAsFixed(2),
159+
...node.details,
160+
};
161+
return material.Container(
162+
margin: const material.EdgeInsets.only(top: 8),
163+
padding: const material.EdgeInsets.all(10),
164+
decoration: material.BoxDecoration(
165+
border: material.Border.all(color: wb.borderSubtle),
166+
borderRadius: material.BorderRadius.circular(6),
167+
),
168+
child: material.Column(
169+
crossAxisAlignment: material.CrossAxisAlignment.start,
170+
children: [
171+
for (final e in fields.entries)
172+
material.Padding(
173+
padding: const material.EdgeInsets.symmetric(vertical: 2),
174+
child: material.Row(
175+
crossAxisAlignment: material.CrossAxisAlignment.start,
176+
children: [
177+
material.SizedBox(
178+
width: 140,
179+
child: material.Text(e.key,
180+
style: material.TextStyle(color: wb.mutedForeground)),
181+
),
182+
material.Expanded(child: material.SelectableText(e.value)),
183+
],
184+
),
185+
),
186+
],
110187
),
111188
);
112189
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import 'package:flutter/material.dart' as material;
2+
import 'package:flutter_test/flutter_test.dart';
3+
import 'package:querya_desktop/features/workspace/plan_tree_view.dart';
4+
import 'package:querya_desktop/features/workspace/query_plan.dart';
5+
6+
import '../../support/querya_theme_test_shell.dart';
7+
8+
void main() {
9+
testWidgets('a click on a plan node shows every field it reported (#1164)',
10+
(t) async {
11+
final root = PlanNode(
12+
operation: 'Hash Join',
13+
cost: 500,
14+
children: [
15+
PlanNode(
16+
operation: 'Seq Scan',
17+
relation: 'orders',
18+
cost: 300,
19+
details: {'Startup Cost': '0.00', 'Filter': 'paid'},
20+
),
21+
],
22+
);
23+
await t.pumpWidget(queryaThemeTestShell(
24+
child: material.SizedBox(
25+
width: 600,
26+
height: 600,
27+
child: PlanTreeView(root: root),
28+
),
29+
));
30+
await t.pump();
31+
32+
expect(find.text('Seq Scan'), findsOneWidget);
33+
expect(find.text('Filter'), findsNothing);
34+
35+
// The name shows again in the panel, so tap the tree row (the first one).
36+
await t.tap(find.text('Seq Scan').first);
37+
await t.pump();
38+
expect(find.text('Filter'), findsOneWidget);
39+
expect(find.text('paid'), findsOneWidget);
40+
expect(find.text('Startup Cost'), findsOneWidget);
41+
42+
// A second click closes the details again.
43+
await t.tap(find.text('Seq Scan').first);
44+
await t.pump();
45+
expect(find.text('Filter'), findsNothing);
46+
});
47+
}

0 commit comments

Comments
 (0)