diff --git a/lib/core/database/destructive_sql_detector.dart b/lib/core/database/destructive_sql_detector.dart new file mode 100644 index 0000000..2548693 --- /dev/null +++ b/lib/core/database/destructive_sql_detector.dart @@ -0,0 +1,422 @@ +/// Categorization of destructive SQL operations that can alter or destroy schema/data. +enum DestructiveSqlType { + dropDatabase, + dropSchema, + dropTable, + dropView, + dropMaterializedView, + truncateTable, + unconditionalDelete; + + String get label => switch (this) { + DestructiveSqlType.dropDatabase => 'DROP DATABASE', + DestructiveSqlType.dropSchema => 'DROP SCHEMA', + DestructiveSqlType.dropTable => 'DROP TABLE', + DestructiveSqlType.dropView => 'DROP VIEW', + DestructiveSqlType.dropMaterializedView => 'DROP MATERIALIZED VIEW', + DestructiveSqlType.truncateTable => 'TRUNCATE TABLE', + DestructiveSqlType.unconditionalDelete => 'UNCONDITIONAL DELETE', + }; + + String get riskLevel => switch (this) { + DestructiveSqlType.dropDatabase => 'CRITICAL', + DestructiveSqlType.dropSchema => 'HIGH', + DestructiveSqlType.dropTable => 'HIGH', + DestructiveSqlType.truncateTable => 'HIGH', + DestructiveSqlType.unconditionalDelete => 'HIGH', + DestructiveSqlType.dropMaterializedView => 'MEDIUM', + DestructiveSqlType.dropView => 'MEDIUM', + }; +} + +/// Represents a single detected destructive operation within an SQL script. +class DestructiveSqlOperation { + const DestructiveSqlOperation({ + required this.type, + required this.targetName, + required this.rawStatement, + }); + + final DestructiveSqlType type; + final String targetName; + final String rawStatement; + + String get description => switch (type) { + DestructiveSqlType.dropDatabase => + 'Permanently drops database "$targetName" and all contained schemas, tables, and records.', + DestructiveSqlType.dropSchema => + 'Permanently drops schema "$targetName" and all contained tables.', + DestructiveSqlType.dropTable => + 'Permanently drops table structure and all data in "$targetName".', + DestructiveSqlType.dropView => + 'Drops view "$targetName".', + DestructiveSqlType.dropMaterializedView => + 'Drops materialized view "$targetName".', + DestructiveSqlType.truncateTable => + 'Quickly deletes all rows from table "$targetName" without transaction rollbacks in some engines.', + DestructiveSqlType.unconditionalDelete => + 'Deletes all rows from table "$targetName" (no WHERE clause detected).', + }; +} + +/// Result of inspecting SQL text for destructive operations. +class DestructiveSqlInspectionResult { + const DestructiveSqlInspectionResult({ + required this.operations, + }); + + final List operations; + + bool get isDestructive => operations.isNotEmpty; + + /// Returns highest risk level present ('CRITICAL', 'HIGH', 'MEDIUM', or 'NONE'). + String get maxRiskLevel { + if (operations.isEmpty) return 'NONE'; + if (operations.any((o) => o.type.riskLevel == 'CRITICAL')) return 'CRITICAL'; + if (operations.any((o) => o.type.riskLevel == 'HIGH')) return 'HIGH'; + return 'MEDIUM'; + } +} + +/// Heuristic analyzer and sanitizer for detecting destructive SQL queries +/// before executing them in the SQL workspace. +abstract final class DestructiveSqlDetector { + static final _dropDatabaseRegex = RegExp( + r'^\s*DROP\s+DATABASE\s+(?:IF\s+EXISTS\s+)?(?:["`]?([a-zA-Z0-9_]+)["`]?)', + caseSensitive: false, + ); + + static final _dropSchemaRegex = RegExp( + r'^\s*DROP\s+SCHEMA\s+(?:IF\s+EXISTS\s+)?(?:["`]?([a-zA-Z0-9_]+)["`]?)', + caseSensitive: false, + ); + + static final _dropTableRegex = RegExp( + r'^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:(?:["`]?([a-zA-Z0-9_]+)["`]?\.)?["`]?([a-zA-Z0-9_]+)["`]?)', + caseSensitive: false, + ); + + static final _dropMatViewRegex = RegExp( + r'^\s*DROP\s+MATERIALIZED\s+VIEW\s+(?:IF\s+EXISTS\s+)?(?:(?:["`]?([a-zA-Z0-9_]+)["`]?\.)?["`]?([a-zA-Z0-9_]+)["`]?)', + caseSensitive: false, + ); + + static final _dropViewRegex = RegExp( + r'^\s*DROP\s+VIEW\s+(?:IF\s+EXISTS\s+)?(?:(?:["`]?([a-zA-Z0-9_]+)["`]?\.)?["`]?([a-zA-Z0-9_]+)["`]?)', + caseSensitive: false, + ); + + static final _truncateRegex = RegExp( + r'^\s*TRUNCATE\s+(?:TABLE\s+)?(?:(?:["`]?([a-zA-Z0-9_]+)["`]?\.)?["`]?([a-zA-Z0-9_]+)["`]?)', + caseSensitive: false, + ); + + static final _deleteRegex = RegExp( + r'^\s*DELETE\s+FROM\s+(?:(?:["`]?([a-zA-Z0-9_]+)["`]?\.)?["`]?([a-zA-Z0-9_]+)["`]?)', + caseSensitive: false, + ); + + /// Strips comments and string literals to prevent false positives when keywords + /// appear inside strings or comments. + static String stripCommentsAndStrings(String sql) { + final buffer = StringBuffer(); + final len = sql.length; + var i = 0; + + while (i < len) { + // 1. Line comment: -- + if (i + 1 < len && sql[i] == '-' && sql[i + 1] == '-') { + i += 2; + while (i < len && sql[i] != '\n' && sql[i] != '\r') { + i++; + } + buffer.write(' '); + continue; + } + + // 2. Block comment: /* ... */ + if (i + 1 < len && sql[i] == '/' && sql[i + 1] == '*') { + i += 2; + while (i + 1 < len && !(sql[i] == '*' && sql[i + 1] == '/')) { + i++; + } + if (i + 1 < len) { + i += 2; // skip */ + } else { + i = len; + } + buffer.write(' '); + continue; + } + + // 3. Dollar quotes in PostgreSQL: $$ or $tag$ + if (sql[i] == '\$') { + final match = RegExp(r'^\$([a-zA-Z0-9_]*)\$').matchAsPrefix(sql.substring(i)); + if (match != null) { + final tag = match.group(0)!; + i += tag.length; + final closeIdx = sql.indexOf(tag, i); + if (closeIdx != -1) { + i = closeIdx + tag.length; + } else { + i = len; + } + buffer.write("''"); + continue; + } + } + + // 4. Standard string literal: '...' (supporting '' escaping) + if (sql[i] == "'") { + i++; + while (i < len) { + if (sql[i] == "'") { + if (i + 1 < len && sql[i + 1] == "'") { + i += 2; // escaped quote + } else { + i++; // closing quote + break; + } + } else if (sql[i] == '\\' && i + 1 < len) { + i += 2; // escaped char + } else { + i++; + } + } + buffer.write("''"); + continue; + } + + buffer.write(sql[i]); + i++; + } + + return buffer.toString(); + } + + /// Splits an SQL query into individual statements on `;`, taking into account + /// comments and string literals. + static List splitStatements(String sql) { + final statements = []; + final current = StringBuffer(); + final len = sql.length; + var i = 0; + + while (i < len) { + // Line comment + if (i + 1 < len && sql[i] == '-' && sql[i + 1] == '-') { + while (i < len && sql[i] != '\n' && sql[i] != '\r') { + current.write(sql[i]); + i++; + } + continue; + } + + // Block comment + if (i + 1 < len && sql[i] == '/' && sql[i + 1] == '*') { + current.write('/*'); + i += 2; + while (i + 1 < len && !(sql[i] == '*' && sql[i + 1] == '/')) { + current.write(sql[i]); + i++; + } + if (i + 1 < len) { + current.write('*/'); + i += 2; + } else { + i = len; + } + continue; + } + + // Dollar quotes + if (sql[i] == '\$') { + final match = RegExp(r'^\$([a-zA-Z0-9_]*)\$').matchAsPrefix(sql.substring(i)); + if (match != null) { + final tag = match.group(0)!; + current.write(tag); + i += tag.length; + final closeIdx = sql.indexOf(tag, i); + if (closeIdx != -1) { + current.write(sql.substring(i, closeIdx + tag.length)); + i = closeIdx + tag.length; + } else { + current.write(sql.substring(i)); + i = len; + } + continue; + } + } + + // String literal + if (sql[i] == "'") { + current.write("'"); + i++; + while (i < len) { + if (sql[i] == "'") { + current.write("'"); + if (i + 1 < len && sql[i + 1] == "'") { + current.write("'"); + i += 2; + } else { + i++; + break; + } + } else if (sql[i] == '\\' && i + 1 < len) { + current.write(sql[i]); + current.write(sql[i + 1]); + i += 2; + } else { + current.write(sql[i]); + i++; + } + } + continue; + } + + // Statement delimiter + if (sql[i] == ';') { + final stmt = current.toString().trim(); + if (stmt.isNotEmpty) { + statements.add(stmt); + } + current.clear(); + i++; + continue; + } + + current.write(sql[i]); + i++; + } + + final remaining = current.toString().trim(); + if (remaining.isNotEmpty) { + statements.add(remaining); + } + + return statements; + } + + /// Inspects [sql] and returns any detected destructive operations. + static DestructiveSqlInspectionResult inspect(String sql) { + final statements = splitStatements(sql); + final operations = []; + + for (final rawStmt in statements) { + final sanitized = stripCommentsAndStrings(rawStmt).trim(); + if (sanitized.isEmpty) continue; + + // 1. DROP DATABASE + final dropDbMatch = _dropDatabaseRegex.firstMatch(sanitized); + if (dropDbMatch != null) { + final target = dropDbMatch.group(1) ?? 'database'; + operations.add( + DestructiveSqlOperation( + type: DestructiveSqlType.dropDatabase, + targetName: target, + rawStatement: rawStmt.trim(), + ), + ); + continue; + } + + // 2. DROP SCHEMA + final dropSchemaMatch = _dropSchemaRegex.firstMatch(sanitized); + if (dropSchemaMatch != null) { + final target = dropSchemaMatch.group(1) ?? 'schema'; + operations.add( + DestructiveSqlOperation( + type: DestructiveSqlType.dropSchema, + targetName: target, + rawStatement: rawStmt.trim(), + ), + ); + continue; + } + + // 3. DROP MATERIALIZED VIEW + final dropMatViewMatch = _dropMatViewRegex.firstMatch(sanitized); + if (dropMatViewMatch != null) { + final schema = dropMatViewMatch.group(1); + final view = dropMatViewMatch.group(2) ?? 'view'; + final target = (schema != null && schema.isNotEmpty) ? '$schema.$view' : view; + operations.add( + DestructiveSqlOperation( + type: DestructiveSqlType.dropMaterializedView, + targetName: target, + rawStatement: rawStmt.trim(), + ), + ); + continue; + } + + // 4. DROP VIEW + final dropViewMatch = _dropViewRegex.firstMatch(sanitized); + if (dropViewMatch != null) { + final schema = dropViewMatch.group(1); + final view = dropViewMatch.group(2) ?? 'view'; + final target = (schema != null && schema.isNotEmpty) ? '$schema.$view' : view; + operations.add( + DestructiveSqlOperation( + type: DestructiveSqlType.dropView, + targetName: target, + rawStatement: rawStmt.trim(), + ), + ); + continue; + } + + // 5. DROP TABLE + final dropTableMatch = _dropTableRegex.firstMatch(sanitized); + if (dropTableMatch != null) { + final schema = dropTableMatch.group(1); + final table = dropTableMatch.group(2) ?? 'table'; + final target = (schema != null && schema.isNotEmpty) ? '$schema.$table' : table; + operations.add( + DestructiveSqlOperation( + type: DestructiveSqlType.dropTable, + targetName: target, + rawStatement: rawStmt.trim(), + ), + ); + continue; + } + + // 6. TRUNCATE + final truncateMatch = _truncateRegex.firstMatch(sanitized); + if (truncateMatch != null) { + final schema = truncateMatch.group(1); + final table = truncateMatch.group(2) ?? 'table'; + final target = (schema != null && schema.isNotEmpty) ? '$schema.$table' : table; + operations.add( + DestructiveSqlOperation( + type: DestructiveSqlType.truncateTable, + targetName: target, + rawStatement: rawStmt.trim(), + ), + ); + continue; + } + + // 7. DELETE FROM table without WHERE + final deleteMatch = _deleteRegex.firstMatch(sanitized); + if (deleteMatch != null) { + final hasWhere = RegExp(r'\bWHERE\b', caseSensitive: false).hasMatch(sanitized); + if (!hasWhere) { + final schema = deleteMatch.group(1); + final table = deleteMatch.group(2) ?? 'table'; + final target = (schema != null && schema.isNotEmpty) ? '$schema.$table' : table; + operations.add( + DestructiveSqlOperation( + type: DestructiveSqlType.unconditionalDelete, + targetName: target, + rawStatement: rawStmt.trim(), + ), + ); + } + } + } + + return DestructiveSqlInspectionResult(operations: operations); + } +} diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 356b572..053241e 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -129,6 +129,7 @@ abstract final class AppSettingsKeys { static const checkForUpdatesOnStartup = 'check_for_updates_on_startup'; static const updateDismissedVersion = 'update_dismissed_version'; static const hasCompletedWelcomeTour = 'has_completed_welcome_tour'; + static const confirmDestructiveOperations = 'confirm_destructive_operations'; } /// Bumps [listenable] when any preference is persisted (theme, legacy listeners). @@ -238,6 +239,23 @@ class AppSettings { SqlWorkspaceSettingsRevision.bump(); } + /// Whether the SQL editor prompts for confirmation before executing DROP / TRUNCATE. + Future getConfirmDestructiveOperations() async { + final v = await LocalDb.instance.getAppSetting( + AppSettingsKeys.confirmDestructiveOperations, + ); + if (v == null || v.isEmpty) return true; + return v.toLowerCase() == 'true' || v == '1'; + } + + Future setConfirmDestructiveOperations(bool enable) async { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.confirmDestructiveOperations, + enable.toString(), + ); + SqlWorkspaceSettingsRevision.bump(); + } + /// Global interface scale for typography and compact controls. Future getUiScale() async { final v = await LocalDb.instance.getAppSetting(AppSettingsKeys.uiScale); diff --git a/lib/features/main_screen/destructive_query_dialog.dart b/lib/features/main_screen/destructive_query_dialog.dart new file mode 100644 index 0000000..30d59a3 --- /dev/null +++ b/lib/features/main_screen/destructive_query_dialog.dart @@ -0,0 +1,307 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; +import 'package:querya_desktop/shared/widgets/app_dialog.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Opens a confirmation dialog when destructive SQL statements (DROP, TRUNCATE, etc.) +/// are detected before execution. +/// +/// Returns `true` if the user confirmed execution, or `false`/`null` if cancelled. +Future showDestructiveQueryDialog({ + required material.BuildContext context, + required DestructiveSqlInspectionResult result, + required String sql, + String? connectionName, +}) { + return showAppDialog( + context: context, + builder: (ctx) => _DestructiveQueryDialog( + result: result, + sql: sql, + connectionName: connectionName, + ), + ); +} + +class _DestructiveQueryDialog extends material.StatefulWidget { + const _DestructiveQueryDialog({ + required this.result, + required this.sql, + this.connectionName, + }); + + final DestructiveSqlInspectionResult result; + final String sql; + final String? connectionName; + + @override + material.State<_DestructiveQueryDialog> createState() => + _DestructiveQueryDialogState(); +} + +class _DestructiveQueryDialogState extends material.State<_DestructiveQueryDialog> { + bool _acknowledged = false; + bool _copied = false; + + Future _copySql() async { + await Clipboard.setData(ClipboardData(text: widget.sql)); + if (!mounted) return; + setState(() => _copied = true); + await Future.delayed(const Duration(seconds: 2)); + if (mounted) setState(() => _copied = false); + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; + final isCritical = widget.result.maxRiskLevel == 'CRITICAL'; + + return material.Dialog( + backgroundColor: cs.card, + shape: material.RoundedRectangleBorder( + borderRadius: material.BorderRadius.circular(8), + side: material.BorderSide( + color: cs.destructive.withValues(alpha: isDark ? 0.6 : 0.4), + width: 1.5, + ), + ), + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.ConstrainedBox( + constraints: const material.BoxConstraints( + minWidth: 540, + maxWidth: 680, + minHeight: 440, + maxHeight: 580, + ), + child: material.SizedBox( + height: 540, + child: material.Padding( + padding: const material.EdgeInsets.all(20), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Header + material.Row( + children: [ + material.Container( + padding: const material.EdgeInsets.all(8), + decoration: material.BoxDecoration( + color: cs.destructive.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(8), + ), + child: material.Icon( + material.Icons.warning_amber_rounded, + size: 24, + color: cs.destructive, + ), + ), + const Gap(12), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + Text( + isCritical + ? 'Critical Destructive Operation' + : 'Destructive Operation Detected', + ).semiBold().large(), + const Gap(2), + if (widget.connectionName != null) + Text( + 'Target connection: ${widget.connectionName}', + ).muted().small() + else + const Text( + 'This statement will permanently alter or delete database objects.', + ).muted().small(), + ], + ), + ), + ], + ), + const Gap(16), + + // Detected operations list + material.Container( + padding: const material.EdgeInsets.all(12), + decoration: material.BoxDecoration( + color: cs.destructive.withValues( + alpha: isDark ? 0.12 : 0.06, + ), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: cs.destructive.withValues( + alpha: isDark ? 0.35 : 0.25, + ), + ), + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + for (final op in widget.result.operations) ...[ + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: material.BoxDecoration( + color: cs.destructive, + borderRadius: material.BorderRadius.circular(4), + ), + child: Text( + op.type.label, + style: const TextStyle( + color: material.Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + const Gap(8), + material.Expanded( + child: Text( + op.description, + style: material.TextStyle( + fontSize: 12, + color: cs.foreground, + fontWeight: material.FontWeight.w500, + ), + ), + ), + ], + ), + if (op != widget.result.operations.last) const Gap(8), + ], + ], + ), + ), + const Gap(14), + + // SQL Script Preview Header + material.Row( + mainAxisAlignment: material.MainAxisAlignment.spaceBetween, + children: [ + const Text('QUERY PREVIEW').semiBold().xSmall().muted(), + material.InkWell( + onTap: _copySql, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + _copied + ? material.Icons.check_rounded + : material.Icons.copy_rounded, + size: 13, + color: _copied + ? material.Colors.green + : cs.mutedForeground, + ), + const Gap(4), + Text(_copied ? 'Copied' : 'Copy SQL').xSmall(), + ], + ), + ), + ), + ], + ), + const Gap(6), + + // SQL Code block container + material.Expanded( + child: material.Container( + padding: const material.EdgeInsets.all(12), + decoration: material.BoxDecoration( + color: isDark + ? const material.Color(0xFF141416) + : const material.Color(0xFFF4F4F6), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: cs.border.withValues(alpha: 0.6), + ), + ), + child: material.SingleChildScrollView( + child: material.SelectableText( + widget.sql, + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12.5, + height: 1.45, + color: isDark + ? const material.Color(0xFFE2E8F0) + : const material.Color(0xFF1E293B), + ), + ), + ), + ), + ), + const Gap(14), + + // Confirmation Checkbox + material.Row( + children: [ + material.Checkbox( + value: _acknowledged, + onChanged: (v) => setState(() => _acknowledged = v ?? false), + ), + const Gap(8), + material.Expanded( + child: material.GestureDetector( + onTap: () => setState(() => _acknowledged = !_acknowledged), + child: const Text( + 'I understand that this query cannot be undone and may result in permanent data loss.', + ).small(), + ), + ), + ], + ), + const Gap(16), + + // Action buttons + material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Wrap( + spacing: 12, + runSpacing: 8, + alignment: material.WrapAlignment.end, + crossAxisAlignment: material.WrapCrossAlignment.center, + children: [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + DestructiveButton( + onPressed: _acknowledged + ? () => material.Navigator.of(context).pop(true) + : null, + leading: const material.Icon( + material.Icons.delete_forever_rounded, + size: 16, + ), + child: const Text('Execute Destructive Statement'), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 62c9ce2..6107e38 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -6,6 +6,7 @@ import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:file_selector/file_selector.dart'; import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; @@ -16,6 +17,7 @@ import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/main_screen/destructive_query_dialog.dart'; import 'package:querya_desktop/features/main_screen/dml_preview_dialog.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; @@ -167,6 +169,22 @@ class _MysqlSqlWorkspaceState extends material.State { } if (userSql.isEmpty) return; + final confirmDestructive = + await AppSettings.instance.getConfirmDestructiveOperations(); + if (confirmDestructive) { + final inspection = DestructiveSqlDetector.inspect(userSql); + if (inspection.isDestructive) { + if (!mounted) return; + final confirmed = await showDestructiveQueryDialog( + context: context, + result: inspection, + sql: userSql, + connectionName: widget.connectionRow.name, + ); + if (confirmed != true) return; + } + } + setState(() { _running = true; _error = null; diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index b87f287..c889142 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -7,6 +7,7 @@ import 'package:file_selector/file_selector.dart'; import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; import 'package:postgres/postgres.dart' as pg; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/postgres_sql.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; @@ -20,6 +21,7 @@ import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/main_screen/destructive_query_dialog.dart'; import 'package:querya_desktop/features/main_screen/dml_preview_dialog.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; @@ -313,6 +315,23 @@ class _PostgresSqlWorkspaceState extends material.State { userSql = _sqlController.text.trim(); } if (userSql.isEmpty) return; + + final confirmDestructive = + await AppSettings.instance.getConfirmDestructiveOperations(); + if (confirmDestructive) { + final inspection = DestructiveSqlDetector.inspect(userSql); + if (inspection.isDestructive) { + if (!mounted) return; + final confirmed = await showDestructiveQueryDialog( + context: context, + result: inspection, + sql: userSql, + connectionName: widget.connectionRow.name, + ); + if (confirmed != true) return; + } + } + var sql = injectSqlLimit(userSql, _resultMaxRows); setState(() { diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 6d2554c..6155346 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -32,6 +32,7 @@ class _PreferencesDialogContentState extends material.State<_PreferencesDialogContent> { bool _loading = true; bool _checkUpdatesOnStartup = true; + bool _confirmDestructive = true; int? _pgTimeout; int? _mysqlTimeout; int _maxRows = kDefaultSqlResultMaxRows; @@ -46,6 +47,8 @@ class _PreferencesDialogContentState Future _load() async { final startup = await AppSettings.instance.getCheckForUpdatesOnStartup(); + final destructive = + await AppSettings.instance.getConfirmDestructiveOperations(); final pg = await AppSettings.instance.getPostgresSqlStmtTimeoutSeconds(); final my = await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(); final rows = await AppSettings.instance.getSqlResultMaxRows(); @@ -54,6 +57,7 @@ class _PreferencesDialogContentState if (!mounted) return; setState(() { _checkUpdatesOnStartup = startup; + _confirmDestructive = destructive; _pgTimeout = pg; _mysqlTimeout = my; _maxRows = rows; @@ -68,6 +72,11 @@ class _PreferencesDialogContentState await AppSettings.instance.setCheckForUpdatesOnStartup(enabled); } + Future _setConfirmDestructive(bool enabled) async { + setState(() => _confirmDestructive = enabled); + await AppSettings.instance.setConfirmDestructiveOperations(enabled); + } + Future _setPg(int? v) async { setState(() => _pgTimeout = v); await AppSettings.instance.setPostgresSqlStmtTimeoutSeconds(v); @@ -267,6 +276,19 @@ class _PreferencesDialogContentState ], ), ), + const material.SizedBox(height: 12), + PreferencesCheckboxRow( + value: _confirmDestructive, + title: const Text( + 'Confirm destructive SQL operations', + ).small(), + subtitle: const Text( + 'Prompts before executing DROP, TRUNCATE, or unconditional DELETE queries.', + ).muted().xSmall(), + onChanged: (v) { + unawaited(_setConfirmDestructive(v)); + }, + ), const material.SizedBox(height: 16), const PreferencesHint( 'Preferences are stored locally in SQLite (non-secret keys only).', diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 04534f0..0f6e2dd 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:file_selector/file_selector.dart'; import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; import 'package:querya_desktop/core/database/sqlite_service.dart'; @@ -15,6 +16,7 @@ import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/main_screen/destructive_query_dialog.dart'; import 'package:querya_desktop/features/main_screen/dml_preview_dialog.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; @@ -143,6 +145,22 @@ class _SqliteSqlWorkspaceState extends material.State { } if (userSql.isEmpty) return; + final confirmDestructive = + await AppSettings.instance.getConfirmDestructiveOperations(); + if (confirmDestructive) { + final inspection = DestructiveSqlDetector.inspect(userSql); + if (inspection.isDestructive) { + if (!mounted) return; + final confirmed = await showDestructiveQueryDialog( + context: context, + result: inspection, + sql: userSql, + connectionName: widget.connectionRow.name, + ); + if (confirmed != true) return; + } + } + setState(() { _running = true; _error = null; diff --git a/test/core/database/destructive_sql_detector_test.dart b/test/core/database/destructive_sql_detector_test.dart new file mode 100644 index 0000000..71b5b5a --- /dev/null +++ b/test/core/database/destructive_sql_detector_test.dart @@ -0,0 +1,135 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; + +void main() { + group('DestructiveSqlDetector', () { + test('detects DROP DATABASE', () { + final res = DestructiveSqlDetector.inspect('DROP DATABASE production;'); + expect(res.isDestructive, isTrue); + expect(res.operations.length, 1); + expect(res.operations.first.type, DestructiveSqlType.dropDatabase); + expect(res.operations.first.targetName, 'production'); + expect(res.maxRiskLevel, 'CRITICAL'); + }); + + test('detects DROP DATABASE IF EXISTS with backticks', () { + final res = DestructiveSqlDetector.inspect('DROP DATABASE IF EXISTS `analytics_db`'); + expect(res.isDestructive, isTrue); + expect(res.operations.first.type, DestructiveSqlType.dropDatabase); + expect(res.operations.first.targetName, 'analytics_db'); + }); + + test('detects DROP SCHEMA', () { + final res = DestructiveSqlDetector.inspect('DROP SCHEMA public CASCADE;'); + expect(res.isDestructive, isTrue); + expect(res.operations.first.type, DestructiveSqlType.dropSchema); + expect(res.operations.first.targetName, 'public'); + }); + + test('detects DROP TABLE', () { + final res = DestructiveSqlDetector.inspect('DROP TABLE users;'); + expect(res.isDestructive, isTrue); + expect(res.operations.first.type, DestructiveSqlType.dropTable); + expect(res.operations.first.targetName, 'users'); + }); + + test('detects DROP TABLE with schema and quotes', () { + final res = DestructiveSqlDetector.inspect('DROP TABLE IF EXISTS "public"."orders";'); + expect(res.isDestructive, isTrue); + expect(res.operations.first.type, DestructiveSqlType.dropTable); + expect(res.operations.first.targetName, 'public.orders'); + }); + + test('detects DROP VIEW and DROP MATERIALIZED VIEW', () { + final viewRes = DestructiveSqlDetector.inspect('DROP VIEW monthly_report;'); + expect(viewRes.isDestructive, isTrue); + expect(viewRes.operations.first.type, DestructiveSqlType.dropView); + expect(viewRes.operations.first.targetName, 'monthly_report'); + + final matRes = DestructiveSqlDetector.inspect('DROP MATERIALIZED VIEW public.active_users;'); + expect(matRes.isDestructive, isTrue); + expect(matRes.operations.first.type, DestructiveSqlType.dropMaterializedView); + expect(matRes.operations.first.targetName, 'public.active_users'); + }); + + test('detects TRUNCATE and TRUNCATE TABLE', () { + final t1 = DestructiveSqlDetector.inspect('TRUNCATE TABLE session_logs;'); + expect(t1.isDestructive, isTrue); + expect(t1.operations.first.type, DestructiveSqlType.truncateTable); + expect(t1.operations.first.targetName, 'session_logs'); + + final t2 = DestructiveSqlDetector.inspect('TRUNCATE analytics.events;'); + expect(t2.isDestructive, isTrue); + expect(t2.operations.first.type, DestructiveSqlType.truncateTable); + expect(t2.operations.first.targetName, 'analytics.events'); + }); + + test('detects unconditional DELETE FROM', () { + final res = DestructiveSqlDetector.inspect('DELETE FROM users;'); + expect(res.isDestructive, isTrue); + expect(res.operations.first.type, DestructiveSqlType.unconditionalDelete); + expect(res.operations.first.targetName, 'users'); + }); + + test('does NOT mark DELETE with WHERE clause as unconditionalDelete', () { + final res = DestructiveSqlDetector.inspect('DELETE FROM users WHERE id = 123;'); + expect(res.isDestructive, isFalse); + }); + + test('handles multi-statement scripts containing destructive actions', () { + const sql = ''' + SELECT * FROM users WHERE active = true; + INSERT INTO audit_log VALUES (1, 'checking'); + DROP TABLE temp_import_data; + SELECT 1; + '''; + final res = DestructiveSqlDetector.inspect(sql); + expect(res.isDestructive, isTrue); + expect(res.operations.length, 1); + expect(res.operations.first.type, DestructiveSqlType.dropTable); + expect(res.operations.first.targetName, 'temp_import_data'); + }); + + test('ignores destructive keywords inside single-quoted strings', () { + final res = DestructiveSqlDetector.inspect("INSERT INTO logs (msg) VALUES ('DROP TABLE users;');"); + expect(res.isDestructive, isFalse); + }); + + test('ignores destructive keywords inside dollar-quoted strings', () { + final res = DestructiveSqlDetector.inspect(r''' + CREATE OR REPLACE FUNCTION clean_data() RETURNS void AS $$ + BEGIN + -- Some logic + END; + $$ LANGUAGE plpgsql; + '''); + expect(res.isDestructive, isFalse); + }); + + test('ignores destructive keywords inside line comments', () { + final res = DestructiveSqlDetector.inspect(''' + -- DROP TABLE users; + SELECT * FROM users; + '''); + expect(res.isDestructive, isFalse); + }); + + test('ignores destructive keywords inside block comments', () { + final res = DestructiveSqlDetector.inspect(''' + /* + * TRUNCATE TABLE orders; + * DROP DATABASE prod; + */ + SELECT count(*) FROM orders; + '''); + expect(res.isDestructive, isFalse); + }); + + test('returns non-destructive for regular queries', () { + expect(DestructiveSqlDetector.inspect('SELECT * FROM users').isDestructive, isFalse); + expect(DestructiveSqlDetector.inspect('CREATE TABLE items (id INT);').isDestructive, isFalse); + expect(DestructiveSqlDetector.inspect('ALTER TABLE users ADD COLUMN age INT;').isDestructive, isFalse); + expect(DestructiveSqlDetector.inspect('UPDATE users SET age = 20 WHERE id = 1;').isDestructive, isFalse); + }); + }); +} diff --git a/test/features/main_screen/destructive_query_dialog_test.dart b/test/features/main_screen/destructive_query_dialog_test.dart new file mode 100644 index 0000000..52e93f3 --- /dev/null +++ b/test/features/main_screen/destructive_query_dialog_test.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; +import 'package:querya_desktop/features/main_screen/destructive_query_dialog.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('DestructiveQueryDialog', () { + testWidgets('renders warning, detected operations, and disables confirm until acknowledged', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + bool? result; + + final inspection = DestructiveSqlDetector.inspect('DROP TABLE legacy_users;'); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showDestructiveQueryDialog( + context: context, + result: inspection, + sql: 'DROP TABLE legacy_users;', + connectionName: 'Production PostgreSQL', + ); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Destructive Operation Detected'), findsOneWidget); + expect(find.text('Target connection: Production PostgreSQL'), findsOneWidget); + expect(find.text('DROP TABLE'), findsOneWidget); + expect(find.text('DROP TABLE legacy_users;'), findsOneWidget); + expect(find.text('Execute Destructive Statement'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + + // Confirm button is disabled when checkbox is unchecked + await tester.tap(find.text('Execute Destructive Statement')); + await tester.pumpAndSettle(); + expect(result, isNull); + + // Check acknowledgment checkbox + await tester.tap(find.byType(material.Checkbox)); + await tester.pumpAndSettle(); + + // Now clicking confirm returns true and dismisses dialog + await tester.tap(find.text('Execute Destructive Statement')); + await tester.pumpAndSettle(); + expect(result, isTrue); + }); + + testWidgets('shows Critical header for DROP DATABASE', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + + final inspection = DestructiveSqlDetector.inspect('DROP DATABASE customer_records;'); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showDestructiveQueryDialog( + context: context, + result: inspection, + sql: 'DROP DATABASE customer_records;', + ), + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Critical Destructive Operation'), findsOneWidget); + expect(find.text('DROP DATABASE'), findsOneWidget); + }); + + testWidgets('Cancel button dismisses dialog with false', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + bool? result; + + final inspection = DestructiveSqlDetector.inspect('TRUNCATE logs;'); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showDestructiveQueryDialog( + context: context, + result: inspection, + sql: 'TRUNCATE logs;', + ); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(result, isFalse); + }); + }); +}