diff --git a/lib/core/database/query_error_text.dart b/lib/core/database/query_error_text.dart new file mode 100644 index 00000000..91a3488a --- /dev/null +++ b/lib/core/database/query_error_text.dart @@ -0,0 +1,72 @@ +/// A failed query as the results pane shows it (#1557): one readable line, +/// and the driver's text without the statement the app wrapped around the +/// user's SQL. +class QueryErrorText { + const QueryErrorText({required this.summary, required this.detail}); + + /// Reads [raw], the `toString()` of whatever the driver threw. + factory QueryErrorText.parse(String raw) { + final detail = _unwrap(raw.trim()); + return QueryErrorText(summary: _summary(detail), detail: detail); + } + + /// The driver's message alone: `no such column: u.name`. + final String summary; + + /// The whole driver text, minus the app's own wrapping. + final String detail; + + /// Whether [detail] says more than [summary]. + bool get hasDetail => detail != summary; + + static const _maxSummary = 300; + + // SQLite (sqlite3): `SqliteException(1): while preparing statement, , + // SQL logic error (code 1)`. + static final _sqlite = RegExp( + r'SqliteException\(\d+\): (?:while [^,\n]+, )?([^\n]+?)' + r'(?:, [^,\n]*\(code \d+\))?(?:\n|$)', + ); + + // PostgreSQL (postgres 3): `Severity.error 42703: column "x" does not exist + // detail: ... hint: ...`. + static final _postgres = RegExp( + r'\b(?:Severity\.)?(?:error|fatal|panic|ERROR|FATAL|PANIC) ' + r'[0-9A-Z]{5}: ([^\n]+?)(?= (?:detail|hint|table|column|constraint)\b|\n|$)', + ); + + // MySQL (mysql_client): `MySQLServerException [1054]: Unknown column ...`. + static final _mysql = RegExp(r'MySQLServerException \[\d+\]: ([^\n]+)'); + + /// The `SELECT * FROM (\n\n)` that keeps SQLite column names unique + /// (#1144), with the row limit the app adds after it. + static final _wrapper = RegExp( + r'SELECT \* FROM \(\n([\s\S]*?)\n\)(?:\s*LIMIT \d+(?:\s+OFFSET \d+)?)?', + ); + + // Offsets count from the start of the wrapper, not of the user's text. + static final _position = RegExp(r' \(at position \d+\)'); + + static String _unwrap(String raw) { + if (!_wrapper.hasMatch(raw)) return raw; + return raw + .replaceAllMapped(_wrapper, (m) => m[1]!) + .replaceAll(_position, ''); + } + + static String _summary(String detail) { + for (final pattern in [_sqlite, _postgres, _mysql]) { + final m = pattern.firstMatch(detail); + final message = m?.group(1)?.trim(); + if (message != null && message.isNotEmpty) return _cap(message); + } + final first = detail + .split('\n') + .map((l) => l.trim()) + .firstWhere((l) => l.isNotEmpty, orElse: () => detail); + return _cap(first.startsWith('Exception: ') ? first.substring(11) : first); + } + + static String _cap(String s) => + s.length <= _maxSummary ? s : '${s.substring(0, _maxSummary)}…'; +} diff --git a/lib/features/workspace/results_tab.dart b/lib/features/workspace/results_tab.dart index 57be122c..1c4dc1e4 100644 --- a/lib/features/workspace/results_tab.dart +++ b/lib/features/workspace/results_tab.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; import 'package:querya_desktop/core/security/pii_masking_controller.dart'; import 'package:querya_desktop/core/actions/data_grid_command_bridge.dart'; +import 'package:querya_desktop/core/database/query_error_text.dart'; import 'package:querya_desktop/core/widgets/virtual_selectable_text_view.dart'; import 'package:querya_desktop/features/results/charts/quick_chart_view.dart'; import 'package:querya_desktop/features/workspace/data_grid_calc_bar.dart'; @@ -84,6 +85,10 @@ class ResultsTab extends material.StatefulWidget { } class _ResultsTabState extends material.State { + /// The driver text of a query error is folded away until asked for (#1557). + bool _showErrorDetails = false; + bool _errorCopied = false; + /// Selection / focus state lives in notifiers so a navigation step rebuilds /// only the widgets that show it (toolbar, value panel, calc bar), not the /// whole tab and its grid. @@ -249,6 +254,10 @@ class _ResultsTabState extends material.State { @override void didUpdateWidget(covariant ResultsTab oldWidget) { + if (oldWidget.errorMessage != widget.errorMessage) { + _showErrorDetails = false; + _errorCopied = false; + } super.didUpdateWidget(oldWidget); _syncGridBridge(); if (!identical(oldWidget.rows, widget.rows) || @@ -379,56 +388,109 @@ class _ResultsTabState extends material.State { super.dispose(); } - @override - material.Widget build(material.BuildContext context) { - if (widget.isLoading) { - return const material.Center( - key: material.ValueKey('results_mode_loading'), - child: QueryaSpinner( - size: QueryaSpinnerSize.sm, - label: 'Executing query...', - ), - ); - } - - if (widget.errorMessage != null) { - return material.Center( - key: const material.ValueKey('results_mode_error'), - child: material.Padding( - padding: const material.EdgeInsets.all(32), - child: material.Column( - mainAxisAlignment: material.MainAxisAlignment.center, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Icon( - material.Icons.error_outline_rounded, - size: 48, - color: Theme.of(context).colorScheme.destructive, + /// The driver's message as the headline, the rest (without the app's own + /// statement wrapping) behind "Show details", and a Copy button (#1557). + material.Widget _buildError( + material.BuildContext context, + QueryErrorText error, + ) { + final cs = Theme.of(context).colorScheme; + return material.Center( + key: const material.ValueKey('results_mode_error'), + child: material.Padding( + padding: const material.EdgeInsets.all(32), + child: material.Column( + mainAxisAlignment: material.MainAxisAlignment.center, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + material.Icons.error_outline_rounded, + size: 48, + color: cs.destructive, + ), + const Gap(16), + const Text('Query Error').large().semiBold(), + const Gap(8), + material.SelectableText( + error.summary, + textAlign: material.TextAlign.center, + style: material.TextStyle(color: cs.foreground, fontSize: 14), + ), + if (error.hasDetail) ...[ + const Gap(12), + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + QueryaButton.ghost( + label: _showErrorDetails ? 'Hide details' : 'Show details', + icon: _showErrorDetails + ? material.Icons.expand_less_rounded + : material.Icons.expand_more_rounded, + size: QueryaControlSize.sm, + onPressed: () => setState( + () => _showErrorDetails = !_showErrorDetails, + ), + ), + const Gap(6), + QueryaButton.ghost( + label: _errorCopied ? 'Copied' : 'Copy error', + icon: _errorCopied + ? material.Icons.check_rounded + : material.Icons.copy_rounded, + size: QueryaControlSize.sm, + onPressed: () => unawaited(_copyError(error.detail)), + ), + ], ), - const Gap(16), - const Text('Query Error').large().semiBold(), + ], + if (error.hasDetail && _showErrorDetails) ...[ const Gap(8), // A long driver message scrolls inside the space left, instead // of pushing the column past the pane. material.Flexible( child: VirtualSelectableTextView( - text: widget.errorMessage!, + text: error.detail, style: material.TextStyle( - color: Theme.of(context).colorScheme.mutedForeground, + color: cs.mutedForeground, fontSize: 13, ), ), ), - if (widget.errorAction != null) ...[ - const Gap(16), - widget.errorAction!, - ], ], - ), + if (widget.errorAction != null) ...[ + const Gap(16), + widget.errorAction!, + ], + ], + ), + ), + ); + } + + Future _copyError(String text) async { + await Clipboard.setData(ClipboardData(text: text)); + if (!mounted) return; + setState(() => _errorCopied = true); + await Future.delayed(const Duration(seconds: 2)); + if (mounted) setState(() => _errorCopied = false); + } + + @override + material.Widget build(material.BuildContext context) { + if (widget.isLoading) { + return const material.Center( + key: material.ValueKey('results_mode_loading'), + child: QueryaSpinner( + size: QueryaSpinnerSize.sm, + label: 'Executing query...', ), ); } + if (widget.errorMessage != null) { + return _buildError(context, QueryErrorText.parse(widget.errorMessage!)); + } + if (widget.columns.isEmpty && widget.rows.isEmpty && widget.stagingBuffer == null) { if (widget.statusLine != null) { return material.Padding( diff --git a/test/core/database/query_error_text_test.dart b/test/core/database/query_error_text_test.dart new file mode 100644 index 00000000..b289642e --- /dev/null +++ b/test/core/database/query_error_text_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/query_error_text.dart'; + +void main() { + group('QueryErrorText (#1557)', () { + // The example from the issue, as sqflite_common_ffi prints it. + const sqlite = + 'SqliteFfiException(sqlite_error: 1, , SqliteException(1): while preparing ' + 'statement, no such column: u.name, SQL logic error (code 1)\n' + ' Causing statement (at position 29): SELECT * FROM (\n' + 'SELECT o.id, u.name FROM orders o JOIN users u ON u.id = o.user_id ' + 'ORDER BY o.id LIMIT 200\n' + ')\n' + 'LIMIT 5000}) DatabaseException(SqliteException(1): while preparing ' + 'statement, no such column: u.name, SQL logic error (code 1)\n' + ' Causing statement (at position 29): SELECT * FROM (\n' + 'SELECT o.id, u.name FROM orders o JOIN users u ON u.id = o.user_id ' + 'ORDER BY o.id LIMIT 200\n' + ')'; + + test('SQLite: the message alone, without the wrapper or its position', () { + final e = QueryErrorText.parse(sqlite); + expect(e.summary, 'no such column: u.name'); + expect(e.hasDetail, isTrue); + expect(e.detail, isNot(contains('SELECT * FROM ('))); + expect(e.detail, isNot(contains('LIMIT 5000'))); + expect(e.detail, isNot(contains('at position'))); + expect(e.detail, contains('ORDER BY o.id LIMIT 200')); + expect(e.detail, contains('no such column: u.name')); + }); + + test('PostgreSQL: the server message, without detail and hint', () { + final e = QueryErrorText.parse( + 'Severity.error 42703: column "nme" does not exist hint: Perhaps you meant "name".', + ); + expect(e.summary, 'column "nme" does not exist'); + expect(e.detail, contains('hint: Perhaps')); + }); + + test('MySQL: the server message after the error code', () { + final e = QueryErrorText.parse( + "MySQLServerException [1054]: Unknown column 'nme' in 'field list'", + ); + expect(e.summary, "Unknown column 'nme' in 'field list'"); + }); + + test('anything else: its first line; one line has no extra detail', () { + final one = QueryErrorText.parse('connection refused'); + expect(one.summary, 'connection refused'); + expect(one.hasDetail, isFalse); + + final two = QueryErrorText.parse('Exception: socket closed\nat line 3'); + expect(two.summary, 'socket closed'); + expect(two.hasDetail, isTrue); + }); + + test('text without the wrapper is left as it is', () { + const raw = 'SqliteException(1): while preparing statement, near "SELEC": ' + 'syntax error, SQL logic error (code 1)\n' + ' Causing statement (at position 0): SELEC 1'; + final e = QueryErrorText.parse(raw); + expect(e.summary, 'near "SELEC": syntax error'); + expect(e.detail, raw); + }); + + test('a very long message is cut in the summary only', () { + final long = 'x' * 1000; + final e = QueryErrorText.parse(long); + expect(e.summary.length, lessThan(320)); + expect(e.detail, long); + }); + }); +} diff --git a/test/features/workspace/results_tab_test.dart b/test/features/workspace/results_tab_test.dart index 69615aa8..b2f4ef90 100644 --- a/test/features/workspace/results_tab_test.dart +++ b/test/features/workspace/results_tab_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart' show LogicalKeyboardKey; +import 'package:flutter/services.dart' + show LogicalKeyboardKey, MethodCall, SystemChannels; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/motion/querya_fade_slide.dart'; import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; @@ -1457,4 +1458,84 @@ void main() { } }); }); + + group('ResultsTab query errors (#1557)', () { + const raw = + 'SqliteFfiException(sqlite_error: 1, , SqliteException(1): while preparing ' + 'statement, no such column: u.name, SQL logic error (code 1)\n' + ' Causing statement (at position 29): SELECT * FROM (\n' + 'SELECT o.id, u.name FROM orders o JOIN users u ON u.id = o.user_id\n' + ')\n' + 'LIMIT 5000})'; + + Future pumpError(WidgetTester tester) async { + await tester.pumpWidget( + resultsShell( + child: const material.Scaffold( + body: ResultsTab(isLoading: false, errorMessage: raw), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('the headline is the driver message; details are folded', + (tester) async { + await pumpError(tester); + + expect(find.text('no such column: u.name'), findsOneWidget); + expect(find.textContaining('SqliteFfiException'), findsNothing); + expect(find.textContaining('SELECT * FROM ('), findsNothing); + + await tester.tap(find.text('Show details')); + await tester.pumpAndSettle(); + + expect(find.textContaining('SqliteFfiException'), findsWidgets); + expect(find.textContaining('SELECT * FROM ('), findsNothing); + expect(find.textContaining('LIMIT 5000'), findsNothing); + expect(find.text('Hide details'), findsOneWidget); + }); + + testWidgets('Copy error puts the unwrapped text on the clipboard', + (tester) async { + String? copied; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (MethodCall call) async { + if (call.method == 'Clipboard.setData') { + copied = (call.arguments as Map)['text'] as String?; + } + return null; + }, + ); + addTearDown(() => tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null)); + + await pumpError(tester); + await tester.tap(find.text('Copy error')); + await tester.pump(); + + expect(copied, contains('no such column: u.name')); + expect(copied, isNot(contains('SELECT * FROM ('))); + expect(find.text('Copied'), findsOneWidget); + + await tester.pump(const Duration(seconds: 3)); + expect(find.text('Copy error'), findsOneWidget); + }); + + testWidgets('a one-line error has no details to show', (tester) async { + await tester.pumpWidget( + resultsShell( + child: const material.Scaffold( + body: ResultsTab(isLoading: false, errorMessage: 'connection refused'), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('connection refused'), findsOneWidget); + expect(find.text('Show details'), findsNothing); + expect(find.text('Copy error'), findsNothing); + }); + }); }