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

import 'package:querya_desktop/core/database/mysql_connection.dart';
import 'package:querya_desktop/core/database/postgres_connection.dart';
import 'package:querya_desktop/core/database/querya_database_exception.dart';
import 'package:querya_desktop/core/database/redis_connection.dart';
import 'package:querya_desktop/core/database/sqlite_connection.dart';

/// Which engine produced an error; only used to tailor the wording.
enum DatabaseDriver { postgres, mysql, sqlite, mongodb, redis, extension }

/// Maps a driver / OS error to a [QueryaDatabaseException].
///
/// Already-mapped exceptions are returned as is. Unrecognized errors become an
/// [UnknownDatabaseException] that keeps the original text.
QueryaDatabaseException mapDatabaseError(
Object error, {
StackTrace? stackTrace,
DatabaseDriver? driver,
}) {
if (error is QueryaDatabaseException) return error;

final raw = _fullText(error);
final lower = raw.toLowerCase();
final engine = _engineName(driver);

QueryaDatabaseException? mapped;

if (_hasAny(lower, const [
'lock wait timeout',
'lock timeout',
'database is locked',
'sqlite_busy',
'sqlite is busy',
'deadlock detected',
'deadlock found',
])) {
mapped = LockTimeoutException(
'The database is locked by another operation',
remediationHint:
'Commit or roll back any open transaction and try again in a moment',
originalError: error,
stackTrace: stackTrace,
);
} else if (_hasAny(lower, const [
'password authentication failed',
'access denied for user',
'authentication failed',
'auth failed',
'wrongpass',
'noauth',
'invalid password',
'invalid username-password',
'sqlstate 28p01',
'sqlstate 28000',
])) {
mapped = AuthFailedException(
'Authentication failed',
detailedExplanation: '$engine rejected the supplied credentials.',
remediationHint: driver == DatabaseDriver.redis
? 'Check the username and password (the server may require '
'"requirepass" or an ACL user)'
: 'Check the username and password in the connection settings',
originalError: error,
stackTrace: stackTrace,
);
} else if (_firstMatch(raw, [
RegExp(r'database "([^"]+)" does not exist', caseSensitive: false),
RegExp(r"unknown database '([^']+)'", caseSensitive: false),
])
case final db?) {
mapped = DatabaseNotFoundException(
'Database "$db" does not exist',
databaseName: db,
remediationHint:
'Check the database name in the connection settings or create it first',
originalError: error,
stackTrace: stackTrace,
);
} else if (_firstMatch(raw, [
RegExp(r'relation "([^"]+)" does not exist', caseSensitive: false),
RegExp(r"table '([^']+)' doesn't exist", caseSensitive: false),
RegExp(r'no such table: ([\w."]+)', caseSensitive: false),
])
case final table?) {
mapped = TableNotFoundException(
'Table "$table" does not exist',
tableName: table,
remediationHint:
'Check the table name and schema, or refresh the object tree',
originalError: error,
stackTrace: stackTrace,
);
} else if (_hasAny(lower, const [
'syntax error',
'error in your sql syntax',
'err syntax',
])) {
mapped = QuerySyntaxException(
'Syntax error in the query',
detailedExplanation: _innermost(raw),
remediationHint: 'Fix the statement near the position reported by the '
'server and run it again',
originalError: error,
stackTrace: stackTrace,
);
} else if (error is TimeoutException ||
_hasAny(lower, const ['timed out', 'timeout expired', 'etimedout'])) {
mapped = ConnectionTimeoutException(
'The operation timed out',
remediationHint: 'Check the network and firewall, or that the server '
'is not overloaded, then try again',
originalError: error,
stackTrace: stackTrace,
);
} else if (error is SocketException ||
_hasAny(lower, const [
'connection refused',
'failed host lookup',
'no route to host',
'network is unreachable',
'name or service not known',
'econnrefused',
'socketexception',
])) {
mapped = HostUnreachableException(
'Cannot reach the database server',
detailedExplanation: '$engine did not accept the connection.',
remediationHint: 'Check the host and port, that the server is running, '
'and any firewall or SSH tunnel settings',
originalError: error,
stackTrace: stackTrace,
);
}

return mapped ??
UnknownDatabaseException(
_innermost(raw),
originalError: error,
stackTrace: stackTrace,
);
}

/// One-line, user-facing text for [error] (message plus remediation hint).
///
/// Unrecognized errors keep their original text, so callers can use this in
/// place of `error.toString()` without losing information.
String describeDatabaseError(Object error, {DatabaseDriver? driver}) {
final mapped = mapDatabaseError(error, driver: driver);
if (mapped is UnknownDatabaseException) return error.toString();
return mapped.displayText;
}

String _engineName(DatabaseDriver? driver) => switch (driver) {
DatabaseDriver.postgres => 'PostgreSQL',
DatabaseDriver.mysql => 'MySQL',
DatabaseDriver.sqlite => 'SQLite',
DatabaseDriver.mongodb => 'MongoDB',
DatabaseDriver.redis => 'Redis',
DatabaseDriver.extension => 'The extension driver',
null => 'The server',
};

/// Error text including the wrapped `cause` of the connection exceptions.
String _fullText(Object error) {
final parts = <String>[error.toString()];
final cause = switch (error) {
PostgresConnectionException(:final cause) => cause,
MysqlConnectionException(:final cause) => cause,
SqliteConnectionException(:final cause) => cause,
_ => null,
};
if (cause != null) parts.add(cause.toString());
if (error is RedisConnectionException) parts.add(error.message);
return parts.join('\n');
}

String _innermost(String raw) {
final line = raw.split('\n').first.trim();
final i = line.lastIndexOf(': ');
final tail = i >= 0 ? line.substring(i + 2).trim() : line;
return tail.isEmpty ? line : tail;
}

bool _hasAny(String lower, List<String> needles) =>
needles.any(lower.contains);

String? _firstMatch(String text, List<RegExp> patterns) {
for (final p in patterns) {
final m = p.firstMatch(text);
if (m != null) return m.group(1);
}
return null;
}

/// Rethrows [error] as a recognized [QueryaDatabaseException], or unchanged
/// when it is not recognized (so existing typed errors keep their type).
Never rethrowMappedDatabaseError(
Object error,
StackTrace stackTrace, {
DatabaseDriver? driver,
}) {
final mapped = mapDatabaseError(error, stackTrace: stackTrace, driver: driver);
Error.throwWithStackTrace(
mapped is UnknownDatabaseException ? error : mapped,
stackTrace,
);
}
5 changes: 3 additions & 2 deletions lib/core/database/mongodb_connection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:mongo_dart/mongo_dart.dart';
import 'package:querya_desktop/core/database/database_error_mapper.dart';
import 'package:querya_desktop/core/security/ssl_certificate_support.dart';
import 'package:querya_desktop/core/storage/connection_secrets_store.dart';

Expand Down Expand Up @@ -200,11 +201,11 @@ class MongoConnection {
_openedDbs[defaultName] = _db!;
}
scrubCredentials();
} catch (e) {
} catch (e, st) {
_isConnected = false;
_db = null;
await _cleanupTempTlsKey();
rethrow;
rethrowMappedDatabaseError(e, st, driver: DatabaseDriver.mongodb);
}
}

Expand Down
5 changes: 3 additions & 2 deletions lib/core/database/mysql_connection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:mysql_client/mysql_client.dart';
import 'package:querya_desktop/core/database/database_error_mapper.dart';
import 'package:querya_desktop/core/database/mysql_result_cells.dart';
import 'package:querya_desktop/core/database/table_schema_meta.dart';
import 'package:querya_desktop/core/security/ssl_certificate_support.dart';
Expand Down Expand Up @@ -218,10 +219,10 @@ class MysqlConnection {
}
_isConnected = true;
scrubCredentials();
} catch (e) {
} catch (e, st) {
_isConnected = false;
_conn = null;
rethrow;
rethrowMappedDatabaseError(e, st, driver: DatabaseDriver.mysql);
}
}

Expand Down
4 changes: 3 additions & 1 deletion lib/core/database/mysql_connection_pool.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:async';

import 'package:querya_desktop/core/database/database_error_mapper.dart';
import 'package:querya_desktop/core/database/connection_pool_lock.dart';
import 'package:querya_desktop/core/database/mysql_connection.dart';
import 'package:querya_desktop/core/storage/local_db.dart';
Expand Down Expand Up @@ -98,7 +99,8 @@ class MysqlConnectionPool {
} catch (e, st) {
Error.throwWithStackTrace(
MysqlConnectionException(
'Failed to acquire MySQL connection for database "$database": $e',
'Failed to acquire MySQL connection for database "$database": '
'${describeDatabaseError(e, driver: DatabaseDriver.mysql)}',
cause: e,
stackTrace: st,
),
Expand Down
4 changes: 3 additions & 1 deletion lib/core/database/postgres_connection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:io' show SecurityContext;

import 'package:flutter/foundation.dart';
import 'package:postgres/postgres.dart';
import 'package:querya_desktop/core/database/database_error_mapper.dart';
import 'package:querya_desktop/core/storage/connection_secrets_store.dart';
import 'package:querya_desktop/core/storage/local_db.dart';

Expand Down Expand Up @@ -247,7 +248,8 @@ class PostgresConnection {
_conn = null;
Error.throwWithStackTrace(
PostgresConnectionException(
'Failed to connect to PostgreSQL${name.isNotEmpty ? ' ($name)' : ''}: $e',
'Failed to connect to PostgreSQL${name.isNotEmpty ? ' ($name)' : ''}: '
'${describeDatabaseError(e, driver: DatabaseDriver.postgres)}',
cause: e,
stackTrace: st,
),
Expand Down
4 changes: 3 additions & 1 deletion lib/core/database/postgres_connection_pool.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:async';

import 'package:querya_desktop/core/database/database_error_mapper.dart';
import 'package:querya_desktop/core/database/connection_pool_lock.dart';
import 'package:querya_desktop/core/database/postgres_connection.dart';
import 'package:querya_desktop/core/storage/local_db.dart';
Expand Down Expand Up @@ -110,7 +111,8 @@ class PostgresConnectionPool {
} catch (e, st) {
Error.throwWithStackTrace(
PostgresConnectionException(
'Failed to acquire PostgreSQL connection for database "$database": $e',
'Failed to acquire PostgreSQL connection for database "$database": '
'${describeDatabaseError(e, driver: DatabaseDriver.postgres)}',
cause: e,
stackTrace: st,
),
Expand Down
Loading
Loading