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
41 changes: 41 additions & 0 deletions lib/core/database/query_error_text.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import 'dart:convert';

import 'package:postgres/postgres.dart' show ServerException;

/// 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.
Expand Down Expand Up @@ -69,4 +73,41 @@ class QueryErrorText {

static String _cap(String s) =>
s.length <= _maxSummary ? s : '${s.substring(0, _maxSummary)}…';

/// `SELECT * FROM (\n` the wrapper puts before the user's SQL.
static const _wrapperPrefix = 'SELECT * FROM (\n';

// SQLite: `Causing statement (at position 29): ...`, a byte offset into the
// statement the driver prepared.
static final _sqlitePosition = RegExp(r'\(at position (\d+)\)');

/// Where in [sql], the text that was sent, the error is: a 0-based offset in
/// characters, or null when the driver does not say (MySQL) or the place is
/// not in [sql].
///
/// PostgreSQL gives a 1-based character position in the statement; SQLite a
/// byte offset in the statement it prepared, which is the user's SQL inside
/// the app's wrapper when [sql] is a query (#1144), so the wrapper's prefix
/// is taken off. The row limit the app appends comes after the text, so it
/// does not move anything.
static int? offsetIn(Object error, String sql) {
if (error is ServerException) return offsetOfPgPosition(error.position, sql);
final text = error.toString();
final m = _sqlitePosition.firstMatch(text);
if (m == null) return null;
var bytes = int.parse(m[1]!);
if (text.contains(_wrapperPrefix)) bytes -= _wrapperPrefix.length;
if (bytes < 0) return null;
final encoded = utf8.encode(sql);
if (bytes > encoded.length) return null;
return utf8.decode(encoded.sublist(0, bytes), allowMalformed: true).length;
}

/// The offset in [sql] of a PostgreSQL `position` (1-based, in characters),
/// or null when there is none or it lies outside [sql].
static int? offsetOfPgPosition(int? position, String sql) {
if (position == null) return null;
final offset = position - 1;
return offset >= 0 && offset <= sql.length ? offset : null;
}
}
12 changes: 3 additions & 9 deletions lib/features/mongodb/mongo_query_workspace.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import 'package:querya_desktop/features/mongodb/mongo_documents_json_view.dart';
import 'package:querya_desktop/features/mongodb/mongo_documents_table_view.dart';
import 'package:querya_desktop/features/mongodb/mongo_query_executor.dart';
import 'package:querya_desktop/features/mongodb/mql_parser.dart';
import 'package:querya_desktop/core/database/query_error_text.dart';
import 'package:querya_desktop/shared/widgets/query_error_view.dart';
import 'package:querya_desktop/shared/widgets/widgets.dart';
import 'package:querya_desktop/core/ui/querya_control_tokens.dart';

Expand Down Expand Up @@ -499,15 +501,7 @@ class _MongoQueryWorkspaceState extends material.State<MongoQueryWorkspace> {
],
),
const Gap(8),
material.SelectableText(
tab.error!,
style: material.TextStyle(
color: cs.destructive,
fontFamily: QueryaTypography.mono,
fontFamilyFallback: QueryaTypography.monoFontFamilyFallback,
fontSize: 12.5,
),
),
QueryErrorView(error: QueryErrorText.parse(tab.error!), inline: true),
],
),
);
Expand Down
15 changes: 15 additions & 0 deletions lib/features/workspace/generic_sql_workspace.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:querya_desktop/core/erd/erd_saved_layout.dart';
import 'package:querya_desktop/core/database/query_error_text.dart';
import 'package:querya_desktop/core/database/statement_queue.dart';
import 'package:querya_desktop/core/database/sql_statement_splitter.dart';
import 'dart:async';
Expand Down Expand Up @@ -810,6 +811,20 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
} catch (e) {
if (mounted) {
invalidatePane(session);
// One statement: the caret goes where the driver says it failed.
if (sent.length == 1) {
final offset = QueryErrorText.offsetIn(e, sent.single);
if (offset != null) {
final runStart = statementAtCursor
? scopeStart + spans.single.start
: scopeStart + (scope.length - scope.trimLeft().length);
// The text may have been edited while the query ran.
final at = (runStart + offset)
.clamp(0, session.controller.text.length);
session.controller.selection =
material.TextSelection.collapsed(offset: at);
}
}
setState(() {
session.error = e.toString();
session.running = false;
Expand Down
106 changes: 11 additions & 95 deletions lib/features/workspace/results_tab.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ 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';
import 'package:querya_desktop/features/workspace/data_grid_filter_bar.dart';
Expand All @@ -17,6 +16,7 @@ import 'package:querya_desktop/features/workspace/grid_filter_engine.dart';
import 'package:querya_desktop/features/workspace/grid_selection_calc_engine.dart';
import 'package:querya_desktop/features/workspace/result_grid_view.dart';
import 'package:querya_desktop/shared/services/data_export_service.dart';
import 'package:querya_desktop/shared/widgets/query_error_view.dart';
import 'package:querya_desktop/shared/widgets/widgets.dart';
import 'package:querya_desktop/core/ui/querya_control_tokens.dart';

Expand Down Expand Up @@ -86,8 +86,6 @@ 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
Expand Down Expand Up @@ -254,10 +252,6 @@ 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 @@ -388,93 +382,6 @@ class _ResultsTabState extends material.State<ResultsTab> {
super.dispose();
}

/// 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)),
),
],
),
],
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: error.detail,
style: material.TextStyle(
color: cs.mutedForeground,
fontSize: 13,
),
),
),
],
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) {
Expand All @@ -488,7 +395,16 @@ class _ResultsTabState extends material.State<ResultsTab> {
}

if (widget.errorMessage != null) {
return _buildError(context, QueryErrorText.parse(widget.errorMessage!));
return material.Center(
key: const material.ValueKey('results_mode_error'),
child: material.Padding(
padding: const material.EdgeInsets.all(32),
child: QueryErrorView(
error: QueryErrorText.parse(widget.errorMessage!),
action: widget.errorAction,
),
),
);
}

if (widget.columns.isEmpty && widget.rows.isEmpty && widget.stagingBuffer == null) {
Expand Down
130 changes: 130 additions & 0 deletions lib/shared/widgets/query_error_view.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import 'dart:async' show unawaited;

import 'package:flutter/material.dart' as material;
import 'package:flutter/services.dart';
import 'package:querya_desktop/core/database/query_error_text.dart';
import 'package:querya_desktop/core/ui/querya_control_tokens.dart';
import 'package:querya_desktop/core/widgets/virtual_selectable_text_view.dart';
import 'package:querya_desktop/shared/widgets/widgets.dart';

/// A failed query: the driver's message as the headline, the rest (without the
/// statement the app wrapped around the user's SQL) behind "Show details", and
/// a Copy button (#1557). Shared by the SQL results pane and the Mongo query
/// workspace.
///
/// [inline] is for a panel that has its own title row (a box above results):
/// left aligned and without the large icon and title.
class QueryErrorView extends material.StatefulWidget {
const QueryErrorView({
super.key,
required this.error,
this.action,
this.inline = false,
});

final QueryErrorText error;

/// Shown under the error, e.g. a way to fix the connection.
final material.Widget? action;
final bool inline;

@override
material.State<QueryErrorView> createState() => _QueryErrorViewState();
}

class _QueryErrorViewState extends material.State<QueryErrorView> {
bool _showDetails = false;
bool _copied = false;

@override
void didUpdateWidget(covariant QueryErrorView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.error.detail != widget.error.detail) {
_showDetails = false;
_copied = false;
}
}

Future<void> _copy() async {
await Clipboard.setData(ClipboardData(text: widget.error.detail));
if (!mounted) return;
setState(() => _copied = true);
await Future<void>.delayed(const Duration(seconds: 2));
if (mounted) setState(() => _copied = false);
}

@override
material.Widget build(material.BuildContext context) {
final cs = Theme.of(context).colorScheme;
final error = widget.error;
final inline = widget.inline;
final align = inline
? material.CrossAxisAlignment.start
: material.CrossAxisAlignment.center;
return material.Column(
mainAxisAlignment: material.MainAxisAlignment.center,
mainAxisSize: material.MainAxisSize.min,
crossAxisAlignment: align,
children: [
if (!inline) ...[
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: inline ? material.TextAlign.start : 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: _showDetails ? 'Hide details' : 'Show details',
icon: _showDetails
? material.Icons.expand_less_rounded
: material.Icons.expand_more_rounded,
size: QueryaControlSize.sm,
onPressed: () => setState(() => _showDetails = !_showDetails),
),
const Gap(6),
QueryaButton.ghost(
label: _copied ? 'Copied' : 'Copy error',
icon: _copied
? material.Icons.check_rounded
: material.Icons.copy_rounded,
size: QueryaControlSize.sm,
onPressed: () => unawaited(_copy()),
),
],
),
],
if (error.hasDetail && _showDetails) ...[
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: error.detail,
style: material.TextStyle(
color: cs.mutedForeground,
fontSize: 13,
),
),
),
],
if (widget.action != null) ...[
const Gap(16),
widget.action!,
],
],
);
}
}
Loading
Loading