diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 97e18e9f..b4c9f5c7 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -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 { @@ -153,6 +154,16 @@ class MysqlSqlExecutionDelegate extends SqlExecutionDelegate { return v.toInt(); } + @override + Future 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 explainQuery(String sql) async { await ensureLease(); diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 22b5c8ff..a80cdb89 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -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) { @@ -156,6 +157,16 @@ class PostgresSqlExecutionDelegate extends SqlExecutionDelegate { ); } + @override + Future 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 explainQuery(String sql) async { await ensureLease(); diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 42a747ae..735da6a7 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -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 { @@ -127,6 +128,22 @@ class SqliteSqlExecutionDelegate extends SqlExecutionDelegate { ); } + @override + Future 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 explainQuery(String sql) async { await ensureLease(); diff --git a/lib/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index 1ea16a7d..ac85d73a 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -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'; @@ -643,6 +645,7 @@ class GenericSqlWorkspaceState extends material.State { session.rows = []; session.affectedRows = null; session.statusLine = null; + session.planRoot = null; session.resultGridPrimaryKeys = const []; session.resultGridColumnDataTypes = null; session.resultGridColumnMeta = null; @@ -852,6 +855,16 @@ class GenericSqlWorkspaceState extends material.State { /// 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 _explainTreeOrNull(String sql) async { + try { + return await widget.delegate.explainTree(sql); + } catch (_) { + return null; + } + } + Future explain([SqlQueryTabSession? targetSession]) async { final session = targetSession ?? _activeSession; if (session.running || !widget.delegate.supportsExplain) return; @@ -871,6 +884,7 @@ class GenericSqlWorkspaceState extends material.State { session.rows = []; session.affectedRows = null; session.statusLine = null; + session.planRoot = null; session.resultGridPrimaryKeys = const []; session.resultGridColumnDataTypes = null; session.resultGridColumnMeta = null; @@ -889,6 +903,9 @@ class GenericSqlWorkspaceState extends material.State { 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); @@ -1259,8 +1276,25 @@ class GenericSqlWorkspaceState extends material.State { 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, diff --git a/lib/features/workspace/plan_tree_view.dart b/lib/features/workspace/plan_tree_view.dart new file mode 100644 index 00000000..58491fd7 --- /dev/null +++ b/lib/features/workspace/plan_tree_view.dart @@ -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 = []; + 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), + ], + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/workspace/query_plan.dart b/lib/features/workspace/query_plan.dart new file mode 100644 index 00000000..b1669e7f --- /dev/null +++ b/lib/features/workspace/query_plan.dart @@ -0,0 +1,193 @@ +import 'dart:convert'; +import 'dart:math' as math; + +/// One operation of a query plan, with its children below it. +class PlanNode { + PlanNode({ + required this.operation, + this.relation, + this.estimatedRows, + this.actualRows, + this.cost, + this.details = const {}, + List? children, + }) : children = children ?? const []; + + /// What the node does, as the driver names it (`Seq Scan`, `ref`, `SCAN`). + final String operation; + + /// Table or index the node reads, when there is one. + final String? relation; + + final double? estimatedRows; + final double? actualRows; + + /// Cumulative cost up to and including this node, when the driver reports one. + final double? cost; + + /// Every field the driver reported, for the details of a node. + final Map details; + + final List children; + + /// Cost of this node alone: its cumulative cost minus its children's. Null + /// when the driver reports no cost. + double? get ownCost { + final total = cost; + if (total == null) return null; + var own = total; + for (final child in children) { + final c = child.cost; + if (c != null) own -= c; + } + return math.max(own, 0); + } +} + +/// Builds [PlanNode] trees from what each driver returns for EXPLAIN. +abstract final class QueryPlanParser { + /// PostgreSQL `EXPLAIN (FORMAT JSON)`: a list with one object holding `Plan`. + /// Accepts the JSON text or the already decoded value. + static PlanNode? fromPostgresJson(Object? value) { + final decoded = _decode(value); + if (decoded is! List || decoded.isEmpty) return null; + final first = decoded.first; + if (first is! Map || first['Plan'] is! Map) return null; + return _postgresNode(_asMap(first['Plan']!)); + } + + static PlanNode _postgresNode(Map m) { + final children = m['Plans']; + return PlanNode( + operation: '${m['Node Type'] ?? 'Node'}', + relation: (m['Relation Name'] ?? m['Index Name']) as String?, + estimatedRows: _num(m['Plan Rows']), + actualRows: _num(m['Actual Rows']), + cost: _num(m['Total Cost']), + details: { + for (final e in m.entries) + if (e.key != 'Plans' && e.value is! Map && e.value is! List) + e.key: '${e.value}', + }, + children: [ + if (children is List) + for (final c in children) _postgresNode(_asMap(c)), + ], + ); + } + + /// MySQL `EXPLAIN FORMAT=JSON`: `query_block` with a `nested_loop` of tables + /// (joins), or a single `table`. + static PlanNode? fromMysqlJson(Object? value) { + final decoded = _decode(value); + if (decoded is! Map || decoded['query_block'] is! Map) return null; + final block = _asMap(decoded['query_block']!); + final rootCost = _num(_asMap(block['cost_info'] ?? const {})['query_cost']); + final tables = []; + final loop = block['nested_loop']; + // prefix_cost is cumulative along the loop; each table keeps its own part, + // so the query block's cost is the sum of its tables'. + var previous = 0.0; + void addTable(Map table) { + final prefix = _num(_asMap(table['cost_info'] ?? const {})['prefix_cost']); + final node = _mysqlTable(table, ownCost: prefix == null ? null : prefix - previous); + if (prefix != null) previous = prefix; + tables.add(node); + } + + if (loop is List) { + for (final item in loop) { + final table = _asMap(item)['table']; + if (table is Map) addTable(_asMap(table)); + } + } else if (block['table'] is Map) { + addTable(_asMap(block['table']!)); + } + return PlanNode( + operation: 'Query block', + cost: rootCost, + children: tables, + ); + } + + static PlanNode _mysqlTable(Map t, {double? ownCost}) { + return PlanNode( + operation: '${t['access_type'] ?? 'table'}', + relation: t['table_name'] as String?, + estimatedRows: _num(t['rows_examined_per_scan'] ?? t['rows_produced_per_join']), + cost: ownCost, + details: { + for (final e in t.entries) + if (e.value is! Map && e.value is! List) e.key: '${e.value}', + }, + ); + } + + /// SQLite `EXPLAIN QUERY PLAN`: one row per step with `id`, `parent` and + /// `detail`. Rows with parent 0 hang below a synthetic root. SQLite reports + /// no costs, so no node is "most expensive". + static PlanNode? fromSqliteRows( + List<({int id, int parent, String detail})> rows, + ) { + if (rows.isEmpty) return null; + final byParent = >{}; + for (final r in rows) { + byParent.putIfAbsent(r.parent, () => []).add(r); + } + PlanNode build(({int id, int parent, String detail}) r) { + final words = r.detail.trim().split(RegExp(r'\s+')); + return PlanNode( + operation: words.first, + relation: words.length > 1 ? words[1] : null, + details: {'detail': r.detail}, + children: [ + for (final c in byParent[r.id] ?? const []) build(c), + ], + ); + } + + return PlanNode( + operation: 'QUERY PLAN', + children: [for (final r in byParent[0] ?? const []) build(r)], + ); + } + + /// The node with the largest own cost, the first one among equals. Null when + /// no node has a cost. + static PlanNode? hottest(PlanNode root) { + PlanNode? best; + double bestCost = -1; + void visit(PlanNode n) { + final own = n.ownCost; + if (own != null && own > bestCost) { + best = n; + bestCost = own; + } + for (final c in n.children) { + visit(c); + } + } + + visit(root); + return best; + } + + /// The JSON text decoded, or the value itself; null when the text is not JSON. + static Object? _decode(Object? value) { + if (value is! String) return value; + try { + return jsonDecode(value); + } on FormatException { + return null; + } + } + + static Map _asMap(Object? v) => + v is Map ? v.map((k, val) => MapEntry('$k', val)) : const {}; + + static double? _num(Object? v) { + if (v is num) return v.toDouble(); + if (v is String) return double.tryParse(v); + return null; + } +} diff --git a/lib/features/workspace/sql_execution_delegate.dart b/lib/features/workspace/sql_execution_delegate.dart index f600a9cc..7f142a5c 100644 --- a/lib/features/workspace/sql_execution_delegate.dart +++ b/lib/features/workspace/sql_execution_delegate.dart @@ -1,4 +1,6 @@ import 'dart:async'; + +import 'package:querya_desktop/features/workspace/query_plan.dart'; import 'package:flutter/foundation.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/features/workspace/sql_result_grid_schema.dart'; @@ -16,6 +18,10 @@ abstract class SqlExecutionDelegate { /// Explains the SQL query plan if supported by the DBMS. Future explainQuery(String sql); + /// The plan as a tree, when the driver can give one. [explainQuery] remains + /// the text fallback. + Future explainTree(String sql) async => null; + /// Whether [explainQuery] works for this driver (shows the Explain button). bool get supportsExplain => true; diff --git a/lib/features/workspace/sql_query_tab_session.dart b/lib/features/workspace/sql_query_tab_session.dart index cce4d25e..88b04d52 100644 --- a/lib/features/workspace/sql_query_tab_session.dart +++ b/lib/features/workspace/sql_query_tab_session.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/actions/sql_script_format.dart'; import 'package:querya_desktop/core/database/table_schema_meta.dart'; +import 'package:querya_desktop/features/workspace/query_plan.dart'; import 'package:querya_desktop/core/unsaved_work_registry.dart'; import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; @@ -42,6 +43,10 @@ class SqlQueryTabSession { List> rows = const []; int? affectedRows; String? statusLine; + + /// The EXPLAIN plan as a tree, and whether the result area shows it (or text). + PlanNode? planRoot; + bool planAsTree = true; DataGridStagingBuffer? stagingBuffer; String? lastExecutedSql; bool savingChanges = false; diff --git a/test/features/workspace/query_plan_test.dart b/test/features/workspace/query_plan_test.dart new file mode 100644 index 00000000..f51f4a47 --- /dev/null +++ b/test/features/workspace/query_plan_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/workspace/query_plan.dart'; + +/// PostgreSQL plan of a join of orders, customers and items. +const _pgPlan = ''' +[{"Plan": { + "Node Type": "Hash Join", "Plan Rows": 100, "Actual Rows": 120, "Total Cost": 500.0, + "Plans": [ + {"Node Type": "Seq Scan", "Relation Name": "orders", "Plan Rows": 1000, "Actual Rows": 1000, "Total Cost": 300.0}, + {"Node Type": "Hash", "Plan Rows": 50, "Total Cost": 120.0, + "Plans": [ + {"Node Type": "Index Scan", "Relation Name": "customers", "Index Name": "customers_pkey", "Plan Rows": 50, "Total Cost": 100.0} + ]} + ] +}}]'''; + +void main() { + group('PostgreSQL plan', () { + test('a join renders as a tree with the right parents and children', () { + final root = QueryPlanParser.fromPostgresJson(_pgPlan)!; + expect(root.operation, 'Hash Join'); + expect(root.children.map((c) => c.operation), ['Seq Scan', 'Hash']); + final hash = root.children[1]; + expect(hash.children.single.operation, 'Index Scan'); + expect(hash.children.single.relation, 'customers'); + expect(root.children.first.relation, 'orders'); + }); + + test('rows and cost come through; the own cost subtracts children', () { + final root = QueryPlanParser.fromPostgresJson(_pgPlan)!; + expect(root.estimatedRows, 100); + expect(root.actualRows, 120); + expect(root.cost, 500); + expect(root.ownCost, 500 - 300 - 120); + expect(root.children[1].ownCost, 120 - 100); + }); + + test('the most expensive node is the scan with the largest own cost', () { + final root = QueryPlanParser.fromPostgresJson(_pgPlan)!; + expect(QueryPlanParser.hottest(root)!.operation, 'Seq Scan'); + }); + + test('a value that is not a plan gives no tree', () { + expect(QueryPlanParser.fromPostgresJson('not json'), isNull); + expect(QueryPlanParser.fromPostgresJson('[]'), isNull); + }); + }); + + group('MySQL plan', () { + test('tables of a nested loop become children of the query block', () { + const plan = ''' +{"query_block": {"select_id": 1, "cost_info": {"query_cost": "42.00"}, + "nested_loop": [ + {"table": {"table_name": "orders", "access_type": "ALL", + "rows_examined_per_scan": 1000, "cost_info": {"prefix_cost": "30.00"}}}, + {"table": {"table_name": "customers", "access_type": "eq_ref", + "rows_examined_per_scan": 1, "cost_info": {"prefix_cost": "42.00"}}} + ]}}'''; + final root = QueryPlanParser.fromMysqlJson(plan)!; + expect(root.cost, 42); + // Each table keeps its own part of the 42: 30 for orders, 12 for customers. + expect(root.children.map((c) => c.ownCost), [30, 12]); + expect(root.children.map((c) => c.relation), ['orders', 'customers']); + expect(root.children.first.operation, 'ALL'); + expect(QueryPlanParser.hottest(root)!.relation, 'orders'); + }); + }); + + group('SQLite plan', () { + test('rows hang below their parent; a row with no parent is top level', () { + final root = QueryPlanParser.fromSqliteRows([ + (id: 2, parent: 0, detail: 'SCAN orders'), + (id: 3, parent: 2, detail: 'SEARCH customers USING INTEGER PRIMARY KEY (rowid=?)'), + (id: 4, parent: 0, detail: 'USE TEMP B-TREE FOR ORDER BY'), + ])!; + expect(root.operation, 'QUERY PLAN'); + expect(root.children.map((c) => c.operation), ['SCAN', 'USE']); + expect(root.children.first.relation, 'orders'); + expect(root.children.first.children.single.operation, 'SEARCH'); + expect(root.children.first.children.single.relation, 'customers'); + }); + + test('SQLite reports no cost, so no node is the most expensive', () { + final root = QueryPlanParser.fromSqliteRows([ + (id: 2, parent: 0, detail: 'SCAN orders'), + ])!; + expect(QueryPlanParser.hottest(root), isNull); + }); + + test('no rows, no tree', () { + expect(QueryPlanParser.fromSqliteRows(const []), isNull); + }); + }); +}