From 07489044ea3b20584860ecb43f3ee29e41775f60 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 24 Sep 2026 00:39:50 +0300 Subject: [PATCH] fix(sql): do not discard dirty result-grid edits on Execute --- .../extensions/extension_sql_workspace.dart | 11 ++ lib/features/mysql/mysql_sql_workspace.dart | 11 ++ .../postgresql/postgres_sql_workspace.dart | 11 ++ lib/features/sqlite/sqlite_sql_workspace.dart | 14 ++ ...ql_workspace_execute_dirty_guard_test.dart | 158 ++++++++++++++++++ .../workspace/table_view_staging_test.dart | 78 +++++++++ 6 files changed, 283 insertions(+) create mode 100644 test/features/workspace/sql_workspace_execute_dirty_guard_test.dart diff --git a/lib/features/extensions/extension_sql_workspace.dart b/lib/features/extensions/extension_sql_workspace.dart index e1fc160..7c60f12 100644 --- a/lib/features/extensions/extension_sql_workspace.dart +++ b/lib/features/extensions/extension_sql_workspace.dart @@ -281,6 +281,17 @@ class _ExtensionSqlWorkspaceState } if (userSql.isEmpty) return; + final safeToProceed = await confirmDiscardTableEditsIfDirty( + context: context, + buffer: session.stagingBuffer, + tableTitle: session.title, + ); + if (!safeToProceed) return; + if (session.stagingBuffer != null && session.stagingBuffer!.isDirty) { + session.stagingBuffer?.dispose(); + session.stagingBuffer = null; + } + final confirmDestructive = await AppSettings.instance.getConfirmDestructiveOperations(); if (confirmDestructive) { diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 6699a8e..4703153 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -275,6 +275,17 @@ class _MysqlSqlWorkspaceState extends material.State { } if (userSql.isEmpty) return; + final safeToProceed = await confirmDiscardTableEditsIfDirty( + context: context, + buffer: session.stagingBuffer, + tableTitle: session.title, + ); + if (!safeToProceed) return; + if (session.stagingBuffer != null && session.stagingBuffer!.isDirty) { + session.stagingBuffer?.dispose(); + session.stagingBuffer = null; + } + final confirmDestructive = await AppSettings.instance.getConfirmDestructiveOperations(); if (confirmDestructive) { diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index b6a06fd..8798a86 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -401,6 +401,17 @@ class _PostgresSqlWorkspaceState extends material.State { } if (userSql.isEmpty) return; + final safeToProceed = await confirmDiscardTableEditsIfDirty( + context: context, + buffer: session.stagingBuffer, + tableTitle: session.title, + ); + if (!safeToProceed) return; + if (session.stagingBuffer != null && session.stagingBuffer!.isDirty) { + session.stagingBuffer?.dispose(); + session.stagingBuffer = null; + } + final confirmDestructive = await AppSettings.instance.getConfirmDestructiveOperations(); if (confirmDestructive) { diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 3d54219..1ad440c 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -49,6 +49,9 @@ class _SqliteSqlWorkspaceState extends material.State { SqlQueryTabSession get _activeSession => _sessions[_activeSessionIndex]; + @material.visibleForTesting + SqlQueryTabSession get activeSession => _activeSession; + SqliteLease? _lease; bool? _txOpen; @@ -264,6 +267,17 @@ class _SqliteSqlWorkspaceState extends material.State { } if (userSql.isEmpty) return; + final safeToProceed = await confirmDiscardTableEditsIfDirty( + context: context, + buffer: session.stagingBuffer, + tableTitle: session.title, + ); + if (!safeToProceed) return; + if (session.stagingBuffer != null && session.stagingBuffer!.isDirty) { + session.stagingBuffer?.dispose(); + session.stagingBuffer = null; + } + final confirmDestructive = await AppSettings.instance.getConfirmDestructiveOperations(); if (confirmDestructive) { diff --git a/test/features/workspace/sql_workspace_execute_dirty_guard_test.dart b/test/features/workspace/sql_workspace_execute_dirty_guard_test.dart new file mode 100644 index 0000000..eb6d47a --- /dev/null +++ b/test/features/workspace/sql_workspace_execute_dirty_guard_test.dart @@ -0,0 +1,158 @@ +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/sqlite/sqlite_sql_workspace.dart'; +import 'package:querya_desktop/features/workspace/workspace.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; + @override + Future getTemporaryPath() async => _root; + @override + Future getApplicationDocumentsPath() async => _root; + @override + Future getApplicationCachePath() async => _root; + @override + Future getLibraryPath() async => _root; + @override + Future getExternalStoragePath() async => _root; + @override + Future?> getExternalCachePaths() async => [_root]; + @override + Future?> getExternalStoragePaths({StorageDirectory? type}) async => + [_root]; + @override + Future getDownloadsPath() async => _root; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + late String dbPath; + + setUpAll(() async { + sqfliteFfiInit(); + tempDir = await Directory.systemTemp.createTemp('querya_sql_guard_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + + dbPath = '${tempDir.path}/test.db'; + final db = await databaseFactoryFfi.openDatabase(dbPath); + await db.execute('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);'); + await db.execute("INSERT INTO users VALUES (1, 'Alice');"); + await db.close(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + try { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + } catch (_) {} + }); + + tearDown(() { + SqlEditorCommandBridge.instance.unregister(connectionId: 501); + }); + + testWidgets('SQL Execute prompts discard dialog when staging buffer is dirty and preserves buffer on Cancel', + (tester) async { + await tester.binding.setSurfaceSize(const material.Size(1200, 800)); + + final conn = ConnectionRow( + id: 501, + type: 'sqlite', + name: 'Test SQLite', + host: dbPath, + createdAt: '2026-09-24T00:00:00Z', + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.SizedBox.expand( + child: SqliteSqlWorkspace( + connectionRow: conn, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final workspaceState = tester.state(find.byType(SqliteSqlWorkspace)); + final dynamic dynamicState = workspaceState; + final dynamic activeSession = dynamicState.activeSession; + expect(activeSession, isNotNull); + + activeSession.controller.text = 'SELECT * FROM users;'; + activeSession.columns = ['id', 'name']; + activeSession.rows = [['1', 'Alice']]; + final buffer = DataGridStagingBuffer( + columns: ['id', 'name'], + rows: [['1', 'Alice']], + ); + activeSession.stagingBuffer = buffer; + + // Stage an edit in the buffer + buffer.setCell(0, 1, 'Bob'); + expect(buffer.isDirty, isTrue); + + // 1. Press Execute while buffer is dirty + final executeBtn = find.widgetWithText(OutlineButton, 'Execute (F5)'); + expect(executeBtn, findsOneWidget); + + // 3. Press Execute again while buffer is dirty + await tester.tap(executeBtn); + await tester.pumpAndSettle(); + + // Confirmation dialog should be visible + expect(find.text('Unsaved changes in "Query 1"'), findsOneWidget); + expect( + find.text( + 'This table has 1 pending change that have not been saved. Continuing will discard them.', + ), + findsOneWidget, + ); + + // 4. Tap Cancel + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + // Dialog closed, buffer is still dirty and NOT disposed + expect(find.text('Unsaved changes in "Query 1"'), findsNothing); + expect(buffer.isDirty, isTrue); + expect(activeSession.stagingBuffer, same(buffer)); + + // 5. Press Execute again and choose Discard + await tester.tap(executeBtn); + await tester.pumpAndSettle(); + + expect(find.text('Unsaved changes in "Query 1"'), findsOneWidget); + await tester.tap(find.text('Discard')); + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 150))); + await tester.pumpAndSettle(); + + // Query was allowed to run, previous dirty buffer was discarded + expect(find.text('Unsaved changes in "Query 1"'), findsNothing); + final DataGridStagingBuffer? newBuffer = + activeSession.stagingBuffer as DataGridStagingBuffer?; + expect(newBuffer == null || !newBuffer.isDirty, isTrue); + + material.FocusManager.instance.primaryFocus?.unfocus(); + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + }); +} diff --git a/test/features/workspace/table_view_staging_test.dart b/test/features/workspace/table_view_staging_test.dart index c39ece8..1f23a16 100644 --- a/test/features/workspace/table_view_staging_test.dart +++ b/test/features/workspace/table_view_staging_test.dart @@ -468,5 +468,83 @@ void main() { ); expect(ok, isTrue); }); + + testWidgets('shows dialog and returns false when dirty and Cancel pressed', + (tester) async { + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: const material.Scaffold(body: material.SizedBox()), + ), + ); + final ctx = tester.element(find.byType(material.Scaffold)); + final buffer = DataGridStagingBuffer( + columns: ['id', 'name'], + rows: [ + ['1', 'Alice'], + ], + ); + addTearDown(buffer.dispose); + buffer.setCell(0, 1, 'Bob'); + expect(buffer.isDirty, isTrue); + + bool? result; + // Trigger confirmation asynchronously + confirmDiscardTableEditsIfDirty( + context: ctx, + buffer: buffer, + tableTitle: 'public.users', + ).then((val) => result = val); + + await tester.pumpAndSettle(); + expect(find.text('Unsaved changes in "public.users"'), findsOneWidget); + expect( + find.text( + 'This table has 1 pending change that have not been saved. Continuing will discard them.', + ), + findsOneWidget, + ); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(result, isFalse); + expect(buffer.isDirty, isTrue); + }); + + testWidgets('shows dialog and returns true when dirty and Discard pressed', + (tester) async { + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: const material.Scaffold(body: material.SizedBox()), + ), + ); + final ctx = tester.element(find.byType(material.Scaffold)); + final buffer = DataGridStagingBuffer( + columns: ['id', 'name'], + rows: [ + ['1', 'Alice'], + ], + ); + addTearDown(buffer.dispose); + buffer.setCell(0, 1, 'Bob'); + expect(buffer.isDirty, isTrue); + + bool? result; + confirmDiscardTableEditsIfDirty( + context: ctx, + buffer: buffer, + tableTitle: 'Query 1', + ).then((val) => result = val); + + await tester.pumpAndSettle(); + expect(find.text('Unsaved changes in "Query 1"'), findsOneWidget); + + await tester.tap(find.text('Discard')); + await tester.pumpAndSettle(); + + expect(result, isTrue); + }); }); }