diff --git a/lib/features/postgresql/postgres_stats_view.dart b/lib/features/postgresql/postgres_stats_view.dart index 6a575a2f..0f32d938 100644 --- a/lib/features/postgresql/postgres_stats_view.dart +++ b/lib/features/postgresql/postgres_stats_view.dart @@ -29,6 +29,26 @@ class PostgresStatsView extends material.StatefulWidget { material.State createState() => _PostgresStatsViewState(); } +/// Adds one poll's numbers to [history]: connections as they are, and the +/// commits of all databases as a rate per second (a counter). +@visibleForTesting +void recordServerStats( + MetricHistory history, + Map stats, + DateTime now, +) { + history + ..record('connections_active', + ((stats['connections_active'] ?? 0) as num).toDouble(), now) + ..record('connections_total', + ((stats['connections_total'] ?? 0) as num).toDouble(), now); + var commits = 0; + for (final db in stats['databases'] as List? ?? const []) { + commits += ((db as Map)['xact_commit'] as num? ?? 0).toInt(); + } + history.recordCounter('transactions_per_second', commits.toDouble(), now); +} + class _PostgresStatsViewState extends material.State { PgLease? _lease; PostgresConnection? get _connection => _lease?.connection; @@ -104,19 +124,8 @@ class _PostgresStatsViewState extends material.State { } /// Records the poll's numbers. No query is added: the poll already fetched them. - void _recordHistory(Map stats) { - final now = DateTime.now(); - _history - ..record('connections_active', - ((stats['connections_active'] ?? 0) as num).toDouble(), now) - ..record('connections_total', - ((stats['connections_total'] ?? 0) as num).toDouble(), now); - var commits = 0; - for (final db in stats['databases'] as List? ?? const []) { - commits += ((db as Map)['xact_commit'] as num? ?? 0).toInt(); - } - _history.recordCounter('transactions_per_second', commits.toDouble(), now); - } + void _recordHistory(Map stats) => + recordServerStats(_history, stats, DateTime.now()); Future _fetch() async { final c = _connection; diff --git a/lib/features/workspace/data_grid_filter_bar.dart b/lib/features/workspace/data_grid_filter_bar.dart index 545e7b28..a1188d12 100644 --- a/lib/features/workspace/data_grid_filter_bar.dart +++ b/lib/features/workspace/data_grid_filter_bar.dart @@ -1,6 +1,7 @@ import 'dart:async' show Timer; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show KeyDownEvent, LogicalKeyboardKey; +import 'package:querya_desktop/shared/widgets/querya_icon_button.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; /// Kind of filter autocomplete suggestion. @@ -420,15 +421,14 @@ class _DataGridFilterBarState extends material.State { ), ), const Gap(4), - material.IconButton( + QueryaIconButton( + density: QueryaIconButtonDensity.dense, icon: material.Icon( widget.onClose != null ? material.Icons.clear_rounded : material.Icons.close, size: 14, ), - padding: material.EdgeInsets.zero, - constraints: const material.BoxConstraints(minWidth: 20, minHeight: 20), color: cs.mutedForeground, tooltip: 'Clear filter', onPressed: () { @@ -441,10 +441,9 @@ class _DataGridFilterBarState extends material.State { ], if (widget.onClose != null) ...[ const Gap(2), - material.IconButton( + QueryaIconButton( + density: QueryaIconButtonDensity.dense, icon: const material.Icon(material.Icons.close, size: 14), - padding: material.EdgeInsets.zero, - constraints: const material.BoxConstraints(minWidth: 20, minHeight: 20), color: cs.mutedForeground, tooltip: 'Close filter (Esc)', onPressed: () { diff --git a/lib/features/workspace/data_grid_value_panel.dart b/lib/features/workspace/data_grid_value_panel.dart index 8ce91138..7729c640 100644 --- a/lib/features/workspace/data_grid_value_panel.dart +++ b/lib/features/workspace/data_grid_value_panel.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; import 'package:querya_desktop/shared/widgets/querya_dropdown.dart'; +import 'package:querya_desktop/shared/widgets/querya_icon_button.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'xml_html_formatter.dart'; @@ -298,10 +299,9 @@ class _DataGridValuePanelState extends material.State { overflow: material.TextOverflow.ellipsis, ).small().semiBold(), ), - material.IconButton( + QueryaIconButton( + density: QueryaIconButtonDensity.dense, icon: const material.Icon(material.Icons.close, size: 14), - padding: material.EdgeInsets.zero, - constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24), color: cs.mutedForeground, onPressed: widget.onClose, ), @@ -361,20 +361,20 @@ class _DataGridValuePanelState extends material.State { ), ], const material.Spacer(), - material.IconButton( + QueryaIconButton( + density: QueryaIconButtonDensity.dense, icon: material.Icon( _wordWrap ? material.Icons.wrap_text : material.Icons.notes, size: 14, ), - padding: material.EdgeInsets.zero, - constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24), - color: _wordWrap ? cs.primary : cs.mutedForeground, + isActive: _wordWrap, + color: cs.mutedForeground, + activeColor: cs.primary, onPressed: () => setState(() => _wordWrap = !_wordWrap), ), - material.IconButton( + QueryaIconButton( + density: QueryaIconButtonDensity.dense, icon: const material.Icon(material.Icons.copy_rounded, size: 14), - padding: material.EdgeInsets.zero, - constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24), color: cs.mutedForeground, onPressed: () { Clipboard.setData(ClipboardData(text: _controller.text)); diff --git a/test/features/postgresql/postgres_stats_history_test.dart b/test/features/postgresql/postgres_stats_history_test.dart new file mode 100644 index 00000000..5f1b0ba1 --- /dev/null +++ b/test/features/postgresql/postgres_stats_history_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/stats/metric_history.dart'; +import 'package:querya_desktop/features/postgresql/postgres_stats_view.dart'; + +Map _stats({ + int active = 3, + int total = 10, + List commits = const [5], +}) => + { + 'connections_active': active, + 'connections_total': total, + 'databases': [ + for (final c in commits) {'xact_commit': c}, + ], + }; + +void main() { + final t0 = DateTime.utc(2026, 10, 9, 12); + + group('server stats history (#1165)', () { + test('the first poll records the connections, no transaction rate', () { + final h = MetricHistory(); + recordServerStats(h, _stats(), t0); + expect(h.series('connections_active').map((s) => s.value), [3]); + expect(h.series('connections_total').map((s) => s.value), [10]); + expect(h.series('transactions_per_second'), isEmpty); + }); + + test('the second poll gives the first transaction rate', () { + final h = MetricHistory(); + recordServerStats(h, _stats(commits: [100]), t0); + recordServerStats( + h, + _stats(commits: [130, 20]), + t0.add(const Duration(seconds: 5)), + ); + // 150 commits in all, 100 before: 50 in five seconds. + expect(h.series('transactions_per_second').single.value, 10); + expect(h.series('connections_active'), hasLength(2)); + }); + + test('a database that reports no commits counts as zero', () { + final h = MetricHistory(); + recordServerStats(h, {'databases': [{}]}, t0); + recordServerStats( + h, + {'databases': [{}]}, + t0.add(const Duration(seconds: 5)), + ); + expect(h.series('transactions_per_second').single.value, 0); + }); + + test('a server restart (counters go down) gives no negative rate', () { + final h = MetricHistory(); + recordServerStats(h, _stats(commits: [500]), t0); + recordServerStats( + h, + _stats(commits: [4]), + t0.add(const Duration(seconds: 5)), + ); + expect(h.series('transactions_per_second'), isEmpty); + }); + + test('a poll without databases still records the connections', () { + final h = MetricHistory(); + recordServerStats( + h, + {'connections_active': 1, 'connections_total': 2}, + t0, + ); + expect(h.series('connections_active').single.value, 1); + expect(h.series('connections_total').single.value, 2); + expect(h.series('transactions_per_second'), isEmpty); + }); + + test('a poll with missing connection counts records zero', () { + final h = MetricHistory(); + recordServerStats(h, const {}, t0); + expect(h.series('connections_active').single.value, 0); + expect(h.series('connections_total').single.value, 0); + }); + }); +} diff --git a/test/features/workspace/query_plan_dialects_test.dart b/test/features/workspace/query_plan_dialects_test.dart new file mode 100644 index 00000000..6ca91403 --- /dev/null +++ b/test/features/workspace/query_plan_dialects_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/workspace/query_plan.dart'; + +/// MySQL `EXPLAIN FORMAT=JSON` of a join of orders, customers and items. +/// Prefix costs are cumulative: 30, then 70, then the query cost 120.5. +const _mysqlJoin = ''' +{"query_block": {"select_id": 1, "cost_info": {"query_cost": "120.50"}, + "nested_loop": [ + {"table": {"table_name": "orders", "access_type": "ALL", + "rows_examined_per_scan": 1000, "filtered": "100.00", + "cost_info": {"prefix_cost": "30.00"}}}, + {"table": {"table_name": "customers", "access_type": "eq_ref", + "rows_examined_per_scan": 1, "filtered": "100.00", + "cost_info": {"prefix_cost": "70.00"}}}, + {"table": {"table_name": "items", "access_type": "ref", + "key": "items_order", "rows_examined_per_scan": 3, + "cost_info": {"prefix_cost": "120.50"}}} + ]}}'''; + +void main() { + group('MySQL plan of a three-table join (#1164)', () { + test('each table is a child of the query block, in join order', () { + final root = QueryPlanParser.fromMysqlJson(_mysqlJoin)!; + expect(root.operation, 'Query block'); + expect(root.children.map((c) => c.relation), + ['orders', 'customers', 'items']); + expect(root.children.map((c) => c.operation), ['ALL', 'eq_ref', 'ref']); + }); + + test('each table keeps its own part of the query cost', () { + final root = QueryPlanParser.fromMysqlJson(_mysqlJoin)!; + expect(root.cost, 120.5); + expect(root.children.map((c) => c.ownCost), [30, 40, 50.5]); + }); + + test('the most expensive table is highlighted', () { + final root = QueryPlanParser.fromMysqlJson(_mysqlJoin)!; + expect(QueryPlanParser.hottest(root)!.relation, 'items'); + }); + + test('rows examined per scan come through as estimates', () { + final root = QueryPlanParser.fromMysqlJson(_mysqlJoin)!; + expect(root.children.map((c) => c.estimatedRows), [1000, 1, 3]); + }); + + test('a plan with no query block gives no tree', () { + expect(QueryPlanParser.fromMysqlJson('{}'), isNull); + expect(QueryPlanParser.fromMysqlJson('{"query_block": 1}'), isNull); + expect(QueryPlanParser.fromMysqlJson('not json'), isNull); + }); + + test('equal costs: the first table is the most expensive', () { + const plan = ''' +{"query_block": {"cost_info": {"query_cost": "20.00"}, + "nested_loop": [ + {"table": {"table_name": "a", "access_type": "ALL", + "cost_info": {"prefix_cost": "10.00"}}}, + {"table": {"table_name": "b", "access_type": "ALL", + "cost_info": {"prefix_cost": "20.00"}}} + ]}}'''; + final root = QueryPlanParser.fromMysqlJson(plan)!; + expect(root.children.map((c) => c.ownCost), [10, 10]); + expect(QueryPlanParser.hottest(root)!.relation, 'a'); + }); + }); + + group('SQLite plan of a three-table join (#1164)', () { + final rows = [ + (id: 2, parent: 0, detail: 'SCAN orders'), + (id: 3, parent: 0, detail: 'SEARCH customers USING INTEGER PRIMARY KEY (rowid=?)'), + (id: 4, parent: 0, detail: 'SEARCH items USING INDEX items_order (order_id=?)'), + ]; + + test('the three steps are children of the plan, in order', () { + final root = QueryPlanParser.fromSqliteRows(rows)!; + expect(root.operation, 'QUERY PLAN'); + expect(root.children.map((c) => c.operation), ['SCAN', 'SEARCH', 'SEARCH']); + expect(root.children.map((c) => c.relation), + ['orders', 'customers', 'items']); + }); + + test('each step keeps its full detail text', () { + final root = QueryPlanParser.fromSqliteRows(rows)!; + expect(root.children[2].details['detail'], + 'SEARCH items USING INDEX items_order (order_id=?)'); + }); + + test('SQLite has no costs, so nothing is highlighted', () { + final root = QueryPlanParser.fromSqliteRows(rows)!; + expect(QueryPlanParser.hottest(root), isNull); + }); + }); +} diff --git a/test/guards/no_material_segmented_or_icon_buttons_results_test.dart b/test/guards/no_material_segmented_or_icon_buttons_results_test.dart index 4183b830..c17e0a9e 100644 --- a/test/guards/no_material_segmented_or_icon_buttons_results_test.dart +++ b/test/guards/no_material_segmented_or_icon_buttons_results_test.dart @@ -8,6 +8,9 @@ void main() { const scoped = [ 'lib/features/workspace/results_tab.dart', 'lib/features/workspace/data_grid_groupings_view.dart', + // Pulled into the results tab through its imports. + 'lib/features/workspace/data_grid_filter_bar.dart', + 'lib/features/workspace/data_grid_value_panel.dart', 'lib/features/results/charts/quick_chart_view.dart', 'lib/features/mongodb/mongo_query_workspace.dart', ]; diff --git a/test/shared/widgets/querya_sparkline_test.dart b/test/shared/widgets/querya_sparkline_test.dart new file mode 100644 index 00000000..2c68ad7a --- /dev/null +++ b/test/shared/widgets/querya_sparkline_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/stats/metric_history.dart'; +import 'package:querya_desktop/shared/widgets/querya_sparkline.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + Future pumpSparkline( + WidgetTester t, + List samples, + ) async { + await t.pumpWidget(queryaThemeTestShell( + child: QueryaSparkline( + samples: samples, + color: material.Colors.blue, + ), + )); + await t.pump(); + } + + String tooltipMessage(WidgetTester t) => + t.widget(find.byType(material.Tooltip)).message!; + + group('QueryaSparkline (#1165)', () { + testWidgets('no tooltip before the first sample', (t) async { + await pumpSparkline(t, const []); + expect(find.byType(material.Tooltip), findsNothing); + }); + + testWidgets('one sample: the tooltip names it, with its time', (t) async { + await pumpSparkline(t, [ + MetricSample(DateTime(2026, 10, 9, 12, 0, 6), 42.5), + ]); + expect(tooltipMessage(t), '42.5 at 12:00:06'); + }); + + testWidgets('the tooltip names the newest sample when more arrive', + (t) async { + await pumpSparkline(t, [ + MetricSample(DateTime(2026, 10, 9, 12, 0, 1), 10), + MetricSample(DateTime(2026, 10, 9, 12, 0, 6), 42.5), + ]); + expect(tooltipMessage(t), '42.5 at 12:00:06'); + + await pumpSparkline(t, [ + MetricSample(DateTime(2026, 10, 9, 12, 0, 1), 10), + MetricSample(DateTime(2026, 10, 9, 12, 0, 6), 42.5), + MetricSample(DateTime(2026, 10, 9, 12, 0, 11), 7.3), + ]); + expect(tooltipMessage(t), '7.3 at 12:00:11'); + }); + + testWidgets('values from 100 show as whole numbers', (t) async { + await pumpSparkline(t, [ + MetricSample(DateTime(2026, 10, 9, 12, 0, 1), 1234.6), + ]); + expect(tooltipMessage(t), '1235 at 12:00:01'); + }); + + testWidgets('a flat series does not throw', (t) async { + await pumpSparkline(t, [ + MetricSample(DateTime(2026, 10, 9, 12, 0, 1), 5), + MetricSample(DateTime(2026, 10, 9, 12, 0, 6), 5), + ]); + expect(t.takeException(), isNull); + }); + }); +}