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
11 changes: 11 additions & 0 deletions lib/features/mysql/mysql_sql_workspace.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import 'package:querya_desktop/core/database/table_mutation_engine.dart';
import 'package:querya_desktop/core/storage/app_settings.dart';
import 'package:querya_desktop/core/storage/local_db.dart';
import 'package:querya_desktop/features/workspace/workspace.dart';
import 'package:querya_desktop/features/workspace/query_plan.dart';

/// Execution delegate for MySQL / MariaDB connections.
class MysqlSqlExecutionDelegate extends SqlExecutionDelegate {
Expand Down Expand Up @@ -153,6 +154,16 @@ class MysqlSqlExecutionDelegate extends SqlExecutionDelegate {
return v.toInt();
}

@override
Future<PlanNode?> explainTree(String sql) async {
await ensureLease();
final conn = _lease?.connection;
if (conn == null || !conn.isConnected) return null;
final rs = await conn.executeWithTimeout('EXPLAIN FORMAT=JSON $sql');
if (rs.rows.isEmpty) return null;
return QueryPlanParser.fromMysqlJson(rs.rows.first.assoc().values.first);
}

@override
Future<String> explainQuery(String sql) async {
await ensureLease();
Expand Down
11 changes: 11 additions & 0 deletions lib/features/postgresql/postgres_sql_workspace.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart';
import 'package:querya_desktop/features/postgresql/postgres_result_utils.dart';
import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart';
import 'package:querya_desktop/features/workspace/workspace.dart';
import 'package:querya_desktop/features/workspace/query_plan.dart';

/// Database used for this SQL workspace session (matches [PostgresService.acquire]).
String _pgSqlSessionDatabase(ConnectionRow row) {
Expand Down Expand Up @@ -156,6 +157,16 @@ class PostgresSqlExecutionDelegate extends SqlExecutionDelegate {
);
}

@override
Future<PlanNode?> explainTree(String sql) async {
await ensureLease();
final conn = _lease?.connection;
if (conn == null || !conn.isConnected) return null;
final res = await conn.execute('EXPLAIN (FORMAT JSON) $sql');
if (res.isEmpty) return null;
return QueryPlanParser.fromPostgresJson(res.first.first);
}

@override
Future<String> explainQuery(String sql) async {
await ensureLease();
Expand Down
17 changes: 17 additions & 0 deletions lib/features/sqlite/sqlite_sql_workspace.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import 'package:querya_desktop/core/storage/app_settings.dart';
import 'package:querya_desktop/core/storage/local_db.dart';
import 'package:querya_desktop/features/sqlite/sqlite_result_utils.dart';
import 'package:querya_desktop/features/workspace/workspace.dart';
import 'package:querya_desktop/features/workspace/query_plan.dart';

/// Execution delegate for SQLite connections.
class SqliteSqlExecutionDelegate extends SqlExecutionDelegate {
Expand Down Expand Up @@ -127,6 +128,22 @@ class SqliteSqlExecutionDelegate extends SqlExecutionDelegate {
);
}

@override
Future<PlanNode?> explainTree(String sql) async {
await ensureLease();
final conn = _lease?.connection;
if (conn == null || !conn.isConnected) return null;
final res = await conn.executeWithTimeout('EXPLAIN QUERY PLAN $sql');
return QueryPlanParser.fromSqliteRows([
for (final r in res)
(
id: int.parse('${r.values.elementAt(0)}'),
parent: int.parse('${r.values.elementAt(1)}'),
detail: '${r.values.elementAt(3)}',
),
]);
}

@override
Future<String> explainQuery(String sql) async {
await ensureLease();
Expand Down
36 changes: 35 additions & 1 deletion lib/features/workspace/generic_sql_workspace.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import 'package:querya_desktop/features/workspace/query_editor_tab.dart';
import 'package:querya_desktop/features/workspace/results_tab.dart';
import 'package:querya_desktop/features/workspace/sql_editor_chrome.dart';
import 'package:querya_desktop/features/erd/erd_view.dart';
import 'package:querya_desktop/features/workspace/plan_tree_view.dart';
import 'package:querya_desktop/features/workspace/query_plan.dart';
import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart';
import 'package:querya_desktop/features/workspace/sql_query_history_dialog.dart';
import 'package:querya_desktop/features/workspace/sql_query_tab_bar.dart';
Expand Down Expand Up @@ -643,6 +645,7 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
session.rows = [];
session.affectedRows = null;
session.statusLine = null;
session.planRoot = null;
session.resultGridPrimaryKeys = const [];
session.resultGridColumnDataTypes = null;
session.resultGridColumnMeta = null;
Expand Down Expand Up @@ -852,6 +855,16 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {

/// Shows the query plan of the selection (or the whole editor text) in the
/// result grid, one plan line per row.
/// The plan as a tree; null when the driver has none or it cannot be read.
/// The text plan is already shown, so a failure here is not an error.
Future<PlanNode?> _explainTreeOrNull(String sql) async {
try {
return await widget.delegate.explainTree(sql);
} catch (_) {
return null;
}
}

Future<void> explain([SqlQueryTabSession? targetSession]) async {
final session = targetSession ?? _activeSession;
if (session.running || !widget.delegate.supportsExplain) return;
Expand All @@ -871,6 +884,7 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
session.rows = [];
session.affectedRows = null;
session.statusLine = null;
session.planRoot = null;
session.resultGridPrimaryKeys = const [];
session.resultGridColumnDataTypes = null;
session.resultGridColumnMeta = null;
Expand All @@ -889,6 +903,9 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
session.statusLine = 'Query plan: ${lines.length} line(s).';
session.running = false;
});
final tree = await _explainTreeOrNull(sql);
if (!mounted) return;
setState(() => session.planRoot = tree);
} catch (e) {
if (mounted) {
invalidatePane(session);
Expand Down Expand Up @@ -1259,8 +1276,25 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
bottom: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (session.planRoot != null)
material.Padding(
padding: const material.EdgeInsets.fromLTRB(8, 6, 8, 6),
child: material.Align(
alignment: material.Alignment.centerLeft,
child: material.SizedBox(
width: 200,
child: QueryaTabStrip(
labels: const ['Plan', 'Text'],
selectedIndex: session.planAsTree ? 0 : 1,
onSelected: (i) => setState(() => session.planAsTree = i == 0),
),
),
),
),
Expanded(
child: ResultsTab(
child: session.planRoot != null && session.planAsTree
? PlanTreeView(root: session.planRoot!)
: ResultsTab(
columns: session.columns,
rows: session.rows,
errorMessage: session.error,
Expand Down
113 changes: 113 additions & 0 deletions lib/features/workspace/plan_tree_view.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import 'package:flutter/material.dart' as material;
import 'package:querya_desktop/features/workspace/query_plan.dart';
import 'package:querya_desktop/shared/widgets/widgets.dart';

/// The plan as a tree, top-down. The node with the largest own cost is
/// highlighted; each node shows its share of the total cost as a bar.
class PlanTreeView extends material.StatelessWidget {
const PlanTreeView({super.key, required this.root});

final PlanNode root;

@override
material.Widget build(material.BuildContext context) {
final hottest = QueryPlanParser.hottest(root);
final total = root.cost ?? _sumOwn(root);
final rows = <material.Widget>[];
void add(PlanNode node, int depth) {
rows.add(_PlanRow(
node: node,
depth: depth,
hottest: identical(node, hottest),
total: total,
));
for (final child in node.children) {
add(child, depth + 1);
}
}

add(root, 0);
return material.SingleChildScrollView(
padding: const material.EdgeInsets.all(8),
child: material.Column(
crossAxisAlignment: material.CrossAxisAlignment.start,
children: rows,
),
);
}

static double _sumOwn(PlanNode n) {
var sum = n.ownCost ?? 0;
for (final c in n.children) {
sum += _sumOwn(c);
}
return sum;
}
}

class _PlanRow extends material.StatelessWidget {
const _PlanRow({
required this.node,
required this.depth,
required this.hottest,
required this.total,
});

final PlanNode node;
final int depth;
final bool hottest;
final double total;

@override
material.Widget build(material.BuildContext context) {
final wb = context.workbench;
final own = node.ownCost;
final share = own == null || total <= 0 ? 0.0 : (own / total).clamp(0.0, 1.0);
final rowsText = node.estimatedRows == null
? null
: 'rows ${node.estimatedRows!.round()}'
'${node.actualRows == null ? '' : ' → ${node.actualRows!.round()}'}';
return material.Padding(
padding: material.EdgeInsets.only(left: depth * 18.0, bottom: 6),
child: material.Container(
padding: const material.EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: material.BoxDecoration(
color: hottest ? wb.accent.withValues(alpha: 0.12) : null,
border: material.Border.all(
color: hottest ? wb.accent : wb.borderSubtle,
width: hottest ? 1.5 : 1,
),
borderRadius: material.BorderRadius.circular(6),
),
child: material.Column(
crossAxisAlignment: material.CrossAxisAlignment.start,
children: [
material.Row(
children: [
material.Text(node.operation,
style: const material.TextStyle(fontWeight: material.FontWeight.w600)),
if (node.relation != null) ...[
const material.SizedBox(width: 8),
material.Text(node.relation!,
style: material.TextStyle(color: wb.mutedForeground)),
],
],
),
if (rowsText != null)
material.Text(rowsText,
style: material.TextStyle(fontSize: 11, color: wb.mutedForeground)),
if (own != null) ...[
const material.SizedBox(height: 4),
material.Stack(
children: [
material.Container(height: 4, width: 160, color: wb.borderSubtle),
material.Container(height: 4, width: 160 * share, color: wb.accent),
],
),
],
],
),
),
);
}
}
Loading
Loading