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
72 changes: 72 additions & 0 deletions lib/core/database/query_error_text.dart
Original file line number Diff line number Diff line change
@@ -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, <msg>,
// 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<sql>\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)}…';
}
130 changes: 96 additions & 34 deletions lib/features/workspace/results_tab.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -84,6 +85,10 @@ class ResultsTab extends material.StatefulWidget {
}

class _ResultsTabState extends material.State<ResultsTab> {
/// 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.
Expand Down Expand Up @@ -249,6 +254,10 @@ class _ResultsTabState extends material.State<ResultsTab> {

@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) ||
Expand Down Expand Up @@ -379,56 +388,109 @@ class _ResultsTabState extends material.State<ResultsTab> {
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<void> _copyError(String text) async {
await Clipboard.setData(ClipboardData(text: text));
if (!mounted) return;
setState(() => _errorCopied = true);
await Future<void>.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(
Expand Down
73 changes: 73 additions & 0 deletions test/core/database/query_error_text_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
Loading
Loading