diff --git a/lib/core/extensions/extension_driver_session.dart b/lib/core/extensions/extension_driver_session.dart index 4e638c7a..b83730c9 100644 --- a/lib/core/extensions/extension_driver_session.dart +++ b/lib/core/extensions/extension_driver_session.dart @@ -445,21 +445,18 @@ class ExtensionDriverSession { required String tableName, }) async { final bridge = await ensureConnected(row); - try { - final result = await bridge.sendRequest('db.getTableSchema', { - 'connectionId': row.id, - 'database': database, - if (schema != null && schema.isNotEmpty) 'schema': schema, - 'tableName': tableName, - }); - if (result is Map) { - return TableSchemaMeta.fromJson(Map.from(result)); - } - return TableSchemaMeta(tableName: tableName, schema: schema); - } catch (e) { - debugPrint('ExtensionDriverSession getTableSchema fallback ($e)'); - return TableSchemaMeta(tableName: tableName, schema: schema); + final result = await bridge.sendRequest('db.getTableSchema', { + 'connectionId': row.id, + 'database': database, + if (schema != null && schema.isNotEmpty) 'schema': schema, + 'tableName': tableName, + }); + if (result is Map) { + return TableSchemaMeta.fromJson(Map.from(result)); } + throw StateError( + 'Driver db.getTableSchema returned unexpected payload: $result', + ); } /// Executes batch data mutations (insert, update, delete) via `db.mutate`. diff --git a/lib/features/extensions/extension_table_view.dart b/lib/features/extensions/extension_table_view.dart index 67cd6f5b..06e58fb5 100644 --- a/lib/features/extensions/extension_table_view.dart +++ b/lib/features/extensions/extension_table_view.dart @@ -53,6 +53,9 @@ class _ExtensionTableViewState extends material.State { ExtensionDriverCapabilities? _capabilities; DataGridStagingBuffer? _stagingBuffer; + bool _schemaLoaded = false; + Object? _schemaError; + List _primaryKeys = const []; bool _isSaving = false; bool _restartingDriver = false; @@ -80,6 +83,12 @@ class _ExtensionTableViewState extends material.State { Future _restartDriver() async { if (_restartingDriver) return; + if (!await _confirmDiscardIfNeeded()) return; + if (!mounted) return; + _stagingBuffer?.dispose(); + _stagingBuffer = null; + _schemaLoaded = false; + _schemaError = null; setState(() { _restartingDriver = true; }); @@ -122,13 +131,19 @@ class _ExtensionTableViewState extends material.State { if (oldWidget.connectionRow.id != widget.connectionRow.id || oldWidget.database != widget.database || oldWidget.tableName != widget.tableName) { - _offset = 0; - _totalRows = null; - _filterController.clear(); - _filterActive = false; - _stagingBuffer?.dispose(); - _stagingBuffer = null; - unawaited(_loadPage(refreshCount: true)); + unawaited(() async { + if (!await _confirmDiscardIfNeeded()) return; + if (!mounted) return; + _stagingBuffer?.dispose(); + _stagingBuffer = null; + _schemaLoaded = false; + _schemaError = null; + _offset = 0; + _totalRows = null; + _filterController.clear(); + _filterActive = false; + await _loadPage(refreshCount: true); + }()); } } @@ -140,16 +155,89 @@ class _ExtensionTableViewState extends material.State { super.dispose(); } + String get _tableTitle => '${widget.database}.${widget.tableName}'; + + bool get _isDirty => _stagingBuffer?.isDirty == true; + + Future _confirmDiscardIfNeeded() { + return confirmDiscardTableEditsIfDirty( + context: context, + buffer: _stagingBuffer, + tableTitle: _tableTitle, + ); + } + + /// Guards navigation with a discard confirmation and then runs [action] + /// before triggering a page load. + Future _navigateAndLoad(void Function() action) async { + if (!await _confirmDiscardIfNeeded()) return; + if (!mounted) return; + _stagingBuffer?.dispose(); + _stagingBuffer = null; + action(); + unawaited(_loadPage(refreshCount: true)); + } + + Future _onRefresh() async { + if (!await _confirmDiscardIfNeeded()) return; + if (!mounted) return; + _stagingBuffer?.dispose(); + _stagingBuffer = null; + _schemaLoaded = false; + _schemaError = null; + await _loadPage(refreshCount: true); + } + + Future _ensureSchema() async { + if (_schemaLoaded) return; + if (widget.isView || _capabilities?.supportsMutations != true) { + _schemaLoaded = true; + _schemaError = null; + _primaryKeys = const []; + return; + } + final loaded = await loadTableViewSchema( + () => ExtensionDriverSession.instance.getTableSchema( + widget.connectionRow, + database: widget.database, + tableName: widget.tableName, + ), + ); + final schema = loaded.schema; + if (schema != null) { + _primaryKeys = List.from(schema.primaryKeys); + _schemaError = null; + } else { + _primaryKeys = const []; + _schemaError = loaded.error; + } + _schemaLoaded = true; + } + void _updateStatusLine() { final total = _totalRows; final shownFrom = _rows.isEmpty ? 0 : _offset + 1; final shownTo = _offset + _rows.length; + final String pag; if (total == null) { - _statusLine = _loading + pag = _loading ? 'Loading data...' : 'Showing $shownTo row(s) (Calculating count...).'; } else { - _statusLine = 'Rows $shownFrom–$shownTo of $total.'; + pag = 'Rows $shownFrom–$shownTo of $total.'; + } + final reason = tableViewEditDisabledReason( + isView: widget.isView, + customSqlActive: false, + hasPrimaryKey: _primaryKeys.isNotEmpty, + schemaLoaded: _schemaLoaded, + readOnly: _capabilities != null && !_capabilities!.supportsMutations, + schemaError: _schemaError, + ); + if (reason != null) { + _statusLine = '$pag · $reason'; + } else { + _statusLine = pag; } } @@ -208,18 +296,26 @@ class _ExtensionTableViewState extends material.State { 'SELECT * FROM $_qualifiedName$_whereClause LIMIT ${widget.pageSize} OFFSET $_offset', ); + await _ensureSchema(); + if (!mounted) return; setState(() { _columns = dataResult.columns; _rows = dataResult.rows; _loading = false; - _stagingBuffer?.dispose(); - if (!widget.isView && (_capabilities?.supportsMutations == true)) { - _stagingBuffer = - DataGridStagingBuffer(columns: _columns, rows: _rows); - } else { - _stagingBuffer = null; - } + final editingEnabled = tableViewEditingEnabled( + isView: widget.isView, + customSqlActive: false, + hasPrimaryKey: _primaryKeys.isNotEmpty, + readOnly: _capabilities?.supportsMutations != true, + schemaError: _schemaError, + ); + _stagingBuffer = replaceTableViewStagingBuffer( + previous: _stagingBuffer, + columns: _columns, + rows: _rows, + enabled: editingEnabled, + ); _updateStatusLine(); }); @@ -238,14 +334,22 @@ class _ExtensionTableViewState extends material.State { final buffer = _stagingBuffer; if (buffer == null || !buffer.isDirty) return; + final primaryKeys = _primaryKeys; + if (primaryKeys.isEmpty) { + if (mounted) { + showAppToast( + context: context, + message: + 'Cannot save: no primary key is available for ${widget.tableName}. ' + 'Edits would match all rows.', + variant: AppToastVariant.error, + ); + } + return; + } + setState(() => _isSaving = true); try { - final schema = await ExtensionDriverSession.instance.getTableSchema( - widget.connectionRow, - database: widget.database, - tableName: widget.tableName, - ); - final mutations = >[]; // 1. Updates @@ -255,16 +359,10 @@ class _ExtensionTableViewState extends material.State { final origRow = buffer.originalRows[rowIndex]; final whereMap = {}; - if (schema.primaryKeys.isNotEmpty) { - for (final pk in schema.primaryKeys) { - final idx = _columns.indexOf(pk); - if (idx != -1 && idx < origRow.length) { - whereMap[pk] = origRow[idx]; - } - } - } else { - for (var c = 0; c < _columns.length; c++) { - whereMap[_columns[c]] = c < origRow.length ? origRow[c] : null; + for (final pk in primaryKeys) { + final idx = _columns.indexOf(pk); + if (idx != -1 && idx < origRow.length) { + whereMap[pk] = origRow[idx]; } } @@ -304,16 +402,10 @@ class _ExtensionTableViewState extends material.State { for (final rowIndex in buffer.deletedRowIndices) { final origRow = buffer.originalRows[rowIndex]; final whereMap = {}; - if (schema.primaryKeys.isNotEmpty) { - for (final pk in schema.primaryKeys) { - final idx = _columns.indexOf(pk); - if (idx != -1 && idx < origRow.length) { - whereMap[pk] = origRow[idx]; - } - } - } else { - for (var c = 0; c < _columns.length; c++) { - whereMap[_columns[c]] = c < origRow.length ? origRow[c] : null; + for (final pk in primaryKeys) { + final idx = _columns.indexOf(pk); + if (idx != -1 && idx < origRow.length) { + whereMap[pk] = origRow[idx]; } } mutations.add({ @@ -330,10 +422,17 @@ class _ExtensionTableViewState extends material.State { mutations: mutations, ); if (!mounted) return; - final count = res['affectedRows'] ?? mutations.length; + final affectedRows = res['affectedRows']; + if (affectedRows is! int) { + throw StateError( + 'Save failed: driver did not return an affectedRows count.', + ); + } + expectDmlMatchedRows(affectedRows); + showAppToast( context: context, - message: 'Successfully applied $count mutation(s).', + message: 'Successfully applied $affectedRows mutation(s).', variant: AppToastVariant.success, ); unawaited(_loadPage(refreshCount: true)); @@ -351,16 +450,18 @@ class _ExtensionTableViewState extends material.State { } void _applyFilter() { - _offset = 0; - _totalRows = null; - unawaited(_loadPage(refreshCount: true)); + unawaited(_navigateAndLoad(() { + _offset = 0; + _totalRows = null; + })); } void _clearFilter() { _filterController.clear(); - _offset = 0; - _totalRows = null; - unawaited(_loadPage(refreshCount: true)); + unawaited(_navigateAndLoad(() { + _offset = 0; + _totalRows = null; + })); } Future _openDdlDialog() async { @@ -437,9 +538,10 @@ class _ExtensionTableViewState extends material.State { } } - bool get _canGoBack => _offset > 0; + bool get _canGoBack => _offset > 0 && !_isDirty; bool get _canGoForward { + if (_isDirty) return false; final total = _totalRows; if (total == null) return _rows.length >= widget.pageSize; return _offset + widget.pageSize < total; @@ -447,14 +549,16 @@ class _ExtensionTableViewState extends material.State { void _previousPage() { if (!_canGoBack || _loading) return; - _offset = (_offset - widget.pageSize).clamp(0, 1 << 30); - unawaited(_loadPage()); + unawaited(_navigateAndLoad(() { + _offset = (_offset - widget.pageSize).clamp(0, 1 << 30); + })); } void _nextPage() { if (!_canGoForward || _loading) return; - _offset += widget.pageSize; - unawaited(_loadPage()); + unawaited(_navigateAndLoad(() { + _offset += widget.pageSize; + })); } @override @@ -473,7 +577,13 @@ class _ExtensionTableViewState extends material.State { loading: _loading, canGoPrevious: _canGoBack && !_loading, canGoNext: _canGoForward && !_loading, - onNavigateHome: widget.onNavigateHome, + onNavigateHome: widget.onNavigateHome != null + ? () => unawaited(() async { + if (!await _confirmDiscardIfNeeded()) return; + if (!mounted) return; + widget.onNavigateHome!(); + }()) + : null, filterActive: _filterActive || _filterController.text.isNotEmpty, filterText: _filterController.text, onToggleFilter: () { @@ -484,7 +594,7 @@ class _ExtensionTableViewState extends material.State { onOpenDdl: _openDdlDialog, onGoPrevious: _previousPage, onGoNext: _nextPage, - onRefresh: () => _loadPage(refreshCount: true), + onRefresh: () => unawaited(_onRefresh()), onRestartDriver: () => unawaited(_restartDriver()), isRestarting: _restartingDriver, onCopyFormat: (format) { diff --git a/test/features/extensions/extension_table_view_test.dart b/test/features/extensions/extension_table_view_test.dart index d2fce669..611cf644 100644 --- a/test/features/extensions/extension_table_view_test.dart +++ b/test/features/extensions/extension_table_view_test.dart @@ -1,7 +1,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/core/database/table_schema_meta.dart'; import 'package:querya_desktop/core/extensions/models/extension_driver_capabilities.dart'; import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/workspace/table_view_staging.dart'; void main() { group('ExtensionDriver Mutation Standard & Staging', () { @@ -104,5 +106,138 @@ void main() { expect(mutations[2]['type'], 'delete'); expect(mutations[2]['where'], {'id': '2'}); }); + + test('Extension driver mutation response validation requires integer affectedRows >= 1', () { + // 1. Missing affectedRows count + void validateResponse(dynamic res) { + if (res is! Map) throw StateError('Response is not map'); + final affected = res['affectedRows']; + if (affected is! int) { + throw StateError('Save failed: driver did not return an affectedRows count.'); + } + expectDmlMatchedRows(affected); + } + + expect( + () => validateResponse({}), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Save failed: driver did not return an affectedRows count.'), + )), + ); + + // 2. Non-integer affectedRows + expect( + () => validateResponse({'affectedRows': '1'}), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Save failed: driver did not return an affectedRows count.'), + )), + ); + + // 3. Zero affectedRows matches no rows (expectDmlMatchedRows throws) + expect( + () => validateResponse({'affectedRows': 0}), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Save failed: a statement matched 0 rows.'), + )), + ); + + // 4. Positive affectedRows succeeds + expect(() => validateResponse({'affectedRows': 3}), returnsNormally); + }); + + test('Schema fetch failure is captured as unavailable and disables staging', () async { + final failedLoad = await loadTableViewSchema(() async { + throw StateError('Extension driver rpc timeout'); + }); + + expect(failedLoad.isOk, isFalse); + expect(failedLoad.error, isA()); + + final enabled = tableViewEditingEnabled( + isView: false, + customSqlActive: false, + hasPrimaryKey: false, + readOnly: false, + schemaError: failedLoad.error, + ); + expect(enabled, isFalse); + + final reason = tableViewEditDisabledReason( + isView: false, + customSqlActive: false, + hasPrimaryKey: false, + schemaLoaded: true, + readOnly: false, + schemaError: failedLoad.error, + ); + expect(reason, contains('Cannot edit: schema unavailable.')); + expect(reason, contains('Extension driver rpc timeout')); + }); + + test('Table without primary keys disables staging with no PK reason', () async { + final successLoad = await loadTableViewSchema(() async { + return const TableSchemaMeta(tableName: 'logs', columns: [], primaryKeys: []); + }); + + expect(successLoad.isOk, isTrue); + expect(successLoad.schema!.primaryKeys, isEmpty); + + final enabled = tableViewEditingEnabled( + isView: false, + customSqlActive: false, + hasPrimaryKey: successLoad.schema!.primaryKeys.isNotEmpty, + readOnly: false, + schemaError: null, + ); + expect(enabled, isFalse); + + final reason = tableViewEditDisabledReason( + isView: false, + customSqlActive: false, + hasPrimaryKey: successLoad.schema!.primaryKeys.isNotEmpty, + schemaLoaded: true, + readOnly: false, + schemaError: null, + ); + expect(reason, 'Cannot edit: no primary key detected'); + }); + + test('Table with primary key and mutation support enables staging', () async { + final successLoad = await loadTableViewSchema(() async { + return const TableSchemaMeta( + tableName: 'users', + columns: [], + primaryKeys: ['id'], + ); + }); + + expect(successLoad.isOk, isTrue); + expect(successLoad.schema!.primaryKeys, ['id']); + + final enabled = tableViewEditingEnabled( + isView: false, + customSqlActive: false, + hasPrimaryKey: successLoad.schema!.primaryKeys.isNotEmpty, + readOnly: false, + schemaError: null, + ); + expect(enabled, isTrue); + + final reason = tableViewEditDisabledReason( + isView: false, + customSqlActive: false, + hasPrimaryKey: successLoad.schema!.primaryKeys.isNotEmpty, + schemaLoaded: true, + readOnly: false, + schemaError: null, + ); + expect(reason, isNull); + }); }); }