From 2048940e8da4c947c6a9688e66de9c2cfce0a487 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 13:13:04 +0300 Subject: [PATCH 1/3] feat(explain): plan trees and the most expensive node for PostgreSQL, MySQL and SQLite (#1164) Explain showed the plan as text lines, and finding the expensive node meant reading indentation. Parsers build a tree of operations from the JSON plans of PostgreSQL and MySQL and from the rows of SQLite's EXPLAIN QUERY PLAN. Each node has its own cost (its cumulative cost minus its children's); the node with the largest own cost is the one to highlight. Tests use recorded plans. --- lib/features/workspace/query_plan.dart | 174 +++++++++++++++++++ test/features/workspace/query_plan_test.dart | 92 ++++++++++ 2 files changed, 266 insertions(+) create mode 100644 lib/features/workspace/query_plan.dart create mode 100644 test/features/workspace/query_plan_test.dart diff --git a/lib/features/workspace/query_plan.dart b/lib/features/workspace/query_plan.dart new file mode 100644 index 00000000..fc20644b --- /dev/null +++ b/lib/features/workspace/query_plan.dart @@ -0,0 +1,174 @@ +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 = value is String ? jsonDecode(value) : 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 = value is String ? jsonDecode(value) : 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']; + if (loop is List) { + for (final item in loop) { + final table = _asMap(item)['table']; + if (table is Map) tables.add(_mysqlTable(_asMap(table))); + } + } else if (block['table'] is Map) { + tables.add(_mysqlTable(_asMap(block['table']!))); + } + return PlanNode( + operation: 'Query block', + cost: rootCost, + children: tables, + ); + } + + static PlanNode _mysqlTable(Map t) { + final cost = _asMap(t['cost_info'] ?? const {}); + 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: _num(cost['prefix_cost']), + 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; + } + + 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/test/features/workspace/query_plan_test.dart b/test/features/workspace/query_plan_test.dart new file mode 100644 index 00000000..be98a276 --- /dev/null +++ b/test/features/workspace/query_plan_test.dart @@ -0,0 +1,92 @@ +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); + 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); + }); + }); +} From c426b7e0e400bcf15c768d0dec71792e59f5da19 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 13:13:57 +0300 Subject: [PATCH 2/3] feat(explain): show the plan as a tree, with Plan | Text (#1164) Each driver gives the plan as a tree: PostgreSQL EXPLAIN (FORMAT JSON), MySQL EXPLAIN FORMAT=JSON, SQLite EXPLAIN QUERY PLAN rows. The result area of an explained query shows the tree with the most expensive node highlighted and each node's share of the cost as a bar, and a Plan | Text switch keeps the text plan. A driver without a tree, or a plan that cannot be read, keeps the text only. --- lib/features/mysql/mysql_sql_workspace.dart | 11 ++ .../postgresql/postgres_sql_workspace.dart | 11 ++ lib/features/sqlite/sqlite_sql_workspace.dart | 17 +++ .../workspace/generic_sql_workspace.dart | 36 +++++- lib/features/workspace/plan_tree_view.dart | 113 ++++++++++++++++++ .../workspace/sql_execution_delegate.dart | 6 + .../workspace/sql_query_tab_session.dart | 5 + 7 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 lib/features/workspace/plan_tree_view.dart 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/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; From dc6c58309be0d00bf62ae77a7ab0c2990d6d4501 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 13:17:24 +0300 Subject: [PATCH 3/3] fix(explain): MySQL tables keep their own cost; unreadable plan text gives no tree (#1164) MySQL's prefix_cost is cumulative along the nested loop, so each table now keeps its own part, and the query block's cost is the sum of its tables'. A plan text that is not JSON gives no tree instead of throwing. --- lib/features/workspace/query_plan.dart | 33 +++++++++++++++----- test/features/workspace/query_plan_test.dart | 2 ++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/lib/features/workspace/query_plan.dart b/lib/features/workspace/query_plan.dart index fc20644b..b1669e7f 100644 --- a/lib/features/workspace/query_plan.dart +++ b/lib/features/workspace/query_plan.dart @@ -49,7 +49,7 @@ 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 = value is String ? jsonDecode(value) : 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; @@ -79,19 +79,29 @@ abstract final class QueryPlanParser { /// MySQL `EXPLAIN FORMAT=JSON`: `query_block` with a `nested_loop` of tables /// (joins), or a single `table`. static PlanNode? fromMysqlJson(Object? value) { - final decoded = value is String ? jsonDecode(value) : 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) tables.add(_mysqlTable(_asMap(table))); + if (table is Map) addTable(_asMap(table)); } } else if (block['table'] is Map) { - tables.add(_mysqlTable(_asMap(block['table']!))); + addTable(_asMap(block['table']!)); } return PlanNode( operation: 'Query block', @@ -100,13 +110,12 @@ abstract final class QueryPlanParser { ); } - static PlanNode _mysqlTable(Map t) { - final cost = _asMap(t['cost_info'] ?? const {}); + 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: _num(cost['prefix_cost']), + cost: ownCost, details: { for (final e in t.entries) if (e.value is! Map && e.value is! List) e.key: '${e.value}', @@ -163,6 +172,16 @@ abstract final class QueryPlanParser { 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 {}; diff --git a/test/features/workspace/query_plan_test.dart b/test/features/workspace/query_plan_test.dart index be98a276..f51f4a47 100644 --- a/test/features/workspace/query_plan_test.dart +++ b/test/features/workspace/query_plan_test.dart @@ -58,6 +58,8 @@ void main() { ]}}'''; 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');