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
35 changes: 22 additions & 13 deletions lib/features/postgresql/postgres_stats_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,26 @@ class PostgresStatsView extends material.StatefulWidget {
material.State<PostgresStatsView> 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<String, dynamic> 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<PostgresStatsView> {
PgLease? _lease;
PostgresConnection? get _connection => _lease?.connection;
Expand Down Expand Up @@ -104,19 +124,8 @@ class _PostgresStatsViewState extends material.State<PostgresStatsView> {
}

/// Records the poll's numbers. No query is added: the poll already fetched them.
void _recordHistory(Map<String, dynamic> 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<String, dynamic> stats) =>
recordServerStats(_history, stats, DateTime.now());

Future<void> _fetch() async {
final c = _connection;
Expand Down
11 changes: 5 additions & 6 deletions lib/features/workspace/data_grid_filter_bar.dart
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -420,15 +421,14 @@ class _DataGridFilterBarState extends material.State<DataGridFilterBar> {
),
),
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: () {
Expand All @@ -441,10 +441,9 @@ class _DataGridFilterBarState extends material.State<DataGridFilterBar> {
],
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: () {
Expand Down
20 changes: 10 additions & 10 deletions lib/features/workspace/data_grid_value_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -298,10 +299,9 @@ class _DataGridValuePanelState extends material.State<DataGridValuePanel> {
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,
),
Expand Down Expand Up @@ -361,20 +361,20 @@ class _DataGridValuePanelState extends material.State<DataGridValuePanel> {
),
],
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));
Expand Down
84 changes: 84 additions & 0 deletions test/features/postgresql/postgres_stats_history_test.dart
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> _stats({
int active = 3,
int total = 10,
List<int> 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);
});
});
}
93 changes: 93 additions & 0 deletions test/features/workspace/query_plan_dialects_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];
Expand Down
69 changes: 69 additions & 0 deletions test/shared/widgets/querya_sparkline_test.dart
Original file line number Diff line number Diff line change
@@ -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<void> pumpSparkline(
WidgetTester t,
List<MetricSample> samples,
) async {
await t.pumpWidget(queryaThemeTestShell(
child: QueryaSparkline(
samples: samples,
color: material.Colors.blue,
),
));
await t.pump();
}

String tooltipMessage(WidgetTester t) =>
t.widget<material.Tooltip>(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);
});
});
}
Loading