From bd11cd2f104d9cdfd095b670520aeadf82edc01c Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 5 Oct 2026 17:12:56 +0300 Subject: [PATCH 1/2] refactor(workspace): unify table browsers into GenericTableView (#1033) --- .../extensions/extension_table_view.dart | 906 ++++++-------- lib/features/mysql/mysql_table_view.dart | 1103 ++++++----------- .../postgresql/postgres_table_view.dart | 893 ++++--------- lib/features/sqlite/sqlite_table_view.dart | 967 +++++---------- .../workspace/generic_table_view.dart | 736 +++++++++++ .../workspace/table_data_delegate.dart | 75 ++ lib/features/workspace/workspace.dart | 2 + 7 files changed, 2123 insertions(+), 2559 deletions(-) create mode 100644 lib/features/workspace/generic_table_view.dart create mode 100644 lib/features/workspace/table_data_delegate.dart diff --git a/lib/features/extensions/extension_table_view.dart b/lib/features/extensions/extension_table_view.dart index 4929fdfe..e0baa628 100644 --- a/lib/features/extensions/extension_table_view.dart +++ b/lib/features/extensions/extension_table_view.dart @@ -1,7 +1,6 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; @@ -16,6 +15,228 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; const _defaultPageSize = 200; +/// Extension driver delegate for [GenericTableView]. +class ExtensionTableDataDelegate extends TableDataMutationDelegate { + ExtensionTableDataDelegate({ + required this.connectionRow, + required this.database, + required this.tableName, + this.isView = false, + this.whereClauseProvider, + }); + + final ConnectionRow connectionRow; + final String database; + final String tableName; + final bool isView; + final String Function()? whereClauseProvider; + + ExtensionDriverCapabilities? _capabilities; + List _primaryKeys = const []; + + String get qualifiedName => '`$database`.`$tableName`'; + + String get _whereClause => whereClauseProvider?.call() ?? ''; + + Future getCapabilities() async { + _capabilities ??= await ExtensionDriverSession.instance + .getCapabilities(connectionRow); + return _capabilities!; + } + + @override + String browseDataSql({required int offset, required int limit}) { + return 'SELECT * FROM $qualifiedName$_whereClause LIMIT $limit OFFSET $offset'; + } + + @override + bool isAllowedSelectQuery(String sql) => true; + + @override + Future loadSchema() async { + final caps = await getCapabilities(); + if (isView || !caps.supportsMutations) { + _primaryKeys = const []; + return const TableDataSchemaInfo(); + } + + final loaded = await loadTableViewSchema( + () => ExtensionDriverSession.instance.getTableSchema( + connectionRow, + database: database, + tableName: tableName, + ), + ); + final s = loaded.schema; + if (s != null) { + _primaryKeys = List.from(s.primaryKeys); + return TableDataSchemaInfo( + primaryKeys: _primaryKeys, + columnDataTypes: columnDataTypesFromSchema(s), + columnMeta: columnMetaFromSchema(s), + ); + } + _primaryKeys = const []; + return TableDataSchemaInfo( + schemaError: loaded.error, + ); + } + + @override + Future loadPage({ + required int offset, + required int limit, + bool refreshCount = false, + }) async { + await getCapabilities(); + final dataResult = await ExtensionDriverSession.instance.query( + connectionRow, + 'SELECT * FROM $qualifiedName$_whereClause LIMIT $limit OFFSET $offset', + ); + + int? totalRows; + if (refreshCount) { + try { + final countQuery = 'SELECT count(*) AS cnt FROM $qualifiedName$_whereClause'; + final countResult = await ExtensionDriverSession.instance.query( + connectionRow, + countQuery, + ); + if (countResult.rows.isNotEmpty && countResult.rows.first.isNotEmpty) { + totalRows = int.tryParse(countResult.rows.first.first); + } + } catch (_) { + try { + final fallbackQuery = 'SELECT count() AS cnt FROM $qualifiedName$_whereClause'; + final countResult = await ExtensionDriverSession.instance.query( + connectionRow, + fallbackQuery, + ); + if (countResult.rows.isNotEmpty && countResult.rows.first.isNotEmpty) { + totalRows = int.tryParse(countResult.rows.first.first); + } + } catch (_) {} + } + } + + return TableDataPage( + columns: dataResult.columns, + rows: dataResult.rows, + totalRowCount: totalRows, + ); + } + + @override + Future loadCustomSql(String sql) async { + final dataResult = await ExtensionDriverSession.instance.query( + connectionRow, + sql, + ); + return TableDataPage( + columns: dataResult.columns, + rows: dataResult.rows, + totalRowCount: null, + ); + } + + @override + Future applyStagedChanges({ + required TableMutationPlan plan, + required DataGridStagingBuffer buffer, + Duration? timeout, + }) async { + final primaryKeys = _primaryKeys; + if (primaryKeys.isEmpty) { + throw StateError( + 'Cannot save: no primary key is available for $tableName. Edits would match all rows.', + ); + } + + final mutations = >[]; + final columns = buffer.columns; + + // 1. Updates + for (final entry in buffer.modifiedCells.entries) { + final rowIndex = entry.key; + final colMap = entry.value; + final origRow = buffer.originalRows[rowIndex]; + + final whereMap = {}; + for (final pk in primaryKeys) { + final idx = columns.indexOf(pk); + if (idx != -1 && idx < origRow.length) { + whereMap[pk] = origRow[idx]; + } + } + + final setMap = {}; + for (final colEntry in colMap.entries) { + final colName = columns[colEntry.key]; + final val = colEntry.value; + setMap[colName] = val == TableMutationEngine.kNullSentinel ? null : val; + } + + mutations.add({ + 'type': 'update', + 'where': whereMap, + 'set': setMap, + }); + } + + // 2. Inserts + for (final row in buffer.insertedRows) { + final valuesMap = {}; + for (var c = 0; c < columns.length; c++) { + final val = c < row.length ? row[c] : null; + valuesMap[columns[c]] = (val == null || + val == TableMutationEngine.kNullSentinel || + val == 'NULL') + ? null + : val; + } + mutations.add({ + 'type': 'insert', + 'values': valuesMap, + }); + } + + // 3. Deletes + for (final rowIndex in buffer.deletedRowIndices) { + final origRow = buffer.originalRows[rowIndex]; + final whereMap = {}; + for (final pk in primaryKeys) { + final idx = columns.indexOf(pk); + if (idx != -1 && idx < origRow.length) { + whereMap[pk] = origRow[idx]; + } + } + mutations.add({ + 'type': 'delete', + 'where': whereMap, + }); + } + + if (mutations.isNotEmpty) { + final res = await ExtensionDriverSession.instance.mutate( + connectionRow, + database: database, + tableName: tableName, + mutations: mutations, + ); + final affectedRows = res['affectedRows']; + if (affectedRows is! int) { + throw StateError( + 'Save failed: driver did not return an affectedRows count.', + ); + } + expectDmlMatchedRows(affectedRows); + } + } + + @override + void dispose() {} +} + /// Paginated data browser for extension driver tables and views with async count and toolbar. class ExtensionTableView extends material.StatefulWidget { const ExtensionTableView({ @@ -41,92 +262,23 @@ class ExtensionTableView extends material.StatefulWidget { } class _ExtensionTableViewState extends material.State { - bool _loading = true; - String? _error; - List _columns = []; - List> _rows = []; - int _offset = 0; - int? _totalRows; - String? _statusLine; - - bool _filterActive = false; + late ExtensionTableDataDelegate _delegate; final _filterController = material.TextEditingController(); - - ExtensionDriverCapabilities? _capabilities; - DataGridStagingBuffer? _stagingBuffer; - bool _schemaLoaded = false; - Object? _schemaError; - List _primaryKeys = const []; - - /// Tables open in view mode; editing is switched on explicitly. - bool _editMode = false; - bool _isSaving = false; - + bool _filterActive = false; bool _restartingDriver = false; - String get _qualifiedName => '`${widget.database}`.`${widget.tableName}`'; + final GlobalKey _genericKey = + GlobalKey(); String get _whereClause { final text = _filterController.text.trim(); return text.isEmpty ? '' : ' WHERE $text'; } - bool get _isDriverError { - final err = _error; - if (err == null) return false; - return err.contains('PluginCrashedException') || - err.contains('PluginDeadlockException') || - err.contains('PluginProtocolTimeoutException') || - err.contains('TimeoutException') || - err.contains('SocketException') || - err.contains('Broken pipe') || - err.contains('JsonRpcStdioClient') || - err.contains('Connection') || - err.contains('is not started'); - } - - Future _restartDriver() async { - if (_restartingDriver) return; - if (!await _confirmDiscardIfNeeded()) return; - if (!mounted) return; - _stagingBuffer?.dispose(); - _stagingBuffer = null; - _schemaLoaded = false; - _schemaError = null; - setState(() { - _restartingDriver = true; - }); - try { - await ExtensionDriverSession.instance.restart(widget.connectionRow); - if (!mounted) return; - showAppToast( - context: context, - message: 'Driver restarted successfully', - variant: AppToastVariant.success, - ); - setState(() { - _restartingDriver = false; - _error = null; - }); - await _loadPage(refreshCount: true); - } catch (e) { - if (!mounted) return; - setState(() { - _error = 'Driver restart failed: $e'; - _restartingDriver = false; - }); - showAppToast( - context: context, - message: 'Driver restart failed: $e', - variant: AppToastVariant.error, - ); - } - } - @override void initState() { super.initState(); - unawaited(_loadPage(refreshCount: true)); + _delegate = _createDelegate(); } @override @@ -134,379 +286,79 @@ class _ExtensionTableViewState extends material.State { super.didUpdateWidget(oldWidget); if (oldWidget.connectionRow.id != widget.connectionRow.id || oldWidget.database != widget.database || - oldWidget.tableName != widget.tableName) { - unawaited(() async { - if (!await _confirmDiscardIfNeeded()) return; - if (!mounted) return; - _stagingBuffer?.dispose(); - _stagingBuffer = null; - _editMode = false; - _schemaLoaded = false; - _schemaError = null; - _offset = 0; - _totalRows = null; - _filterController.clear(); - _filterActive = false; - await _loadPage(refreshCount: true); - }()); + oldWidget.tableName != widget.tableName || + oldWidget.isView != widget.isView) { + _delegate.dispose(); + _filterController.clear(); + _filterActive = false; + _delegate = _createDelegate(); } } @override void dispose() { - _stagingBuffer?.dispose(); - _stagingBuffer = null; + _delegate.dispose(); _filterController.dispose(); super.dispose(); } - String get _tableTitle => '${widget.database}.${widget.tableName}'; - - bool get _isDirty => _stagingBuffer?.isDirty == true; - - /// Whether this table could be edited (driver mutations, PK, not a view). - bool get _canEdit => tableViewEditingEnabled( - isView: widget.isView, - customSqlActive: false, - hasPrimaryKey: _primaryKeys.isNotEmpty, - readOnly: _capabilities?.supportsMutations != true, - schemaError: _schemaError, - ); - - String? _editDisabledReason() => tableViewEditDisabledReason( - isView: widget.isView, - customSqlActive: false, - hasPrimaryKey: _primaryKeys.isNotEmpty, - schemaLoaded: _schemaLoaded, - readOnly: _capabilities != null && !_capabilities!.supportsMutations, - schemaError: _schemaError, - ); - - void _enterEditMode() { - if (!_canEdit || _editMode) return; - setState(() { - _editMode = true; - _stagingBuffer = replaceTableViewStagingBuffer( - previous: _stagingBuffer, - columns: _columns, - rows: _rows, - enabled: true, - primaryKeys: _primaryKeys, - ); - }); - } - - Future _exitEditMode() async { - if (!_editMode) return; - if (!await _confirmDiscardIfNeeded()) return; - if (!mounted) return; - setState(() { - _editMode = false; - _stagingBuffer?.dispose(); - _stagingBuffer = null; - }); - } - - 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) { - pag = _loading - ? 'Loading data...' - : 'Showing $shownTo row(s) (Calculating count...).'; - } else { - pag = 'Rows $shownFrom–$shownTo of $total.'; - } - final reason = tableViewEditDisabledReason( + ExtensionTableDataDelegate _createDelegate() { + return ExtensionTableDataDelegate( + connectionRow: widget.connectionRow, + database: widget.database, + tableName: widget.tableName, isView: widget.isView, - customSqlActive: false, - hasPrimaryKey: _primaryKeys.isNotEmpty, - schemaLoaded: _schemaLoaded, - readOnly: _capabilities != null && !_capabilities!.supportsMutations, - schemaError: _schemaError, + whereClauseProvider: () => _whereClause, ); - if (reason != null) { - _statusLine = '$pag · $reason'; - } else { - _statusLine = pag; - } } - Future _fetchCountAsync({required bool refresh}) async { - if (!refresh && _totalRows != null) return; - try { - final countQuery = - 'SELECT count(*) AS cnt FROM $_qualifiedName$_whereClause'; - final countResult = await ExtensionDriverSession.instance.query( - widget.connectionRow, - countQuery, - ); - if (countResult.rows.isNotEmpty && countResult.rows.first.isNotEmpty) { - if (!mounted) return; - setState(() { - _totalRows = int.tryParse(countResult.rows.first.first); - _updateStatusLine(); - }); - return; - } - } catch (_) { - // Fallback for drivers that only support count() without asterisk - try { - final fallbackQuery = - 'SELECT count() AS cnt FROM $_qualifiedName$_whereClause'; - final countResult = await ExtensionDriverSession.instance.query( - widget.connectionRow, - fallbackQuery, - ); - if (countResult.rows.isNotEmpty && countResult.rows.first.isNotEmpty) { - if (!mounted) return; - setState(() { - _totalRows = int.tryParse(countResult.rows.first.first); - _updateStatusLine(); - }); - } - } catch (_) {} - } + bool _isDriverError(String? err) { + if (err == null) return false; + return err.contains('PluginCrashedException') || + err.contains('PluginDeadlockException') || + err.contains('PluginProtocolTimeoutException') || + err.contains('TimeoutException') || + err.contains('SocketException') || + err.contains('Broken pipe') || + err.contains('JsonRpcStdioClient') || + err.contains('Connection') || + err.contains('is not started'); } - Future _loadPage({bool refreshCount = false}) async { + Future _restartDriver(GenericTableViewState state) async { + if (_restartingDriver) return; + if (!await state.confirmDiscardIfNeeded()) return; if (!mounted) return; + setState(() { - _loading = true; - _error = null; - if (refreshCount) _totalRows = null; - _updateStatusLine(); + _restartingDriver = true; }); - try { - _capabilities ??= await ExtensionDriverSession.instance - .getCapabilities(widget.connectionRow); - - final dataResult = await ExtensionDriverSession.instance.query( - widget.connectionRow, - 'SELECT * FROM $_qualifiedName$_whereClause LIMIT ${widget.pageSize} OFFSET $_offset', - ); - - await _ensureSchema(); - + await ExtensionDriverSession.instance.restart(widget.connectionRow); if (!mounted) return; + showAppToast( + context: context, + message: 'Driver restarted successfully', + variant: AppToastVariant.success, + ); setState(() { - _columns = dataResult.columns; - _rows = dataResult.rows; - _loading = false; - _stagingBuffer = replaceTableViewStagingBuffer( - previous: _stagingBuffer, - columns: _columns, - rows: _rows, - enabled: _canEdit && _editMode, - primaryKeys: _primaryKeys, - ); - _updateStatusLine(); + _restartingDriver = false; }); - - unawaited(_fetchCountAsync(refresh: refreshCount || _totalRows == null)); + await state.refresh(); } catch (e) { if (!mounted) return; setState(() { - _error = e.toString(); - _loading = false; - _updateStatusLine(); + _restartingDriver = false; }); - } - } - - Future _onApplyChanges() async { - 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 mutations = >[]; - - // 1. Updates - for (final entry in buffer.modifiedCells.entries) { - final rowIndex = entry.key; - final colMap = entry.value; - final origRow = buffer.originalRows[rowIndex]; - - final whereMap = {}; - for (final pk in primaryKeys) { - final idx = _columns.indexOf(pk); - if (idx != -1 && idx < origRow.length) { - whereMap[pk] = origRow[idx]; - } - } - - final setMap = {}; - for (final colEntry in colMap.entries) { - final colName = _columns[colEntry.key]; - final val = colEntry.value; - setMap[colName] = - val == TableMutationEngine.kNullSentinel ? null : val; - } - - mutations.add({ - 'type': 'update', - 'where': whereMap, - 'set': setMap, - }); - } - - // 2. Inserts - for (final row in buffer.insertedRows) { - final valuesMap = {}; - for (var c = 0; c < _columns.length; c++) { - final val = c < row.length ? row[c] : null; - valuesMap[_columns[c]] = (val == null || - val == TableMutationEngine.kNullSentinel || - val == 'NULL') - ? null - : val; - } - mutations.add({ - 'type': 'insert', - 'values': valuesMap, - }); - } - - // 3. Deletes - for (final rowIndex in buffer.deletedRowIndices) { - final origRow = buffer.originalRows[rowIndex]; - final whereMap = {}; - for (final pk in primaryKeys) { - final idx = _columns.indexOf(pk); - if (idx != -1 && idx < origRow.length) { - whereMap[pk] = origRow[idx]; - } - } - mutations.add({ - 'type': 'delete', - 'where': whereMap, - }); - } - - if (mutations.isNotEmpty) { - final res = await ExtensionDriverSession.instance.mutate( - widget.connectionRow, - database: widget.database, - tableName: widget.tableName, - mutations: mutations, - ); - if (!mounted) return; - 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 $affectedRows mutation(s).', - variant: AppToastVariant.success, - ); - unawaited(_loadPage(refreshCount: true)); - } - } catch (e) { - if (!mounted) return; showAppToast( context: context, - message: 'Failed to apply mutations: $e', + message: 'Driver restart failed: $e', variant: AppToastVariant.error, ); - } finally { - if (mounted) setState(() => _isSaving = false); } } - void _applyFilter() { - unawaited(_navigateAndLoad(() { - _offset = 0; - _totalRows = null; - })); - } - - void _clearFilter() { - _filterController.clear(); - unawaited(_navigateAndLoad(() { - _offset = 0; - _totalRows = null; - })); - } - - Future _openDdlDialog() async { + Future _openDdlDialog(material.BuildContext context) async { final navigator = material.Navigator.of(context, rootNavigator: true); unawaited(showAppDialog( context: context, @@ -527,7 +379,7 @@ class _ExtensionTableViewState extends material.State { final ddlText = meta.ddl?.trim().isNotEmpty == true ? meta.ddl! - : '-- No DDL metadata returned by extension driver for ${widget.tableName}\nSELECT * FROM $_qualifiedName LIMIT 10;'; + : '-- No DDL metadata returned by extension driver for ${widget.tableName}\nSELECT * FROM ${_delegate.qualifiedName} LIMIT 10;'; await showAppDialog( context: context, @@ -582,115 +434,43 @@ class _ExtensionTableViewState extends material.State { } } - 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; - } - - void _previousPage() { - if (!_canGoBack || _loading) return; - unawaited(_navigateAndLoad(() { - _offset = (_offset - widget.pageSize).clamp(0, 1 << 30); - })); + void _applyFilter(GenericTableViewState state) { + unawaited(state.refresh()); } - void _nextPage() { - if (!_canGoForward || _loading) return; - unawaited(_navigateAndLoad(() { - _offset += widget.pageSize; - })); + void _clearFilter(GenericTableViewState state) { + _filterController.clear(); + unawaited(state.refresh()); } @override material.Widget build(material.BuildContext context) { final kind = widget.isView ? 'View' : 'Table'; - - void toggleEditMode() { - if (_editMode) { - unawaited(_exitEditMode()); - } else { - _enterEditMode(); - } - } - - return material.CallbackShortcuts( - bindings: { - const material.SingleActivator(LogicalKeyboardKey.keyE, control: true): - toggleEditMode, - const material.SingleActivator(LogicalKeyboardKey.keyE, meta: true): - toggleEditMode, - }, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - ExtensionTableToolbar( - title: '$kind · ${widget.database}.${widget.tableName}', - paginationLabel: _statusLine ?? 'Loading...', - tableIcon: widget.isView - ? material.Icons.view_list_rounded - : material.Icons.table_chart_outlined, - loading: _loading, - canGoPrevious: _canGoBack && !_loading, - canGoNext: _canGoForward && !_loading, - 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: () { - setState(() { - _filterActive = !_filterActive; - }); - }, - onOpenDdl: _openDdlDialog, - onGoPrevious: _previousPage, - onGoNext: _nextPage, - onRefresh: () => unawaited(_onRefresh()), - onRestartDriver: () => unawaited(_restartDriver()), - isRestarting: _restartingDriver, - editAction: widget.isView - ? null - : TableEditModeButton( - editMode: _editMode, - canEdit: _canEdit, - busy: _loading, - disabledReason: _editDisabledReason(), - onEdit: _enterEditMode, - onDone: () => unawaited(_exitEditMode()), - ), - onCopyFormat: (format) { - unawaited(() async { - await DataExportService.copyToClipboard( - format, - columns: _columns, - rows: _rows, - ); - }()); - }, - onSaveFormat: (format) { - unawaited(() async { - final outcome = await DataExportService.saveToFile( - format, - columns: _columns, - rows: _rows, - ); - if (!context.mounted) return; - if (outcome == SaveExportOutcome.error) { - await _showSaveFileErrorDialog(context); - } - }()); - }, - ), - if (_filterActive) - material.Container( + final tableTitle = '$kind · ${widget.database}.${widget.tableName}'; + + return GenericTableView( + key: _genericKey, + delegate: _delegate, + title: tableTitle, + tableTitle: '${widget.database}.${widget.tableName}', + dialect: SqlDialect.postgres, // Generic fallback + tableName: widget.tableName, + schema: widget.database, + isView: widget.isView, + limit: widget.pageSize, + onNavigateHome: widget.onNavigateHome, + showExportToolbar: false, + errorAction: _isDriverError(_genericKey.currentState?.error) + ? ExtensionDriverRecoveryBanner( + onRestart: () { + final st = _genericKey.currentState; + if (st != null) unawaited(_restartDriver(st)); + }, + isRestarting: _restartingDriver, + ) + : null, + subToolbar: _filterActive + ? material.Container( padding: const material.EdgeInsets.symmetric( horizontal: 16, vertical: 8), decoration: material.BoxDecoration( @@ -717,47 +497,87 @@ class _ExtensionTableViewState extends material.State { isDense: true, border: material.OutlineInputBorder(), ), - onSubmitted: (_) => _applyFilter(), + onSubmitted: (_) { + final st = _genericKey.currentState; + if (st != null) _applyFilter(st); + }, ), ), const Gap(8), OutlineButton( size: ButtonSize.small, - onPressed: _applyFilter, + onPressed: () { + final st = _genericKey.currentState; + if (st != null) _applyFilter(st); + }, child: const Text('Apply'), ), if (_filterController.text.isNotEmpty) ...[ const Gap(6), GhostButton( size: ButtonSize.small, - onPressed: _clearFilter, + onPressed: () { + final st = _genericKey.currentState; + if (st != null) _clearFilter(st); + }, child: const Text('Clear'), ), ], ], ), - ), - material.Expanded( - child: ResultsTab( - columns: _columns, - rows: _rows, - errorMessage: _error, - isLoading: _loading, - statusLine: _statusLine, - showExportToolbar: false, - stagingBuffer: _stagingBuffer, - onApplyChanges: _stagingBuffer != null ? _onApplyChanges : null, - isSaving: _isSaving, - errorAction: _isDriverError - ? ExtensionDriverRecoveryBanner( - onRestart: () => unawaited(_restartDriver()), - isRestarting: _restartingDriver, - ) - : null, - ), - ), - ], - ), + ) + : null, + customToolbarBuilder: (ctx, state) { + return ExtensionTableToolbar( + title: tableTitle, + paginationLabel: state.statusLine() ?? 'Loading...', + tableIcon: widget.isView + ? material.Icons.view_list_rounded + : material.Icons.table_chart_outlined, + loading: state.isLoading, + canGoPrevious: state.canGoPrevious, + canGoNext: state.canGoNext, + onNavigateHome: widget.onNavigateHome != null + ? () => unawaited(state.navigateHome()) + : null, + filterActive: _filterActive || _filterController.text.isNotEmpty, + filterText: _filterController.text, + onToggleFilter: () { + setState(() { + _filterActive = !_filterActive; + }); + }, + onOpenDdl: () => unawaited(_openDdlDialog(ctx)), + onGoPrevious: state.goToPreviousPage, + onGoNext: state.goToNextPage, + onRefresh: () => unawaited(state.refresh()), + onRestartDriver: () => unawaited(_restartDriver(state)), + isRestarting: _restartingDriver, + editAction: widget.isView ? null : state.buildEditModeButton(), + onCopyFormat: (format) { + unawaited(() async { + await DataExportService.copyToClipboard( + format, + columns: state.columnNames, + rows: state.rows, + ); + }()); + }, + onSaveFormat: (format) { + unawaited(() async { + final outcome = await DataExportService.saveToFile( + format, + columns: state.columnNames, + rows: state.rows, + ); + if (!ctx.mounted) return; + if (outcome == SaveExportOutcome.error) { + await _showSaveFileErrorDialog(ctx); + } + }()); + }, + ); + }, ); } } diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index 64287c59..c4ca8508 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -1,16 +1,15 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:mysql_client/mysql_client.dart'; import 'package:querya_desktop/core/database/mysql_connection.dart'; import 'package:querya_desktop/core/database/mysql_result_cells.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_limit.dart'; -import 'package:querya_desktop/core/storage/app_settings.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/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/mysql/mysql_sql_editor_dialog.dart'; import 'package:querya_desktop/features/mysql/mysql_table_utils.dart'; @@ -19,15 +18,13 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; const _defaultLimit = 200; -class MysqlTableView extends material.StatefulWidget { - const MysqlTableView({ - super.key, +/// MySQL delegate for [GenericTableView]. +class MysqlTableDataDelegate extends TableDataMutationDelegate { + MysqlTableDataDelegate({ required this.connectionRow, required this.database, required this.tableName, this.isView = false, - this.limit = _defaultLimit, - this.onNavigateHome, this.isReadOnly = false, }); @@ -35,185 +32,92 @@ class MysqlTableView extends material.StatefulWidget { final String database; final String tableName; final bool isView; - final int limit; - final VoidCallback? onNavigateHome; - - /// Title-bar session lock: no staging / Save / `tableWrite` acquire. final bool isReadOnly; - @override - material.State createState() => _MysqlTableViewState(); -} - -class _MysqlTableViewState extends material.State { MysqlLease? _lease; MysqlConnection? get _connection => _lease?.connection; - bool _loading = true; - String? _error; - - List _columnNames = []; - List> _rows = []; - int _rowsOnPage = 0; - int? _totalRowCount; - int _offset = 0; - bool _customSqlActive = false; - String? _customSql; - - DataGridStagingBuffer? _stagingBuffer; - List _primaryKeys = []; Map _columnDataTypes = {}; - Map _columnMeta = {}; - bool _schemaLoaded = false; - Object? _schemaError; - bool _isSaving = false; - - String get _tableTitle => '${widget.database}.${widget.tableName}'; - - bool get _isDirty => _stagingBuffer?.isDirty ?? false; - - /// Tables open in view mode; editing is switched on explicitly. - bool _editMode = false; - - /// Whether this table could be edited (PK, not a view, writable). - bool get _canEdit => tableViewEditingEnabled( - isView: widget.isView, - customSqlActive: _customSqlActive, - hasPrimaryKey: _primaryKeys.isNotEmpty, - readOnly: widget.isReadOnly, - schemaError: _schemaError, - ); - - /// Edit mode is on and the table is editable. - bool get _editingEnabled => _canEdit && _editMode; + List _primaryKeys = []; String _qualifiedFrom() { - final d = MysqlConnection.quoteIdentifier(widget.database); - final t = MysqlConnection.quoteIdentifier(widget.tableName); + final d = MysqlConnection.quoteIdentifier(database); + final t = MysqlConnection.quoteIdentifier(tableName); return '$d.$t'; } - String _browseDataSql() { - return mysqlBrowseDataSql( - qualifiedFrom: _qualifiedFrom(), - primaryKeys: _primaryKeys, - limit: widget.limit, - offset: _offset, + Future _ensureReadConnection() async { + if (_lease != null && _connection?.isConnected == true) return; + _lease?.release(); + _lease = await MysqlService.instance.acquire( + connectionRow, + database: database, + mode: MysqlSessionMode.readOnly, ); } - @override - void initState() { - super.initState(); - _connectAndLoad(); - } - - @override - void didUpdateWidget(covariant MysqlTableView oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.connectionRow.id != widget.connectionRow.id || - oldWidget.database != widget.database || - oldWidget.tableName != widget.tableName || - oldWidget.isView != widget.isView) { - _customSqlActive = false; - _customSql = null; - _resetStaging(); - _disconnectCurrent(interruptIfBusy: true); - _connectAndLoad(); - } else if (oldWidget.isReadOnly != widget.isReadOnly) { - _syncStagingToReadOnly(); + Future withTableWrite( + Future Function(MysqlConnection conn) fn, + ) async { + final lease = await MysqlService.instance.acquire( + connectionRow, + database: database, + mode: MysqlSessionMode.tableWrite, + ); + try { + return await fn(lease.connection); + } finally { + lease.release(); } } @override - void dispose() { - _resetStaging(); - _disconnectCurrent(interruptIfBusy: true); - super.dispose(); - } - - void _resetStaging() { - _stagingBuffer?.dispose(); - _stagingBuffer = null; - _primaryKeys = []; - _columnDataTypes = {}; - _columnMeta = {}; - _schemaLoaded = false; - _schemaError = null; - _isSaving = false; + String browseDataSql({required int offset, required int limit}) { + return mysqlBrowseDataSql( + qualifiedFrom: _qualifiedFrom(), + primaryKeys: _primaryKeys, + limit: limit, + offset: offset, + ); } - void _syncStagingToReadOnly() { - if (widget.isReadOnly) { - _stagingBuffer?.dispose(); - _stagingBuffer = null; - } else if (_columnNames.isNotEmpty) { - _stagingBuffer = replaceTableViewStagingBuffer( - previous: _stagingBuffer, - columns: _columnNames, - rows: _rows, - enabled: _editingEnabled, - primaryKeys: _primaryKeys, - ); - } - if (mounted) setState(() {}); - } + @override + bool isAllowedSelectQuery(String sql) => isAllowedMysqlSelectQuery(sql); - void _disconnectCurrent({bool interruptIfBusy = false}) { - if (interruptIfBusy && _loading) { - MysqlService.instance.interrupt( - widget.connectionRow, - database: widget.database, - mode: MysqlSessionMode.readOnly, - ); + @override + Future loadSchema() async { + if (isView || isReadOnly) { + _primaryKeys = []; + _columnDataTypes = {}; + return const TableDataSchemaInfo(); } - if (interruptIfBusy && _isSaving) { - MysqlService.instance.interrupt( - widget.connectionRow, - database: widget.database, - mode: MysqlSessionMode.tableWrite, - ); + await _ensureReadConnection(); + final conn = _connection; + if (conn == null || !conn.isConnected) { + throw StateError('Not connected'); } - _lease?.release(); - _lease = null; - } - Future _connectAndLoad() async { - _editMode = false; - _disconnectCurrent(); - if (!mounted) return; - setState(() { - _loading = true; - _error = null; - _columnNames = []; - _rows = []; - _rowsOnPage = 0; - _totalRowCount = null; - _offset = 0; - _customSqlActive = false; - _customSql = null; - _resetStaging(); - }); - try { - final lease = await MysqlService.instance.acquire( - widget.connectionRow, - database: widget.database, - mode: MysqlSessionMode.readOnly, + final loaded = await loadTableViewSchema( + () => conn.getTableSchema( + database: database, + table: tableName, + ), + ); + final s = loaded.schema; + if (s != null) { + _primaryKeys = List.from(s.primaryKeys); + _columnDataTypes = columnDataTypesFromSchema(s); + return TableDataSchemaInfo( + primaryKeys: _primaryKeys, + columnDataTypes: _columnDataTypes, + columnMeta: columnMetaFromSchema(s), ); - if (!mounted) { - lease.release(); - return; - } - _lease = lease; - await _fetch(refreshCount: true); - } catch (e) { - if (mounted) { - setState(() { - _error = e.toString(); - _loading = false; - }); - } } + _primaryKeys = []; + _columnDataTypes = {}; + return TableDataSchemaInfo( + schemaError: loaded.error, + ); } List _resultColumns(IResultSet rs) { @@ -246,652 +150,353 @@ class _MysqlTableViewState extends material.State { return out; } - Future _withTableWrite( - Future Function(MysqlConnection conn) fn, - ) async { - if (widget.isReadOnly) { - throw StateError('MySQL session is read-only'); - } - final lease = await MysqlService.instance.acquire( - widget.connectionRow, - database: widget.database, - mode: MysqlSessionMode.tableWrite, - ); - try { - return await fn(lease.connection); - } finally { - lease.release(); + @override + Future loadPage({ + required int offset, + required int limit, + bool refreshCount = false, + }) async { + await _ensureReadConnection(); + final conn = _connection; + if (conn == null || !conn.isConnected) { + throw StateError('Not connected'); } - } - String? _editDisabledReason() => tableViewEditDisabledReason( - isView: widget.isView, - customSqlActive: _customSqlActive, - hasPrimaryKey: _primaryKeys.isNotEmpty, - schemaLoaded: _schemaLoaded, - readOnly: widget.isReadOnly, - schemaError: _schemaError, - ); - - void _enterEditMode() { - if (!_canEdit || _editMode) return; - setState(() { - _editMode = true; - _stagingBuffer = replaceTableViewStagingBuffer( - previous: _stagingBuffer, - columns: _columnNames, - rows: _rows, - enabled: _editingEnabled, - primaryKeys: _primaryKeys, - ); - }); - } - - Future _exitEditMode() async { - if (!_editMode) return; - if (!await _confirmDiscardIfNeeded()) return; - if (!mounted) return; - setState(() { - _editMode = false; - _stagingBuffer?.dispose(); - _stagingBuffer = null; - }); - } - - void _toggleEditMode() { - if (_editMode) { - unawaited(_exitEditMode()); - } else { - _enterEditMode(); + int? totalRows; + if (refreshCount) { + try { + totalRows = await conn.estimateTableRows( + database: database, + table: tableName, + ); + } catch (_) { + totalRows = null; + } } - } - material.Widget _editModeButton() => TableEditModeButton( - editMode: _editMode, - canEdit: _canEdit, - busy: _loading || _isSaving, - disabledReason: _editDisabledReason(), - onEdit: _enterEditMode, - onDone: () => unawaited(_exitEditMode()), - ); + final dataSql = browseDataSql(offset: offset, limit: limit); + final result = await conn.execute(dataSql); - Future _confirmDiscardIfNeeded() { - return confirmDiscardTableEditsIfDirty( - context: context, - buffer: _stagingBuffer, - tableTitle: _tableTitle, - ); - } - - Future _ensureSchema(MysqlConnection conn) async { - if (_schemaLoaded) return; - if (widget.isView) { - _schemaLoaded = true; - _schemaError = null; - _primaryKeys = []; - _columnDataTypes = {}; - _columnMeta = {}; - return; - } - final loaded = await loadTableViewSchema( - () => conn.getTableSchema( - database: widget.database, - table: widget.tableName, - ), - ); - final schema = loaded.schema; - if (schema != null) { - _primaryKeys = List.from(schema.primaryKeys); - _columnDataTypes = columnDataTypesFromSchema(schema); - _columnMeta = columnMetaFromSchema(schema); - _schemaError = null; - } else { - _primaryKeys = []; - _columnDataTypes = {}; - _columnMeta = {}; - _schemaError = loaded.error; - } - _schemaLoaded = true; - } + final colNames = _resultColumns(result); + final stringRows = await _resultRowsAsync(result); - void _installStagingBuffer(List columns, List> rows) { - _stagingBuffer = replaceTableViewStagingBuffer( - previous: _stagingBuffer, - columns: columns, - rows: rows, - enabled: _editingEnabled, - primaryKeys: _primaryKeys, + return TableDataPage( + columns: colNames, + rows: stringRows, + totalRowCount: totalRows, ); } - Future _fetch({bool refreshCount = false}) async { + @override + Future loadCustomSql(String sql) async { + await _ensureReadConnection(); final conn = _connection; if (conn == null || !conn.isConnected) { - if (mounted && _loading) { - setState(() { - _error = 'Not connected'; - _loading = false; - }); - } - return; + throw StateError('Not connected'); } - if (_customSqlActive) { - await _fetchCustom(); - return; - } - if (!mounted) return; - setState(() { - _loading = true; - _error = null; - }); - try { - await _ensureSchema(conn); - if (!mounted) return; - - int? totalRows = _totalRowCount; - if (refreshCount || totalRows == null) { - try { - totalRows = await conn.estimateTableRows( - database: widget.database, - table: widget.tableName, - ); - } catch (_) { - totalRows = null; - } - } - final dataSql = _browseDataSql(); - final result = await conn.execute(dataSql); - if (!mounted) return; + final result = await conn.execute( + injectSqlLimit(sql, kDefaultSqlResultMaxRows), + ); + final colNames = _resultColumns(result); + final stringRows = await _resultRowsAsync(result); - final colNames = _resultColumns(result); - final stringRows = await _resultRowsAsync(result); + return TableDataPage( + columns: colNames, + rows: stringRows, + totalRowCount: null, + ); + } - if (!mounted) return; - final shown = stringRows.length; - if (totalRows != null && shown > 0 && totalRows < _offset + shown) { - totalRows = null; + @override + Future applyStagedChanges({ + required TableMutationPlan plan, + required DataGridStagingBuffer buffer, + Duration? timeout, + }) async { + if (isReadOnly) return; + await withTableWrite((conn) async { + if (!conn.isConnected) { + throw StateError('Could not connect to MySQL.'); } - setState(() { - _columnNames = colNames; - _rows = stringRows; - _rowsOnPage = shown; - if (refreshCount || _totalRowCount == null) { - _totalRowCount = totalRows; + await conn.runInTransaction(() async { + for (final stmt in plan.statements) { + final rs = await conn.execute(stmt.sql); + expectDmlMatchedRows(rs.affectedRows.toInt()); } - _loading = false; - _installStagingBuffer(colNames, stringRows); }); - } catch (e) { - if (mounted) { - setState(() { - _error = e.toString(); - _loading = false; - }); - } - } + }); } - Future _fetchCustom() async { - final conn = _connection; - if (conn == null || !conn.isConnected) { - if (mounted && _loading) { - setState(() { - _error = 'Not connected'; - _loading = false; - }); - } - return; - } - final sql = _customSql; - if (sql == null || sql.isEmpty) return; - if (!mounted) return; - setState(() { - _loading = true; - _error = null; - }); - try { - final result = await conn.execute( - injectSqlLimit(sql, kDefaultSqlResultMaxRows), + @override + void cancel({bool interruptIfBusy = false}) { + if (interruptIfBusy) { + MysqlService.instance.interrupt( + connectionRow, + database: database, + mode: MysqlSessionMode.readOnly, + ); + MysqlService.instance.interrupt( + connectionRow, + database: database, + mode: MysqlSessionMode.tableWrite, ); - if (!mounted) return; - final stringRows = await _resultRowsAsync(result); - if (!mounted) return; - setState(() { - _columnNames = _resultColumns(result); - _rows = stringRows; - _rowsOnPage = _rows.length; - _totalRowCount = null; - _loading = false; - _installStagingBuffer(_columnNames, stringRows); - }); - } catch (e) { - if (mounted) { - setState(() { - _error = e.toString(); - _loading = false; - }); - } } } - Future _onSqlRun(String sql) async { - final trimmed = sql.trim(); - if (!isAllowedMysqlSelectQuery(trimmed)) return; - if (!await _confirmDiscardIfNeeded()) return; - if (!mounted) return; - final browse = _browseDataSql().trim(); - if (_browseSqlCompareKey(trimmed) == _browseSqlCompareKey(browse)) { - setState(() { - _customSqlActive = false; - _customSql = null; - }); - await _fetch(refreshCount: true); - } else { - setState(() { - _customSqlActive = true; - _customSql = trimmed; - }); - await _fetchCustom(); - } + @override + void dispose() { + _lease?.release(); + _lease = null; } +} - void _openSqlEditor() { - showMysqlSqlEditorDialog( - context: context, - initialSql: (_customSqlActive && _customSql != null) - ? _customSql! - : _browseDataSql(), - browseSql: _browseDataSql(), - onRun: (sql) => unawaited(_onSqlRun(sql)), - ); - } +/// Paginated data browser for MySQL tables and views. +class MysqlTableView extends material.StatefulWidget { + const MysqlTableView({ + super.key, + required this.connectionRow, + required this.database, + required this.tableName, + this.isView = false, + this.limit = _defaultLimit, + this.onNavigateHome, + this.isReadOnly = false, + }); - Future _exitCustomMode() async { - if (!await _confirmDiscardIfNeeded()) return; - if (!mounted) return; - setState(() { - _customSqlActive = false; - _customSql = null; - }); - await _fetch(refreshCount: true); - } + final ConnectionRow connectionRow; + final String database; + final String tableName; + final bool isView; + final int limit; + final material.VoidCallback? onNavigateHome; + final bool isReadOnly; - void _goToPreviousPage() { - if (_customSqlActive) return; - if (_offset <= 0 || _loading || _isDirty) return; - setState(() { - final next = _offset - widget.limit; - _offset = next < 0 ? 0 : next; - }); - unawaited(_fetch()); - } + @override + material.State createState() => _MysqlTableViewState(); +} - void _goToNextPage() { - if (_customSqlActive) return; - if (_loading || _isDirty) return; - final total = _totalRowCount; - final limit = widget.limit; - if (total != null && _offset + _rowsOnPage >= total) return; - if (total == null && _rowsOnPage < limit) return; - setState(() { - _offset += limit; - }); - unawaited(_fetch()); - } +class _MysqlTableViewState extends material.State { + late MysqlTableDataDelegate _delegate; - bool get _canGoPrevious => - !_customSqlActive && _offset > 0 && !_loading && !_isDirty; + @override + void initState() { + super.initState(); + _delegate = _createDelegate(); + } - bool get _canGoNext { - if (_customSqlActive || _loading || _isDirty) return false; - final total = _totalRowCount; - final limit = widget.limit; - if (total != null) { - return _offset + _rowsOnPage < total; + @override + void didUpdateWidget(covariant MysqlTableView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.connectionRow.id != widget.connectionRow.id || + oldWidget.database != widget.database || + oldWidget.tableName != widget.tableName || + oldWidget.isView != widget.isView || + oldWidget.isReadOnly != widget.isReadOnly) { + _delegate.dispose(); + _delegate = _createDelegate(); } - return _rowsOnPage >= limit; } - String _paginationLabel() { - if (_customSqlActive) { - if (_rowsOnPage == 0) return '0 rows (custom SQL)'; - return '$_rowsOnPage row${_rowsOnPage == 1 ? '' : 's'} (custom SQL)'; - } - if (_rowsOnPage == 0) { - final t = _totalRowCount; - if (t == null) return '0 rows'; - return '0 of $t'; - } - final start = _offset + 1; - final end = _offset + _rowsOnPage; - final total = _totalRowCount; - if (total != null) { - return '$start–$end of $total'; - } - return '$start–$end'; + @override + void dispose() { + _delegate.dispose(); + super.dispose(); } - String? _statusLine() { - final reason = tableViewEditDisabledReason( + MysqlTableDataDelegate _createDelegate() { + return MysqlTableDataDelegate( + connectionRow: widget.connectionRow, + database: widget.database, + tableName: widget.tableName, isView: widget.isView, - customSqlActive: _customSqlActive, - hasPrimaryKey: _primaryKeys.isNotEmpty, - schemaLoaded: _schemaLoaded, - readOnly: widget.isReadOnly, - schemaError: _schemaError, + isReadOnly: widget.isReadOnly, ); - final pag = _paginationLabel(); - if (reason != null) return '$pag · $reason'; - return pag; } - Future _onRefresh() async { - if (!await _confirmDiscardIfNeeded()) return; - if (!mounted) return; - _schemaLoaded = false; - _schemaError = null; - if (_customSqlActive) { - await _fetchCustom(); - } else { - await _fetch(refreshCount: true); - } + void _openSqlEditor(material.BuildContext context, GenericTableViewState state) { + showMysqlSqlEditorDialog( + context: context, + initialSql: (state.customSqlActive && state.customSql != null) + ? state.customSql! + : state.browseDataSql(), + browseSql: state.browseDataSql(), + onRun: (sql) => unawaited(state.runCustomSql(sql)), + ); } - Future _onNavigateHome() async { - final home = widget.onNavigateHome; - if (home == null) return; - if (!await _confirmDiscardIfNeeded()) return; - if (!mounted) return; - home(); - } + @override + material.Widget build(material.BuildContext context) { + final tableTitle = '${widget.database}.${widget.tableName}'; - Future _applyStagedChanges() async { - if (widget.isReadOnly) return; - final buffer = _stagingBuffer; - if (buffer == null || !buffer.isDirty || _isSaving) return; - setState(() => _isSaving = true); - final outcome = await applyTableViewStagedChanges( - context: context, - buffer: buffer, + return GenericTableView( + delegate: _delegate, + title: tableTitle, + tableTitle: tableTitle, dialect: SqlDialect.mysql, tableName: widget.tableName, schema: widget.database, - primaryKeys: _primaryKeys, - columnDataTypes: _columnDataTypes.isEmpty ? null : _columnDataTypes, - columnMeta: _columnMeta.isEmpty ? null : _columnMeta, - execute: (plan) async { - await _withTableWrite((conn) async { - if (!conn.isConnected) { - throw StateError('Could not connect to MySQL.'); - } - await conn.runInTransaction(() async { - for (final stmt in plan.statements) { - final rs = await conn.execute(stmt.sql); - expectDmlMatchedRows(rs.affectedRows.toInt()); - } - }); - }); - }, - ); - if (!mounted) return; - if (outcome.isApplied) { - if (buffer.insertedRowCount > 0) { - // Inserted rows have no database-generated keys / defaults yet, so a - // follow-up UPDATE / DELETE on them would match 0 rows. Reload the page. - buffer.dispose(); - setState(() { - _stagingBuffer = null; - _isSaving = false; - }); - showAppToast( - context: context, - message: tableViewSavedMessage(outcome.statementCount), - variant: AppToastVariant.success, - ); - await _fetch(refreshCount: true); - return; - } - final newRows = buffer.committedRows; - buffer.dispose(); - setState(() { - _rows = newRows; - _rowsOnPage = newRows.length; - _stagingBuffer = replaceTableViewStagingBuffer( - previous: null, - columns: _columnNames, - rows: newRows, - enabled: _editingEnabled, - primaryKeys: _primaryKeys, - ); - _isSaving = false; - }); - showAppToast( - context: context, - message: tableViewSavedMessage(outcome.statementCount), - variant: AppToastVariant.success, - ); - return; - } - setState(() => _isSaving = false); - if (outcome.isFailed && outcome.error != null) { - await showTableViewSaveFailedDialog( - context: context, - error: outcome.error!, - ); - } - } - - material.Widget _buildChromeRow(ColorScheme cs) { - final title = '$_tableTitle${widget.isView ? ' (view)' : ''}'; - return material.Container( - height: 48, - padding: const material.EdgeInsets.symmetric(horizontal: 12), - decoration: material.BoxDecoration( - color: cs.muted.withValues(alpha: 0.35), - border: material.Border( - bottom: material.BorderSide( - color: cs.border.withValues(alpha: 0.4), - ), - ), - ), - child: material.Row( - children: [ - if (widget.onNavigateHome != null) ...[ - material.Tooltip( - message: 'Return to server overview', - child: OutlineButton( - size: ButtonSize.small, - onPressed: () => unawaited(_onNavigateHome()), - leading: const material.Icon( - material.Icons.dns_outlined, - size: 14, - ), - child: const Text('Server'), - ), - ), - const Gap(10), - ], - material.Icon( - widget.isView - ? material.Icons.view_agenda_rounded - : material.Icons.table_chart_rounded, - size: 20, - color: cs.primary, - ), - const Gap(8), - material.Expanded( - child: material.Text( - title, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 13, - fontWeight: material.FontWeight.w600, - color: cs.foreground, + isView: widget.isView, + isReadOnly: widget.isReadOnly, + limit: widget.limit, + onNavigateHome: widget.onNavigateHome, + showExportToolbar: true, + customToolbarBuilder: (ctx, state) { + final cs = Theme.of(ctx).colorScheme; + final title = '$tableTitle${widget.isView ? ' (view)' : ''}'; + + return material.Container( + height: 48, + padding: const material.EdgeInsets.symmetric(horizontal: 12), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.35), + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.4), ), ), ), - material.Expanded( - flex: 2, - child: material.LayoutBuilder( - builder: (context, constraints) { - return material.SingleChildScrollView( - scrollDirection: material.Axis.horizontal, - child: material.ConstrainedBox( - constraints: material.BoxConstraints( - minWidth: constraints.maxWidth, - ), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: material.BoxDecoration( - color: cs.muted.withValues(alpha: 0.4), - borderRadius: material.BorderRadius.circular(4), - ), - child: material.Text( - _paginationLabel(), - style: material.TextStyle( - fontSize: 11, - color: cs.mutedForeground, - ), - ), - ), - const Gap(6), - if (!widget.isView) ...[ - _editModeButton(), - const Gap(4), - ], - OutlineButton( - size: ButtonSize.small, - onPressed: _openSqlEditor, - leading: const material.Icon( - material.Icons.code_rounded, - size: 15, - ), - child: const Text('SQL'), - ), - if (_customSqlActive) ...[ - const Gap(4), - OutlineButton( - size: ButtonSize.small, - onPressed: () => unawaited(_exitCustomMode()), - leading: const material.Icon( - material.Icons.table_chart_rounded, - size: 15, - ), - child: const Text('Browse'), - ), - ], - const Gap(4), - OutlineButton( - size: ButtonSize.small, - onPressed: (!_canGoPrevious || _loading) - ? null - : _goToPreviousPage, - leading: const material.Icon( - material.Icons.chevron_left_rounded, - size: 16, - ), - child: const Text('Prev'), - ), - const Gap(4), - OutlineButton( - size: ButtonSize.small, - onPressed: - (!_canGoNext || _loading) ? null : _goToNextPage, - leading: const material.Icon( - material.Icons.chevron_right_rounded, - size: 16, - ), - child: const Text('Next'), - ), - const Gap(8), - OutlineButton( - size: ButtonSize.small, - onPressed: - _loading ? null : () => unawaited(_onRefresh()), - leading: const material.Icon( - material.Icons.refresh_rounded, - size: 14, - ), - child: const Text('Refresh'), - ), - ], + child: material.Row( + children: [ + if (widget.onNavigateHome != null) ...[ + material.Tooltip( + message: 'Return to server overview', + child: OutlineButton( + size: ButtonSize.small, + onPressed: () => unawaited(state.navigateHome()), + leading: const material.Icon( + material.Icons.dns_outlined, + size: 14, ), + child: const Text('Server'), + ), + ), + const Gap(10), + ], + material.Icon( + widget.isView + ? material.Icons.view_agenda_rounded + : material.Icons.table_chart_rounded, + size: 20, + color: cs.primary, + ), + const Gap(8), + material.Expanded( + child: material.Text( + title, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, ), - ); - }, - ), - ), - ], - ), - ); - } - - @override - material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; - final buffer = _stagingBuffer; - - return material.CallbackShortcuts( - bindings: { - const material.SingleActivator(LogicalKeyboardKey.keyE, control: true): - _toggleEditMode, - const material.SingleActivator(LogicalKeyboardKey.keyE, meta: true): - _toggleEditMode, - const material.SingleActivator(LogicalKeyboardKey.f5): () { - if (!_loading) unawaited(_onRefresh()); - }, - }, - child: material.Focus( - autofocus: true, - child: material.Container( - color: cs.background, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - if (buffer == null) - _buildChromeRow(cs) - else - ListenableBuilder( - listenable: buffer, - builder: (context, _) => _buildChromeRow(cs), ), + ), material.Expanded( - child: ResultsTab( - columns: _columnNames, - rows: _rows, - errorMessage: _error, - isLoading: _loading, - statusLine: _statusLine(), - showExportToolbar: true, - stagingBuffer: _stagingBuffer, - columnDataTypes: - _columnDataTypes.isEmpty ? null : _columnDataTypes, - onApplyChanges: - _stagingBuffer != null ? _applyStagedChanges : null, - isSaving: _isSaving, + flex: 2, + child: material.LayoutBuilder( + builder: (context, constraints) { + return material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.ConstrainedBox( + constraints: material.BoxConstraints( + minWidth: constraints.maxWidth, + ), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.4), + borderRadius: material.BorderRadius.circular(4), + ), + child: material.Text( + state.paginationLabel(), + style: material.TextStyle( + fontSize: 11, + color: cs.mutedForeground, + ), + ), + ), + const Gap(6), + if (!widget.isView) ...[ + state.buildEditModeButton(), + const Gap(4), + ], + OutlineButton( + size: ButtonSize.small, + onPressed: () => _openSqlEditor(ctx, state), + leading: const material.Icon( + material.Icons.code_rounded, + size: 15, + ), + child: const Text('SQL'), + ), + if (state.customSqlActive) ...[ + const Gap(4), + OutlineButton( + size: ButtonSize.small, + onPressed: () => unawaited(state.exitCustomMode()), + leading: const material.Icon( + material.Icons.table_chart_rounded, + size: 15, + ), + child: const Text('Browse'), + ), + ], + const Gap(4), + OutlineButton( + size: ButtonSize.small, + onPressed: (!state.canGoPrevious || state.isLoading) + ? null + : state.goToPreviousPage, + leading: const material.Icon( + material.Icons.chevron_left_rounded, + size: 16, + ), + child: const Text('Prev'), + ), + const Gap(4), + OutlineButton( + size: ButtonSize.small, + onPressed: (!state.canGoNext || state.isLoading) + ? null + : state.goToNextPage, + leading: const material.Icon( + material.Icons.chevron_right_rounded, + size: 16, + ), + child: const Text('Next'), + ), + const Gap(8), + OutlineButton( + size: ButtonSize.small, + onPressed: state.isLoading + ? null + : () => unawaited(state.refresh()), + leading: const material.Icon( + material.Icons.refresh_rounded, + size: 14, + ), + child: const Text('Refresh'), + ), + ], + ), + ), + ); + }, ), ), ], ), - ), - ), + ); + }, ); } - - static String _browseSqlCompareKey(String sql) { - var s = sql.trim(); - while (s.endsWith(';')) { - s = s.substring(0, s.length - 1).trimRight(); - } - return s.replaceAll(RegExp(r'\s+'), ' '); - } } diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index 6d46b8c4..272ff3fa 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -1,7 +1,6 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:postgres/postgres.dart'; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; @@ -17,17 +16,15 @@ import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart'; import 'package:querya_desktop/features/workspace/workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -class PostgresTableView extends material.StatefulWidget { - const PostgresTableView({ - super.key, +/// PostgreSQL delegate for [GenericTableView]. +class PostgresTableDataDelegate extends TableDataMutationDelegate { + PostgresTableDataDelegate({ required this.connectionRow, required this.database, required this.schema, required this.tableName, this.isView = false, this.isMaterializedView = false, - this.limit = kPostgresBrowseDefaultRowLimit, - this.onNavigateHome, }); final ConnectionRow connectionRow; @@ -35,180 +32,30 @@ class PostgresTableView extends material.StatefulWidget { final String schema; final String tableName; final bool isView; - final VoidCallback? onNavigateHome; - - /// When true, toolbar offers REFRESH MATERIALIZED VIEW and matview label. final bool isMaterializedView; - final int limit; - @override - material.State createState() => _PostgresTableViewState(); -} - -class _PostgresTableViewState extends material.State { PgLease? _lease; PostgresConnection? get _connection => _lease?.connection; - bool _loading = true; - String? _error; - - List _columnNames = []; - List> _rows = []; - - /// Rows on the current page (same as _rows.length when not loading). - int _rowsOnPage = 0; - - /// Planner estimate (`reltuples`); `null` when unknown or stale. - int? _totalRowCount; - - /// Zero-based offset for LIMIT/OFFSET pagination. - int _offset = 0; - - /// When true, [dataSql] comes from [_customSql] (no pagination). - bool _customSqlActive = false; - String? _customSql; - - DataGridStagingBuffer? _stagingBuffer; - List _primaryKeys = []; Map _columnDataTypes = {}; - Map _columnMeta = {}; - bool _schemaLoaded = false; - Object? _schemaError; - bool _isSaving = false; - - String get _tableTitle => '${widget.schema}.${widget.tableName}'; - - bool get _isDirty => _stagingBuffer?.isDirty ?? false; - - /// Tables open in view mode; editing is switched on explicitly. - bool _editMode = false; - - /// Whether this table could be edited (PK, not a view, writable). - bool get _canEdit => tableViewEditingEnabled( - isView: widget.isView, - isMaterializedView: widget.isMaterializedView, - customSqlActive: _customSqlActive, - hasPrimaryKey: _primaryKeys.isNotEmpty, - schemaError: _schemaError, - ); - - /// Edit mode is on and the table is editable. - bool get _editingEnabled => _canEdit && _editMode; - - @override - void initState() { - super.initState(); - _connectAndLoad(); - } - - @override - void didUpdateWidget(covariant PostgresTableView oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.connectionRow.id != widget.connectionRow.id || - oldWidget.database != widget.database || - oldWidget.schema != widget.schema || - oldWidget.tableName != widget.tableName || - oldWidget.isMaterializedView != widget.isMaterializedView) { - _customSqlActive = false; - _customSql = null; - _resetStaging(); - _disconnectCurrent(); - _connectAndLoad(); - } - } - - @override - void dispose() { - _resetStaging(); - _disconnectCurrent(interruptIfBusy: true); - super.dispose(); - } - - void _resetStaging() { - _stagingBuffer?.dispose(); - _stagingBuffer = null; - _primaryKeys = []; - _columnDataTypes = {}; - _columnMeta = {}; - _schemaLoaded = false; - _schemaError = null; - _isSaving = false; - } + List _primaryKeys = []; - void _disconnectCurrent({bool interruptIfBusy = false}) { - if (interruptIfBusy && _loading) { - PostgresService.instance.interrupt( - widget.connectionRow, - database: widget.database, - mode: PgSessionMode.readOnly, - ); - } - if (interruptIfBusy && _isSaving) { - PostgresService.instance.interrupt( - widget.connectionRow, - database: widget.database, - mode: PgSessionMode.tableWrite, - ); - } + Future _ensureReadConnection() async { + if (_lease != null && _connection?.isConnected == true) return; _lease?.release(); - _lease = null; - } - - Future _connectAndLoad() async { - _editMode = false; - _disconnectCurrent(); - if (!mounted) return; - setState(() { - _loading = true; - _error = null; - _columnNames = []; - _rows = []; - _rowsOnPage = 0; - _totalRowCount = null; - _offset = 0; - _customSqlActive = false; - _customSql = null; - _resetStaging(); - }); - try { - final lease = await PostgresService.instance.acquire( - widget.connectionRow, - database: widget.database, - mode: PgSessionMode.readOnly, - ); - if (!mounted) { - lease.release(); - return; - } - _lease = lease; - await _fetch(refreshCount: true); - } catch (e) { - if (mounted) { - setState(() { - _error = e.toString(); - _loading = false; - }); - } - } - } - - String _browseDataSql() { - final schemaQ = quotePostgresIdentifier(widget.schema); - final tableQ = quotePostgresIdentifier(widget.tableName); - return postgresBrowseDataSql( - qualifiedFrom: '$schemaQ.$tableQ', - primaryKeys: _primaryKeys, - limit: widget.limit, - offset: _offset, + _lease = await PostgresService.instance.acquire( + connectionRow, + database: database, + mode: PgSessionMode.readOnly, ); } - Future _withTableWrite( + Future withTableWrite( Future Function(PostgresConnection conn) fn, ) async { final lease = await PostgresService.instance.acquire( - widget.connectionRow, - database: widget.database, + connectionRow, + database: database, mode: PgSessionMode.tableWrite, ); try { @@ -218,103 +65,54 @@ class _PostgresTableViewState extends material.State { } } - String? _editDisabledReason() => tableViewEditDisabledReason( - isView: widget.isView, - isMaterializedView: widget.isMaterializedView, - customSqlActive: _customSqlActive, - hasPrimaryKey: _primaryKeys.isNotEmpty, - schemaLoaded: _schemaLoaded, - schemaError: _schemaError, + @override + String browseDataSql({required int offset, required int limit}) { + final schemaQ = quotePostgresIdentifier(schema); + final tableQ = quotePostgresIdentifier(tableName); + return postgresBrowseDataSql( + qualifiedFrom: '$schemaQ.$tableQ', + primaryKeys: _primaryKeys, + limit: limit, + offset: offset, ); - - void _enterEditMode() { - if (!_canEdit || _editMode) return; - setState(() { - _editMode = true; - _stagingBuffer = replaceTableViewStagingBuffer( - previous: _stagingBuffer, - columns: _columnNames, - rows: _rows, - enabled: _editingEnabled, - primaryKeys: _primaryKeys, - ); - }); } - Future _exitEditMode() async { - if (!_editMode) return; - if (!await _confirmDiscardIfNeeded()) return; - if (!mounted) return; - setState(() { - _editMode = false; - _stagingBuffer?.dispose(); - _stagingBuffer = null; - }); - } - - void _toggleEditMode() { - if (_editMode) { - unawaited(_exitEditMode()); - } else { - _enterEditMode(); - } - } - - material.Widget _editModeButton() => TableEditModeButton( - editMode: _editMode, - canEdit: _canEdit, - busy: _loading || _isSaving, - disabledReason: _editDisabledReason(), - onEdit: _enterEditMode, - onDone: () => unawaited(_exitEditMode()), - ); - - Future _confirmDiscardIfNeeded() { - return confirmDiscardTableEditsIfDirty( - context: context, - buffer: _stagingBuffer, - tableTitle: _tableTitle, - ); - } + @override + bool isAllowedSelectQuery(String sql) => isAllowedPostgresSelectQuery(sql); - Future _ensureSchema(PostgresConnection conn) async { - if (_schemaLoaded) return; - if (widget.isView || widget.isMaterializedView) { - _schemaLoaded = true; - _schemaError = null; + @override + Future loadSchema() async { + if (isView || isMaterializedView) { _primaryKeys = []; _columnDataTypes = {}; - _columnMeta = {}; - return; + return const TableDataSchemaInfo(); + } + await _ensureReadConnection(); + final conn = _connection; + if (conn == null || !conn.isConnected) { + throw StateError('Not connected'); } + final loaded = await loadTableViewSchema( () => conn.getTableSchema( - schema: widget.schema, - table: widget.tableName, + schema: schema, + table: tableName, ), ); - final schema = loaded.schema; - if (schema != null) { - _primaryKeys = List.from(schema.primaryKeys); - _columnDataTypes = columnDataTypesFromSchema(schema); - _columnMeta = columnMetaFromSchema(schema); - _schemaError = null; - } else { - _primaryKeys = []; - _columnDataTypes = {}; - _columnMeta = {}; - _schemaError = loaded.error; + final s = loaded.schema; + if (s != null) { + _primaryKeys = List.from(s.primaryKeys); + _columnDataTypes = columnDataTypesFromSchema(s); + return TableDataSchemaInfo( + primaryKeys: _primaryKeys, + columnDataTypes: _columnDataTypes, + columnMeta: columnMetaFromSchema(s), + ); } - _schemaLoaded = true; - } - - void _installStagingBuffer(List columns, List> rows) { - _stagingBuffer = replaceTableViewStagingBuffer( - previous: _stagingBuffer, - columns: columns, - rows: rows, - enabled: _editingEnabled, - primaryKeys: _primaryKeys, + _primaryKeys = []; + _columnDataTypes = {}; + return TableDataSchemaInfo( + schemaError: loaded.error, ); } @@ -339,456 +137,275 @@ class _PostgresTableViewState extends material.State { ); } - /// [refreshCount] re-reads `reltuples` (e.g. first load or Refresh). Pagination only runs SELECT. - Future _fetch({bool refreshCount = false}) async { + @override + Future loadPage({ + required int offset, + required int limit, + bool refreshCount = false, + }) async { + await _ensureReadConnection(); final conn = _connection; if (conn == null || !conn.isConnected) { - if (mounted && _loading) { - setState(() { - _error = 'Not connected'; - _loading = false; - }); - } - return; - } - if (_customSqlActive) { - await _fetchCustom(); - return; + throw StateError('Not connected'); } - if (!mounted) return; - setState(() { - _loading = true; - _error = null; - }); - try { - await _ensureSchema(conn); - if (!mounted) return; - - int? totalRows = _totalRowCount; - if (refreshCount || totalRows == null) { - try { - totalRows = await conn.estimateTableRows( - schema: widget.schema, - table: widget.tableName, - ); - } catch (_) { - totalRows = null; - } + + int? totalRows; + if (refreshCount) { + try { + totalRows = await conn.estimateTableRows( + schema: schema, + table: tableName, + ); + } catch (_) { + totalRows = null; } + } - final dataSql = _browseDataSql(); - final result = await conn.execute(dataSql); - if (!mounted) return; + final dataSql = browseDataSql(offset: offset, limit: limit); + final result = await conn.execute(dataSql); - final colNames = List.generate( - result.schema.columns.length, - (i) => result.schema.columns[i].columnName ?? 'col_$i', - ); + final colNames = List.generate( + result.schema.columns.length, + (i) => result.schema.columns[i].columnName ?? 'col_$i', + ); - final stringRows = await _postgresRowsToDisplayStrings(result, colNames); + final stringRows = await _postgresRowsToDisplayStrings(result, colNames); - if (!mounted) return; - final shown = stringRows.length; - if (totalRows != null && shown > 0 && totalRows < _offset + shown) { - totalRows = null; - } - setState(() { - _columnNames = colNames; - _rows = stringRows; - _rowsOnPage = shown; - if (refreshCount || _totalRowCount == null) { - _totalRowCount = totalRows; - } - _loading = false; - _installStagingBuffer(colNames, stringRows); - }); - } catch (e) { - if (mounted) { - setState(() { - _error = e.toString(); - _loading = false; - }); - } - } + return TableDataPage( + columns: colNames, + rows: stringRows, + totalRowCount: totalRows, + ); } - Future _fetchCustom() async { + @override + Future loadCustomSql(String sql) async { + await _ensureReadConnection(); final conn = _connection; if (conn == null || !conn.isConnected) { - if (mounted && _loading) { - setState(() { - _error = 'Not connected'; - _loading = false; - }); - } - return; + throw StateError('Not connected'); } - final sql = _customSql; - if (sql == null || sql.isEmpty) return; - if (!mounted) return; - setState(() { - _loading = true; - _error = null; - }); - try { - final result = await conn.execute(sql); - if (!mounted) return; - final colNames = List.generate( - result.schema.columns.length, - (i) => result.schema.columns[i].columnName ?? 'col_$i', - ); + final result = await conn.execute(sql); + final colNames = List.generate( + result.schema.columns.length, + (i) => result.schema.columns[i].columnName ?? 'col_$i', + ); - final stringRows = await _postgresRowsToDisplayStrings(result, colNames); - - if (!mounted) return; - setState(() { - _columnNames = colNames; - _rows = stringRows; - _rowsOnPage = stringRows.length; - _totalRowCount = null; - _loading = false; - _installStagingBuffer(colNames, stringRows); - }); - } catch (e) { - if (mounted) { - setState(() { - _error = e.toString(); - _loading = false; - }); - } - } - } + final stringRows = await _postgresRowsToDisplayStrings(result, colNames); - Future _onSqlRun(String sql) async { - final trimmed = sql.trim(); - if (!isAllowedPostgresSelectQuery(trimmed)) return; - if (!await _confirmDiscardIfNeeded()) return; - if (!mounted) return; - final browse = _browseDataSql().trim(); - if (_browseSqlCompareKey(trimmed) == _browseSqlCompareKey(browse)) { - setState(() { - _customSqlActive = false; - _customSql = null; - }); - await _fetch(refreshCount: true); - } else { - setState(() { - _customSqlActive = true; - _customSql = trimmed; - }); - await _fetchCustom(); - } + return TableDataPage( + columns: colNames, + rows: stringRows, + totalRowCount: null, + ); } - Future _refreshMaterializedView() async { - if (_loading) return; - if (!await _confirmDiscardIfNeeded()) return; - if (!mounted) return; - try { - await _withTableWrite((conn) { - return conn.refreshMaterializedView(widget.schema, widget.tableName); - }); - if (!mounted) return; - await _fetch(refreshCount: true); - } catch (e) { - if (mounted) { - setState(() { - _error = e.toString(); - _loading = false; - }); + @override + Future applyStagedChanges({ + required TableMutationPlan plan, + required DataGridStagingBuffer buffer, + Duration? timeout, + }) async { + await withTableWrite((conn) async { + if (!conn.isConnected) { + throw StateError('Could not connect to PostgreSQL.'); } - } + await runPostgresStatementsInTransaction( + (sql) async { + final result = await conn.execute(sql, timeout: timeout); + if (sql != 'BEGIN' && sql != 'COMMIT' && sql != 'ROLLBACK') { + expectDmlMatchedRows(result.affectedRows); + } + }, + plan.statements.map((s) => s.sql), + ); + }); } - void _openPrivileges() { - final conn = _connection; - if (conn == null || !conn.isConnected) return; - showPostgresTablePrivilegesDialog( - context: context, - connection: conn, - schema: widget.schema, - tableName: widget.tableName, - ); + Future refreshMaterializedView() async { + await withTableWrite((conn) { + return conn.refreshMaterializedView(schema, tableName); + }); } - void _openSqlEditor() { - showPostgresSqlEditorDialog( - context: context, - initialSql: (_customSqlActive && _customSql != null) - ? _customSql! - : _browseDataSql(), - browseSql: _browseDataSql(), - onRun: (sql) => unawaited(_onSqlRun(sql)), - ); + @override + void cancel({bool interruptIfBusy = false}) { + if (interruptIfBusy) { + PostgresService.instance.interrupt( + connectionRow, + database: database, + mode: PgSessionMode.readOnly, + ); + PostgresService.instance.interrupt( + connectionRow, + database: database, + mode: PgSessionMode.tableWrite, + ); + } } - Future _exitCustomMode() async { - if (!await _confirmDiscardIfNeeded()) return; - if (!mounted) return; - setState(() { - _customSqlActive = false; - _customSql = null; - }); - await _fetch(refreshCount: true); + @override + void dispose() { + _lease?.release(); + _lease = null; } +} - void _goToPreviousPage() { - if (_customSqlActive) return; - if (_offset <= 0 || _loading || _isDirty) return; - setState(() { - final next = _offset - widget.limit; - _offset = next < 0 ? 0 : next; - }); - unawaited(_fetch()); - } +/// Paginated data browser for PostgreSQL tables, views, and materialized views. +class PostgresTableView extends material.StatefulWidget { + const PostgresTableView({ + super.key, + required this.connectionRow, + required this.database, + required this.schema, + required this.tableName, + this.isView = false, + this.isMaterializedView = false, + this.limit = kPostgresBrowseDefaultRowLimit, + this.onNavigateHome, + }); - void _goToNextPage() { - if (_customSqlActive) return; - if (_loading || _isDirty) return; - final total = _totalRowCount; - final limit = widget.limit; - if (total != null && _offset + _rowsOnPage >= total) return; - if (total == null && _rowsOnPage < limit) return; - setState(() { - _offset += limit; - }); - unawaited(_fetch()); - } + final ConnectionRow connectionRow; + final String database; + final String schema; + final String tableName; + final bool isView; + final bool isMaterializedView; + final int limit; + final material.VoidCallback? onNavigateHome; + + @override + material.State createState() => _PostgresTableViewState(); +} - bool get _canGoPrevious => - !_customSqlActive && _offset > 0 && !_loading && !_isDirty; +class _PostgresTableViewState extends material.State { + late PostgresTableDataDelegate _delegate; - bool get _canGoNext { - if (_customSqlActive || _loading || _isDirty) return false; - final total = _totalRowCount; - final limit = widget.limit; - if (total != null) { - return _offset + _rowsOnPage < total; - } - return _rowsOnPage >= limit; + @override + void initState() { + super.initState(); + _delegate = _createDelegate(); } - String _paginationLabel() { - if (_customSqlActive) { - if (_rowsOnPage == 0) return '0 rows (custom SQL)'; - return '$_rowsOnPage row${_rowsOnPage == 1 ? '' : 's'} (custom SQL)'; - } - if (_rowsOnPage == 0) { - final t = _totalRowCount; - if (t == null) return '0 rows'; - return '0 of $t'; - } - final start = _offset + 1; - final end = _offset + _rowsOnPage; - final total = _totalRowCount; - if (total != null) { - return '$start–$end of $total'; + @override + void didUpdateWidget(covariant PostgresTableView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.connectionRow.id != widget.connectionRow.id || + oldWidget.database != widget.database || + oldWidget.schema != widget.schema || + oldWidget.tableName != widget.tableName || + oldWidget.isMaterializedView != widget.isMaterializedView || + oldWidget.isView != widget.isView) { + _delegate.dispose(); + _delegate = _createDelegate(); } - return '$start–$end'; } - String? _statusLine() { - final reason = tableViewEditDisabledReason( + @override + void dispose() { + _delegate.dispose(); + super.dispose(); + } + + PostgresTableDataDelegate _createDelegate() { + return PostgresTableDataDelegate( + connectionRow: widget.connectionRow, + database: widget.database, + schema: widget.schema, + tableName: widget.tableName, isView: widget.isView, isMaterializedView: widget.isMaterializedView, - customSqlActive: _customSqlActive, - hasPrimaryKey: _primaryKeys.isNotEmpty, - schemaLoaded: _schemaLoaded, - schemaError: _schemaError, ); - final pag = _paginationLabel(); - if (reason != null) return '$pag · $reason'; - return pag; } - Future _onRefresh() async { - if (!await _confirmDiscardIfNeeded()) return; - if (!mounted) return; - _schemaLoaded = false; - _schemaError = null; - if (_customSqlActive) { - await _fetchCustom(); - } else { - await _fetch(refreshCount: true); - } - } - - Future _onNavigateHome() async { - final home = widget.onNavigateHome; - if (home == null) return; - if (!await _confirmDiscardIfNeeded()) return; - if (!mounted) return; - home(); + void _openPrivileges(material.BuildContext context) { + final conn = _delegate._connection; + if (conn == null || !conn.isConnected) return; + showPostgresTablePrivilegesDialog( + context: context, + connection: conn, + schema: widget.schema, + tableName: widget.tableName, + ); } - Future _applyStagedChanges() async { - final buffer = _stagingBuffer; - if (buffer == null || !buffer.isDirty || _isSaving) return; - setState(() => _isSaving = true); - final outcome = await applyTableViewStagedChanges( + void _openSqlEditor(material.BuildContext context, GenericTableViewState state) { + showPostgresSqlEditorDialog( context: context, - buffer: buffer, - dialect: SqlDialect.postgres, - tableName: widget.tableName, - schema: widget.schema, - primaryKeys: _primaryKeys, - columnDataTypes: _columnDataTypes.isEmpty ? null : _columnDataTypes, - columnMeta: _columnMeta.isEmpty ? null : _columnMeta, - execute: (plan) async { - await _withTableWrite((conn) async { - if (!conn.isConnected) { - throw StateError('Could not connect to PostgreSQL.'); - } - await runPostgresStatementsInTransaction( - (sql) async { - final result = await conn.execute(sql); - if (sql != 'BEGIN' && sql != 'COMMIT' && sql != 'ROLLBACK') { - expectDmlMatchedRows(result.affectedRows); - } - }, - plan.statements.map((s) => s.sql), - ); - }); - }, + initialSql: (state.customSqlActive && state.customSql != null) + ? state.customSql! + : state.browseDataSql(), + browseSql: state.browseDataSql(), + onRun: (sql) => unawaited(state.runCustomSql(sql)), ); - if (!mounted) return; - if (outcome.isApplied) { - if (buffer.insertedRowCount > 0) { - // Inserted rows have no database-generated keys / defaults yet, so a - // follow-up UPDATE / DELETE on them would match 0 rows. Reload the page. - buffer.dispose(); - setState(() { - _stagingBuffer = null; - _isSaving = false; - }); - showAppToast( - context: context, - message: tableViewSavedMessage(outcome.statementCount), - variant: AppToastVariant.success, - ); - await _fetch(refreshCount: true); - return; - } - final newRows = buffer.committedRows; - buffer.dispose(); - setState(() { - _rows = newRows; - _rowsOnPage = newRows.length; - _stagingBuffer = replaceTableViewStagingBuffer( - previous: null, - columns: _columnNames, - rows: newRows, - enabled: _editingEnabled, - primaryKeys: _primaryKeys, - ); - _isSaving = false; - }); + } + + Future _refreshMaterializedView( + material.BuildContext context, + GenericTableViewState state, + ) async { + if (state.isLoading) return; + if (!await state.confirmDiscardIfNeeded()) return; + try { + await _delegate.refreshMaterializedView(); + await state.refresh(); + } catch (e) { + if (!context.mounted) return; showAppToast( context: context, - message: tableViewSavedMessage(outcome.statementCount), - variant: AppToastVariant.success, - ); - return; - } - setState(() => _isSaving = false); - if (outcome.isFailed && outcome.error != null) { - await showTableViewSaveFailedDialog( - context: context, - error: outcome.error!, + message: 'Refresh materialized view failed: $e', + variant: AppToastVariant.error, ); } } - material.Widget _buildToolbar() { - PostgresTableToolbar toolbar() => PostgresTableToolbar( - title: - '$_tableTitle${widget.isMaterializedView ? ' (materialized view)' : widget.isView ? ' (view)' : ''}', - paginationLabel: _paginationLabel(), + @override + material.Widget build(material.BuildContext context) { + final tableTitle = '${widget.schema}.${widget.tableName}'; + + return GenericTableView( + delegate: _delegate, + title: tableTitle, + tableTitle: tableTitle, + dialect: SqlDialect.postgres, + tableName: widget.tableName, + schema: widget.schema, + isView: widget.isView, + isMaterializedView: widget.isMaterializedView, + limit: widget.limit, + onNavigateHome: widget.onNavigateHome, + showExportToolbar: true, + customToolbarBuilder: (ctx, state) { + return PostgresTableToolbar( + title: '$tableTitle${widget.isMaterializedView ? ' (materialized view)' : widget.isView ? ' (view)' : ''}', + paginationLabel: state.paginationLabel(), tableIcon: widget.isMaterializedView ? material.Icons.dynamic_feed_rounded : widget.isView ? material.Icons.view_agenda_rounded : material.Icons.table_chart_rounded, - customSqlActive: _customSqlActive, + customSqlActive: state.customSqlActive, isMaterializedView: widget.isMaterializedView, - loading: _loading, - canGoPrevious: _canGoPrevious, - canGoNext: _canGoNext, + loading: state.isLoading, + canGoPrevious: state.canGoPrevious, + canGoNext: state.canGoNext, onNavigateHome: widget.onNavigateHome == null ? null - : () => unawaited(_onNavigateHome()), - onOpenSql: _openSqlEditor, - onOpenPrivileges: _openPrivileges, + : () => unawaited(state.navigateHome()), + onOpenSql: () => _openSqlEditor(ctx, state), + onOpenPrivileges: () => _openPrivileges(ctx), onRefreshMaterializedView: () => - unawaited(_refreshMaterializedView()), - onExitCustomMode: () => unawaited(_exitCustomMode()), - onGoPrevious: _goToPreviousPage, - onGoNext: _goToNextPage, - onRefresh: () => unawaited(_onRefresh()), + unawaited(_refreshMaterializedView(ctx, state)), + onExitCustomMode: () => unawaited(state.exitCustomMode()), + onGoPrevious: state.goToPreviousPage, + onGoNext: state.goToNextPage, + onRefresh: () => unawaited(state.refresh()), editAction: widget.isView || widget.isMaterializedView ? null - : _editModeButton(), + : state.buildEditModeButton(), ); - final buffer = _stagingBuffer; - if (buffer == null) return toolbar(); - return ListenableBuilder( - listenable: buffer, - builder: (context, _) => toolbar(), - ); - } - - @override - material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; - - return material.CallbackShortcuts( - bindings: { - const material.SingleActivator(LogicalKeyboardKey.keyE, control: true): - _toggleEditMode, - const material.SingleActivator(LogicalKeyboardKey.keyE, meta: true): - _toggleEditMode, - const material.SingleActivator(LogicalKeyboardKey.f5): () { - if (!_loading) unawaited(_onRefresh()); - }, }, - child: material.Focus( - autofocus: true, - child: material.Container( - color: cs.background, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _buildToolbar(), - material.Expanded( - child: ResultsTab( - columns: _columnNames, - rows: _rows, - errorMessage: _error, - isLoading: _loading, - statusLine: _statusLine(), - showExportToolbar: true, - stagingBuffer: _stagingBuffer, - columnDataTypes: - _columnDataTypes.isEmpty ? null : _columnDataTypes, - onApplyChanges: - _stagingBuffer != null ? _applyStagedChanges : null, - isSaving: _isSaving, - ), - ), - ], - ), - ), - ), ); } - - /// Ignores trailing semicolons and whitespace so Run matches the browse query. - static String _browseSqlCompareKey(String sql) { - var s = sql.trim(); - while (s.endsWith(';')) { - s = s.substring(0, s.length - 1).trimRight(); - } - return s.replaceAll(RegExp(r'\s+'), ' '); - } } diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index 33a2e57c..81747b4c 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -1,7 +1,6 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:querya_desktop/core/database/sqlite_connection.dart'; import 'package:querya_desktop/core/database/sqlite_service.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; @@ -16,198 +15,52 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; const _defaultLimit = 200; -class SqliteTableView extends material.StatefulWidget { - const SqliteTableView({ - super.key, +/// SQLite delegate for [GenericTableView]. +class SqliteTableDataDelegate extends TableDataMutationDelegate { + SqliteTableDataDelegate({ required this.connectionRow, required this.tableName, this.isView = false, - this.limit = _defaultLimit, - this.onNavigateHome, this.isReadOnly = false, }); final ConnectionRow connectionRow; final String tableName; final bool isView; - final int limit; - final VoidCallback? onNavigateHome; - - /// Title-bar session lock. Combined with connection-form Read only (`useSSL`). final bool isReadOnly; - @override - material.State createState() => _SqliteTableViewState(); -} - -class _SqliteTableViewState extends material.State { SqliteLease? _lease; SqliteConnection? get _connection => _lease?.connection; - bool _loading = true; - String? _error; - - List _columnNames = []; - List> _rows = []; - int _rowsOnPage = 0; - int? _totalRowCount; - int _offset = 0; - - DataGridStagingBuffer? _stagingBuffer; - List _primaryKeys = []; Map _columnDataTypes = {}; - Map _columnMeta = {}; - bool _schemaLoaded = false; - Object? _schemaError; - bool _isSaving = false; - - bool get _isDirty => _stagingBuffer?.isDirty ?? false; - - bool get _readOnly => widget.isReadOnly || widget.connectionRow.useSSL; - - /// Tables open in view mode; editing is switched on explicitly. - bool _editMode = false; - - /// Whether this table could be edited (PK, not a view, writable). - bool get _canEdit => tableViewEditingEnabled( - isView: widget.isView, - customSqlActive: false, - hasPrimaryKey: _primaryKeys.isNotEmpty, - readOnly: _readOnly, - schemaError: _schemaError, - ); - - /// Edit mode is on and the table is editable. - bool get _editingEnabled => _canEdit && _editMode; - - String _qualifiedFrom() { - return SqliteConnection.quoteIdentifier(widget.tableName); - } - - String _browseDataSql() { - return sqliteBrowseDataSql( - qualifiedFrom: _qualifiedFrom(), - primaryKeys: _primaryKeys, - isView: widget.isView, - limit: widget.limit, - offset: _offset, - ); - } - - @override - void initState() { - super.initState(); - _connectAndLoad(); - } - - @override - void didUpdateWidget(covariant SqliteTableView oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.connectionRow.id != widget.connectionRow.id || - oldWidget.tableName != widget.tableName || - oldWidget.isView != widget.isView) { - _resetStaging(); - _disconnectCurrent(); - _connectAndLoad(); - } else if (oldWidget.isReadOnly != widget.isReadOnly || - oldWidget.connectionRow.useSSL != widget.connectionRow.useSSL) { - _syncStagingToReadOnly(); - } - } - - @override - void dispose() { - _resetStaging(); - _disconnectCurrent(interruptIfBusy: true); - super.dispose(); - } + List _primaryKeys = []; - void _resetStaging() { - _stagingBuffer?.dispose(); - _stagingBuffer = null; - _primaryKeys = []; - _columnDataTypes = {}; - _columnMeta = {}; - _schemaLoaded = false; - _schemaError = null; - _isSaving = false; - } + bool get effectiveReadOnly => isReadOnly || connectionRow.useSSL; - void _syncStagingToReadOnly() { - if (_readOnly) { - _stagingBuffer?.dispose(); - _stagingBuffer = null; - } else if (_columnNames.isNotEmpty) { - _stagingBuffer = replaceTableViewStagingBuffer( - previous: _stagingBuffer, - columns: _columnNames, - rows: _rows, - enabled: _editingEnabled, - primaryKeys: _primaryKeys, - ); - } - if (mounted) setState(() {}); - } + String _qualifiedFrom() => SqliteConnection.quoteIdentifier(tableName); - void _disconnectCurrent({bool interruptIfBusy = false}) { - if (interruptIfBusy && _loading) { - SqliteService.instance.interrupt( - widget.connectionRow, - mode: SqliteSessionMode.readOnly, - ); - } - if (interruptIfBusy && _isSaving) { - SqliteService.instance.interrupt( - widget.connectionRow, - mode: SqliteSessionMode.tableWrite, - ); - } + Future ensureBrowseConnection() async { + final current = _connection; + if (current != null && current.isConnected) return current; _lease?.release(); _lease = null; - } - - Future _connectAndLoad() async { - _editMode = false; - _disconnectCurrent(); - if (!mounted) return; - setState(() { - _loading = true; - _error = null; - _columnNames = []; - _rows = []; - _rowsOnPage = 0; - _totalRowCount = null; - _offset = 0; - _resetStaging(); - }); try { final lease = await SqliteService.instance.acquire( - widget.connectionRow, + connectionRow, mode: SqliteSessionMode.readOnly, ); - if (!mounted) { - lease.release(); - return; - } _lease = lease; - await _fetch(); - } catch (e) { - if (!mounted) return; - setState(() { - _error = e.toString(); - _loading = false; - }); + return lease.connection; + } catch (_) { + return null; } } - Future _withTableWrite( + Future withTableWrite( Future Function(SqliteConnection conn) fn, ) async { - if (_readOnly) { - throw StateError('SQLite connection is read-only'); - } final lease = await SqliteService.instance.acquire( - widget.connectionRow, + connectionRow, mode: SqliteSessionMode.tableWrite, ); try { @@ -217,320 +70,235 @@ class _SqliteTableViewState extends material.State { } } - String? _editDisabledReason() => tableViewEditDisabledReason( - isView: widget.isView, - customSqlActive: false, - hasPrimaryKey: _primaryKeys.isNotEmpty, - schemaLoaded: _schemaLoaded, - readOnly: _readOnly, - schemaError: _schemaError, + @override + String browseDataSql({required int offset, required int limit}) { + return sqliteBrowseDataSql( + qualifiedFrom: _qualifiedFrom(), + primaryKeys: _primaryKeys, + isView: isView, + limit: limit, + offset: offset, ); - - void _enterEditMode() { - if (!_canEdit || _editMode) return; - setState(() { - _editMode = true; - _stagingBuffer = replaceTableViewStagingBuffer( - previous: _stagingBuffer, - columns: _columnNames, - rows: _rows, - enabled: _editingEnabled, - primaryKeys: _primaryKeys, - ); - }); - } - - Future _exitEditMode() async { - if (!_editMode) return; - if (!await _confirmDiscardIfNeeded()) return; - if (!mounted) return; - setState(() { - _editMode = false; - _stagingBuffer?.dispose(); - _stagingBuffer = null; - }); } - void _toggleEditMode() { - if (_editMode) { - unawaited(_exitEditMode()); - } else { - _enterEditMode(); - } - } - - material.Widget _editModeButton() => TableEditModeButton( - editMode: _editMode, - canEdit: _canEdit, - busy: _loading || _isSaving, - disabledReason: _editDisabledReason(), - onEdit: _enterEditMode, - onDone: () => unawaited(_exitEditMode()), - ); - - Future _confirmDiscardIfNeeded() { - return confirmDiscardTableEditsIfDirty( - context: context, - buffer: _stagingBuffer, - tableTitle: widget.tableName, - ); - } + @override + bool isAllowedSelectQuery(String sql) => true; - Future _ensureSchema(SqliteConnection conn) async { - if (_schemaLoaded) return; - if (widget.isView) { - _schemaLoaded = true; - _schemaError = null; + @override + Future loadSchema() async { + if (isView || effectiveReadOnly) { _primaryKeys = []; _columnDataTypes = {}; - _columnMeta = {}; - return; + return const TableDataSchemaInfo(); } + final conn = await ensureBrowseConnection(); + if (conn == null || !conn.isConnected) { + throw StateError('Not connected'); + } + final loaded = await loadTableViewSchema( - () => conn.getTableSchema(table: widget.tableName), + () => conn.getTableSchema(tableName), ); - final schema = loaded.schema; - if (schema != null) { + final s = loaded.schema; + if (s != null) { _primaryKeys = sqliteTableBrowserPrimaryKeys( - declaredPrimaryKeys: schema.primaryKeys, - isView: widget.isView, + declaredPrimaryKeys: s.primaryKeys, + isView: isView, ); - _columnDataTypes = columnDataTypesFromSchema(schema); - _columnMeta = columnMetaFromSchema(schema); + _columnDataTypes = columnDataTypesFromSchema(s); + final colMeta = columnMetaFromSchema(s); if (sqliteBrowseNeedsRowidColumn( primaryKeys: _primaryKeys, - isView: widget.isView, + isView: isView, ) && - !_columnMeta.containsKey(kSqliteImplicitRowid)) { + !colMeta.containsKey(kSqliteImplicitRowid)) { _columnDataTypes[kSqliteImplicitRowid] = sqliteImplicitRowidColumn.dataType; - _columnMeta[kSqliteImplicitRowid] = sqliteImplicitRowidColumn; + colMeta[kSqliteImplicitRowid] = sqliteImplicitRowidColumn; } - _schemaError = null; - } else { - _primaryKeys = []; - _columnDataTypes = {}; - _columnMeta = {}; - _schemaError = loaded.error; + return TableDataSchemaInfo( + primaryKeys: _primaryKeys, + columnDataTypes: _columnDataTypes, + columnMeta: colMeta, + ); } - _schemaLoaded = true; - } - - void _installStagingBuffer(List columns, List> rows) { - _stagingBuffer = replaceTableViewStagingBuffer( - previous: _stagingBuffer, - columns: columns, - rows: rows, - enabled: _editingEnabled, - primaryKeys: _primaryKeys, + _primaryKeys = []; + _columnDataTypes = {}; + return TableDataSchemaInfo( + schemaError: loaded.error, ); } - /// Browse connection, re-acquired when the pooled session was closed under - /// us (tree Disconnect, another view interrupting the shared slot). - Future _ensureBrowseConnection() async { - final current = _connection; - if (current != null && current.isConnected) return current; - _lease?.release(); - _lease = null; - try { - final lease = await SqliteService.instance.acquire( - widget.connectionRow, - mode: SqliteSessionMode.readOnly, - ); - if (!mounted) { - lease.release(); - return null; - } - _lease = lease; - return lease.connection; - } catch (_) { - return null; + @override + Future loadPage({ + required int offset, + required int limit, + bool refreshCount = false, + }) async { + final conn = await ensureBrowseConnection(); + if (conn == null || !conn.isConnected) { + throw StateError('Not connected'); } - } - Future _fetch() async { - final conn = await _ensureBrowseConnection(); - if (!mounted) return; - if (conn == null || !conn.isConnected) { - if (mounted && _loading) { - setState(() { - _error = 'Not connected'; - _loading = false; - }); - } - return; + final dataSql = browseDataSql(offset: offset, limit: limit); + final rs = await conn.execute(dataSql); + + final cols = []; + if (rs.isNotEmpty) { + cols.addAll(rs.first.keys); + } else { + cols.addAll(await conn.listColumnNames(table: tableName)); } - setState(() { - _loading = true; - _error = null; - }); - try { - await _ensureSchema(conn); - if (!mounted) return; - // Skip COUNT(*) — it blocks the FFI isolate on large files. Next is - // enabled when the current page is full (_canGoNext). - _totalRowCount = null; + final outRows = rs.map((row) { + return cols.map((col) { + return sqliteResultCellToDisplayString( + row[col], + dataTypeName: _columnDataTypes[col], + ); + }).toList(); + }).toList(); - final browseSql = _browseDataSql(); - final rs = await conn.execute(browseSql); + return TableDataPage( + columns: cols, + rows: outRows, + totalRowCount: null, + ); + } - if (!mounted) return; + @override + Future loadCustomSql(String sql) async { + final conn = await ensureBrowseConnection(); + if (conn == null || !conn.isConnected) { + throw StateError('Not connected'); + } - final cols = []; - if (rs.isNotEmpty) { - cols.addAll(rs.first.keys); - } else { - cols.addAll(await conn.listColumnNames(table: widget.tableName)); - } + final rs = await conn.execute(sql); + final cols = []; + if (rs.isNotEmpty) { + cols.addAll(rs.first.keys); + } else { + cols.addAll(await conn.listColumnNames(table: tableName)); + } - final outRows = rs.map((row) { - return cols.map((col) { - return sqliteResultCellToDisplayString( - row[col], - dataTypeName: _columnDataTypes[col], - ); - }).toList(); + final outRows = rs.map((row) { + return cols.map((col) { + return sqliteResultCellToDisplayString( + row[col], + dataTypeName: _columnDataTypes[col], + ); }).toList(); + }).toList(); - if (!mounted) return; - setState(() { - _columnNames = cols; - _rows = outRows; - _rowsOnPage = outRows.length; - _loading = false; - _installStagingBuffer(cols, outRows); - }); - } catch (e) { - if (!mounted) return; - setState(() { - _error = e.toString(); - _loading = false; - }); - } + return TableDataPage( + columns: cols, + rows: outRows, + totalRowCount: null, + ); } - bool get _canGoPrevious => _offset > 0 && !_loading && !_isDirty; + @override + Future applyStagedChanges({ + required TableMutationPlan plan, + required DataGridStagingBuffer buffer, + Duration? timeout, + }) async { + if (effectiveReadOnly) return; + await withTableWrite((conn) async { + if (!conn.isConnected) { + throw StateError('Could not connect to SQLite.'); + } + await conn.runInTransaction(() async { + for (final stmt in plan.statements) { + expectDmlMatchedRows(await conn.executeAffected(stmt.sql)); + } + }); + }); + } - bool get _canGoNext { - if (_loading || _isDirty) return false; - final total = _totalRowCount; - if (total != null) { - return _offset + _rowsOnPage < total; + @override + void cancel({bool interruptIfBusy = false}) { + if (interruptIfBusy) { + SqliteService.instance.interrupt( + connectionRow, + mode: SqliteSessionMode.readOnly, + ); + SqliteService.instance.interrupt( + connectionRow, + mode: SqliteSessionMode.tableWrite, + ); } - return _rowsOnPage >= widget.limit; } - void _goToPreviousPage() { - if (!_canGoPrevious || _loading) return; - setState(() { - _offset -= widget.limit; - if (_offset < 0) _offset = 0; - }); - unawaited(_fetch()); + @override + void dispose() { + _lease?.release(); + _lease = null; } +} - void _goToNextPage() { - if (!_canGoNext || _loading) return; - setState(() { - _offset += widget.limit; - }); - unawaited(_fetch()); +/// Paginated data browser for SQLite tables and views. +class SqliteTableView extends material.StatefulWidget { + const SqliteTableView({ + super.key, + required this.connectionRow, + required this.tableName, + this.isView = false, + this.limit = _defaultLimit, + this.onNavigateHome, + this.isReadOnly = false, + }); + + final ConnectionRow connectionRow; + final String tableName; + final bool isView; + final int limit; + final VoidCallback? onNavigateHome; + final bool isReadOnly; + + @override + material.State createState() => _SqliteTableViewState(); +} + +class _SqliteTableViewState extends material.State { + late SqliteTableDataDelegate _delegate; + + @override + void initState() { + super.initState(); + _delegate = _createDelegate(); } - Future _onRefresh() async { - if (!await _confirmDiscardIfNeeded()) return; - if (!mounted) return; - _schemaLoaded = false; - _schemaError = null; - await _fetch(); + @override + void didUpdateWidget(covariant SqliteTableView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.connectionRow.id != widget.connectionRow.id || + oldWidget.tableName != widget.tableName || + oldWidget.isView != widget.isView || + oldWidget.isReadOnly != widget.isReadOnly || + oldWidget.connectionRow.useSSL != widget.connectionRow.useSSL) { + _delegate.dispose(); + _delegate = _createDelegate(); + } } - Future _onNavigateHome() async { - final home = widget.onNavigateHome; - if (home == null) return; - if (!await _confirmDiscardIfNeeded()) return; - if (!mounted) return; - home(); + @override + void dispose() { + _delegate.dispose(); + super.dispose(); } - Future _applyStagedChanges() async { - if (_readOnly) return; - final buffer = _stagingBuffer; - if (buffer == null || !buffer.isDirty || _isSaving) return; - setState(() => _isSaving = true); - final outcome = await applyTableViewStagedChanges( - context: context, - buffer: buffer, - dialect: SqlDialect.sqlite, + SqliteTableDataDelegate _createDelegate() { + return SqliteTableDataDelegate( + connectionRow: widget.connectionRow, tableName: widget.tableName, - primaryKeys: _primaryKeys, - columnDataTypes: _columnDataTypes.isEmpty ? null : _columnDataTypes, - columnMeta: _columnMeta.isEmpty ? null : _columnMeta, - execute: (plan) async { - await _withTableWrite((conn) async { - if (!conn.isConnected) { - throw StateError('Could not connect to SQLite.'); - } - await conn.runInTransaction(() async { - for (final stmt in plan.statements) { - expectDmlMatchedRows(await conn.executeAffected(stmt.sql)); - } - }); - }); - }, + isView: widget.isView, + isReadOnly: widget.isReadOnly, ); - if (!mounted) return; - if (outcome.isApplied) { - if (buffer.insertedRowCount > 0) { - // Inserted rows have no database-generated keys / defaults yet, so a - // follow-up UPDATE / DELETE on them would match 0 rows. Reload the page. - buffer.dispose(); - setState(() { - _stagingBuffer = null; - _isSaving = false; - }); - showAppToast( - context: context, - message: tableViewSavedMessage(outcome.statementCount), - variant: AppToastVariant.success, - ); - await _fetch(); - return; - } - final newRows = buffer.committedRows; - buffer.dispose(); - setState(() { - _rows = newRows; - _rowsOnPage = newRows.length; - _stagingBuffer = replaceTableViewStagingBuffer( - previous: null, - columns: _columnNames, - rows: newRows, - enabled: _editingEnabled, - primaryKeys: _primaryKeys, - ); - _isSaving = false; - }); - showAppToast( - context: context, - message: tableViewSavedMessage(outcome.statementCount), - variant: AppToastVariant.success, - ); - return; - } - setState(() => _isSaving = false); - if (outcome.isFailed && outcome.error != null) { - await showTableViewSaveFailedDialog( - context: context, - error: outcome.error!, - ); - } } - Future _showDdlDialog() async { - final conn = await _ensureBrowseConnection(); + Future _showDdlDialog(material.BuildContext context) async { + final conn = await _delegate.ensureBrowseConnection(); if (!mounted || conn == null || !conn.isConnected) return; final navigator = material.Navigator.of(context, rootNavigator: true); unawaited(showAppDialog( @@ -597,220 +365,161 @@ class _SqliteTableViewState extends material.State { } } - String _paginationLabel() { - if (_columnNames.isEmpty && _rows.isEmpty) return ''; - final start = _offset + 1; - final end = _offset + _rowsOnPage; - final total = _totalRowCount; - if (total != null) { - return 'Showing $start-$end of $total row(s)'; - } - return 'Showing $start-$end row(s)'; - } - - String? _statusLine() { - final reason = tableViewEditDisabledReason( + @override + material.Widget build(material.BuildContext context) { + return GenericTableView( + delegate: _delegate, + title: widget.tableName, + tableTitle: widget.tableName, + dialect: SqlDialect.sqlite, + tableName: widget.tableName, isView: widget.isView, - customSqlActive: false, - hasPrimaryKey: _primaryKeys.isNotEmpty, - schemaLoaded: _schemaLoaded, - readOnly: _readOnly, - schemaError: _schemaError, - ); - final pag = _paginationLabel(); - if (pag.isEmpty) return reason; - if (reason != null) return '$pag · $reason'; - return pag; - } - - material.Widget _buildChromeRow(ColorScheme cs) { - final title = '${widget.tableName}${widget.isView ? ' (view)' : ''}'; - return material.Container( - height: 48, - padding: const material.EdgeInsets.symmetric(horizontal: 12), - decoration: material.BoxDecoration( - color: cs.muted.withValues(alpha: 0.35), - border: material.Border( - bottom: material.BorderSide( - color: cs.border.withValues(alpha: 0.4), - ), - ), - ), - child: material.Row( - children: [ - if (widget.onNavigateHome != null) ...[ - material.Tooltip( - message: 'Return to overview', - child: OutlineButton( - size: ButtonSize.small, - onPressed: () => unawaited(_onNavigateHome()), - leading: const material.Icon( - material.Icons.dns_outlined, - size: 14, - ), - child: const Text('Overview'), - ), - ), - const Gap(10), - ], - material.Icon( - widget.isView - ? material.Icons.view_agenda_rounded - : material.Icons.table_chart_rounded, - size: 20, - color: cs.primary, - ), - const Gap(8), - material.Expanded( - child: material.Text( - title, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 13, - fontWeight: material.FontWeight.w600, - color: cs.foreground, + isReadOnly: _delegate.effectiveReadOnly, + limit: widget.limit, + onNavigateHome: widget.onNavigateHome, + showExportToolbar: true, + customToolbarBuilder: (ctx, state) { + final cs = Theme.of(ctx).colorScheme; + final title = '${widget.tableName}${widget.isView ? ' (view)' : ''}'; + + return material.Container( + height: 48, + padding: const material.EdgeInsets.symmetric(horizontal: 12), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.35), + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.4), ), ), ), - material.Expanded( - flex: 2, - child: material.LayoutBuilder( - builder: (context, constraints) { - return material.SingleChildScrollView( - scrollDirection: material.Axis.horizontal, - child: material.ConstrainedBox( - constraints: material.BoxConstraints( - minWidth: constraints.maxWidth, - ), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: material.BoxDecoration( - color: cs.muted.withValues(alpha: 0.4), - borderRadius: material.BorderRadius.circular(4), - ), - child: material.Text( - _paginationLabel(), - style: material.TextStyle( - fontSize: 11, - color: cs.mutedForeground, - ), - ), - ), - const Gap(6), - if (!widget.isView) ...[ - _editModeButton(), - const Gap(4), - ], - OutlineButton( - size: ButtonSize.small, - onPressed: _loading - ? null - : () => unawaited(_showDdlDialog()), - child: const Text('DDL'), - ), - const Gap(4), - OutlineButton( - size: ButtonSize.small, - onPressed: - _loading ? null : () => unawaited(_onRefresh()), - leading: const material.Icon( - material.Icons.refresh_rounded, - size: 14, - ), - child: const Text('Refresh'), - ), - const Gap(4), - OutlineButton( - size: ButtonSize.small, - onPressed: (!_canGoPrevious || _loading) - ? null - : _goToPreviousPage, - leading: const material.Icon( - material.Icons.chevron_left_rounded, - size: 16, - ), - child: const Text('Prev'), - ), - const Gap(4), - OutlineButton( - size: ButtonSize.small, - onPressed: - (!_canGoNext || _loading) ? null : _goToNextPage, - leading: const material.Icon( - material.Icons.chevron_right_rounded, - size: 16, - ), - child: const Text('Next'), - ), - ], + child: material.Row( + children: [ + if (widget.onNavigateHome != null) ...[ + material.Tooltip( + message: 'Return to overview', + child: OutlineButton( + size: ButtonSize.small, + onPressed: () => unawaited(state.navigateHome()), + leading: const material.Icon( + material.Icons.dns_outlined, + size: 14, ), + child: const Text('Overview'), ), - ); - }, - ), - ), - ], - ), - ); - } - - @override - material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; - final buffer = _stagingBuffer; - - return material.CallbackShortcuts( - bindings: { - const material.SingleActivator(LogicalKeyboardKey.keyE, control: true): - _toggleEditMode, - const material.SingleActivator(LogicalKeyboardKey.keyE, meta: true): - _toggleEditMode, - const material.SingleActivator(LogicalKeyboardKey.f5): () { - if (!_loading) unawaited(_onRefresh()); - }, - }, - child: material.Focus( - autofocus: true, - child: material.Container( - color: cs.background, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - if (buffer == null) - _buildChromeRow(cs) - else - ListenableBuilder( - listenable: buffer, - builder: (context, _) => _buildChromeRow(cs), ), + const Gap(10), + ], + material.Icon( + widget.isView + ? material.Icons.view_agenda_rounded + : material.Icons.table_chart_rounded, + size: 20, + color: cs.primary, + ), + const Gap(8), material.Expanded( - child: ResultsTab( - columns: _columnNames, - rows: _rows, - errorMessage: _error, - isLoading: _loading, - statusLine: _statusLine(), - showExportToolbar: true, - stagingBuffer: _stagingBuffer, - columnDataTypes: - _columnDataTypes.isEmpty ? null : _columnDataTypes, - onApplyChanges: - _stagingBuffer != null ? _applyStagedChanges : null, - isSaving: _isSaving, + child: material.Text( + title, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + ), + ), + ), + material.Expanded( + flex: 2, + child: material.LayoutBuilder( + builder: (context, constraints) { + return material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.ConstrainedBox( + constraints: material.BoxConstraints( + minWidth: constraints.maxWidth, + ), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.4), + borderRadius: material.BorderRadius.circular(4), + ), + child: material.Text( + state.paginationLabel(), + style: material.TextStyle( + fontSize: 11, + color: cs.mutedForeground, + ), + ), + ), + const Gap(6), + if (!widget.isView) ...[ + state.buildEditModeButton(), + const Gap(4), + ], + OutlineButton( + size: ButtonSize.small, + onPressed: state.isLoading + ? null + : () => unawaited(_showDdlDialog(ctx)), + child: const Text('DDL'), + ), + const Gap(4), + OutlineButton( + size: ButtonSize.small, + onPressed: state.isLoading + ? null + : () => unawaited(state.refresh()), + leading: const material.Icon( + material.Icons.refresh_rounded, + size: 14, + ), + child: const Text('Refresh'), + ), + const Gap(4), + OutlineButton( + size: ButtonSize.small, + onPressed: (!state.canGoPrevious || state.isLoading) + ? null + : state.goToPreviousPage, + leading: const material.Icon( + material.Icons.chevron_left_rounded, + size: 16, + ), + child: const Text('Prev'), + ), + const Gap(4), + OutlineButton( + size: ButtonSize.small, + onPressed: (!state.canGoNext || state.isLoading) + ? null + : state.goToNextPage, + leading: const material.Icon( + material.Icons.chevron_right_rounded, + size: 16, + ), + child: const Text('Next'), + ), + ], + ), + ), + ); + }, ), ), ], ), - ), - ), + ); + }, ); } } diff --git a/lib/features/workspace/generic_table_view.dart b/lib/features/workspace/generic_table_view.dart new file mode 100644 index 00000000..4f82a213 --- /dev/null +++ b/lib/features/workspace/generic_table_view.dart @@ -0,0 +1,736 @@ +import 'dart:async' show unawaited; + +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart' show LogicalKeyboardKey; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/core/database/table_schema_meta.dart'; +import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/workspace/results_tab.dart'; +import 'package:querya_desktop/features/workspace/table_data_delegate.dart'; +import 'package:querya_desktop/features/workspace/table_view_staging.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Reusable database table browser supporting: +/// - Pagination (LIMIT/OFFSET) and row counts +/// - In-place editing via [DataGridStagingBuffer] and transactional saving +/// - Custom SQL query execution / dialog +/// - Keyboard shortcuts (Ctrl/Cmd+E for edit mode, F5 for refresh) +/// - Flexible toolbar and custom sub-toolbars (e.g. filter bar) +class GenericTableView extends material.StatefulWidget { + const GenericTableView({ + super.key, + required this.delegate, + required this.title, + this.tableTitle, + required this.dialect, + this.tableName = '', + this.schema, + this.isView = false, + this.isMaterializedView = false, + this.isReadOnly = false, + this.limit = 200, + this.onNavigateHome, + this.customToolbarBuilder, + this.subToolbar, + this.errorAction, + this.showExportToolbar = true, + }); + + final TableDataMutationDelegate delegate; + final String title; + final String? tableTitle; + final SqlDialect dialect; + final String tableName; + final String? schema; + final bool isView; + final bool isMaterializedView; + final bool isReadOnly; + final int limit; + final material.VoidCallback? onNavigateHome; + final material.Widget Function(material.BuildContext context, GenericTableViewState state)? + customToolbarBuilder; + final material.Widget? subToolbar; + final material.Widget? errorAction; + final bool showExportToolbar; + + @override + material.State createState() => GenericTableViewState(); +} + +class GenericTableViewState extends material.State { + bool _loading = true; + String? _error; + + List _columnNames = []; + List> _rows = []; + int _rowsOnPage = 0; + int? _totalRowCount; + int _offset = 0; + + bool _customSqlActive = false; + String? _customSql; + + DataGridStagingBuffer? _stagingBuffer; + List _primaryKeys = []; + Map _columnDataTypes = {}; + Map _columnMeta = {}; + bool _schemaLoaded = false; + Object? _schemaError; + bool _isSaving = false; + bool _editMode = false; + + bool get isLoading => _loading; + String? get error => _error; + List get columnNames => _columnNames; + List> get rows => _rows; + int get rowsOnPage => _rowsOnPage; + int? get totalRowCount => _totalRowCount; + int get offset => _offset; + bool get customSqlActive => _customSqlActive; + String? get customSql => _customSql; + DataGridStagingBuffer? get stagingBuffer => _stagingBuffer; + bool get isSaving => _isSaving; + bool get editMode => _editMode; + bool get isDirty => _stagingBuffer?.isDirty ?? false; + + String get effectiveTableTitle => + widget.tableTitle ?? widget.title; + + bool get canEdit => tableViewEditingEnabled( + isView: widget.isView, + isMaterializedView: widget.isMaterializedView, + customSqlActive: _customSqlActive, + hasPrimaryKey: _primaryKeys.isNotEmpty, + readOnly: widget.isReadOnly, + schemaError: _schemaError, + ); + + bool get editingEnabled => canEdit && _editMode; + + bool get canGoPrevious => + !_customSqlActive && _offset > 0 && !_loading && !isDirty; + + bool get canGoNext { + if (_customSqlActive || _loading || isDirty) return false; + final total = _totalRowCount; + final limit = widget.limit; + if (total != null) { + return _offset + _rowsOnPage < total; + } + return _rowsOnPage >= limit; + } + + @override + void initState() { + super.initState(); + _loadPage(refreshCount: true); + } + + @override + void didUpdateWidget(covariant GenericTableView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.delegate != widget.delegate || + oldWidget.tableName != widget.tableName || + oldWidget.schema != widget.schema || + oldWidget.isView != widget.isView || + oldWidget.isMaterializedView != widget.isMaterializedView) { + _customSqlActive = false; + _customSql = null; + _resetStaging(); + _offset = 0; + _loadPage(refreshCount: true); + } else if (oldWidget.isReadOnly != widget.isReadOnly) { + _syncStagingToReadOnly(); + } + } + + @override + void dispose() { + _resetStaging(); + widget.delegate.cancel(interruptIfBusy: true); + widget.delegate.dispose(); + super.dispose(); + } + + void _resetStaging() { + _stagingBuffer?.dispose(); + _stagingBuffer = null; + _primaryKeys = []; + _columnDataTypes = {}; + _columnMeta = {}; + _schemaLoaded = false; + _schemaError = null; + _isSaving = false; + } + + void _syncStagingToReadOnly() { + if (widget.isReadOnly) { + _stagingBuffer?.dispose(); + _stagingBuffer = null; + } else if (_columnNames.isNotEmpty) { + _stagingBuffer = replaceTableViewStagingBuffer( + previous: _stagingBuffer, + columns: _columnNames, + rows: _rows, + enabled: editingEnabled, + primaryKeys: _primaryKeys, + ); + } + if (mounted) setState(() {}); + } + + Future _ensureSchema() async { + if (_schemaLoaded) return; + if (widget.isView || widget.isMaterializedView || widget.isReadOnly) { + _schemaLoaded = true; + _schemaError = null; + _primaryKeys = []; + _columnDataTypes = {}; + _columnMeta = {}; + return; + } + + try { + final info = await widget.delegate.loadSchema(); + _primaryKeys = List.from(info.primaryKeys); + _columnDataTypes = Map.from(info.columnDataTypes); + _columnMeta = Map.from(info.columnMeta); + _schemaError = info.schemaError; + } catch (e) { + _primaryKeys = []; + _columnDataTypes = {}; + _columnMeta = {}; + _schemaError = e; + } + _schemaLoaded = true; + } + + void _installStagingBuffer(List columns, List> rows) { + _stagingBuffer = replaceTableViewStagingBuffer( + previous: _stagingBuffer, + columns: columns, + rows: rows, + enabled: editingEnabled, + primaryKeys: _primaryKeys, + ); + } + + Future _loadPage({bool refreshCount = false}) async { + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + if (refreshCount) _totalRowCount = null; + }); + + try { + await _ensureSchema(); + if (!mounted) return; + + final page = await widget.delegate.loadPage( + offset: _offset, + limit: widget.limit, + refreshCount: refreshCount, + ); + + if (!mounted) return; + final shown = page.rows.length; + var totalRows = page.totalRowCount ?? _totalRowCount; + if (totalRows != null && shown > 0 && totalRows < _offset + shown) { + totalRows = null; + } + + setState(() { + _columnNames = page.columns; + _rows = page.rows; + _rowsOnPage = shown; + _totalRowCount = totalRows; + _loading = false; + _installStagingBuffer(page.columns, page.rows); + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + Future _fetchCustom() async { + final sql = _customSql; + if (sql == null || sql.isEmpty) return; + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + + try { + final page = await widget.delegate.loadCustomSql(sql); + if (!mounted) return; + setState(() { + _columnNames = page.columns; + _rows = page.rows; + _rowsOnPage = page.rows.length; + _totalRowCount = null; + _loading = false; + _installStagingBuffer(page.columns, page.rows); + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + Future confirmDiscardIfNeeded() { + return confirmDiscardTableEditsIfDirty( + context: context, + buffer: _stagingBuffer, + tableTitle: effectiveTableTitle, + ); + } + + String? editDisabledReason() => tableViewEditDisabledReason( + isView: widget.isView, + isMaterializedView: widget.isMaterializedView, + customSqlActive: _customSqlActive, + hasPrimaryKey: _primaryKeys.isNotEmpty, + schemaLoaded: _schemaLoaded, + readOnly: widget.isReadOnly, + schemaError: _schemaError, + ); + + void enterEditMode() { + if (!canEdit || _editMode) return; + setState(() { + _editMode = true; + _stagingBuffer = replaceTableViewStagingBuffer( + previous: _stagingBuffer, + columns: _columnNames, + rows: _rows, + enabled: editingEnabled, + primaryKeys: _primaryKeys, + ); + }); + } + + Future exitEditMode() async { + if (!_editMode) return; + if (!await confirmDiscardIfNeeded()) return; + if (!mounted) return; + setState(() { + _editMode = false; + _stagingBuffer?.dispose(); + _stagingBuffer = null; + }); + } + + void toggleEditMode() { + if (_editMode) { + unawaited(exitEditMode()); + } else { + enterEditMode(); + } + } + + material.Widget buildEditModeButton() => TableEditModeButton( + editMode: _editMode, + canEdit: canEdit, + busy: _loading || _isSaving, + disabledReason: editDisabledReason(), + onEdit: enterEditMode, + onDone: () => unawaited(exitEditMode()), + ); + + void goToPreviousPage() { + if (_customSqlActive) return; + if (_offset <= 0 || _loading || isDirty) return; + setState(() { + final next = _offset - widget.limit; + _offset = next < 0 ? 0 : next; + }); + unawaited(_loadPage()); + } + + void goToNextPage() { + if (_customSqlActive) return; + if (_loading || isDirty) return; + final total = _totalRowCount; + final limit = widget.limit; + if (total != null && _offset + _rowsOnPage >= total) return; + if (total == null && _rowsOnPage < limit) return; + setState(() { + _offset += limit; + }); + unawaited(_loadPage()); + } + + String paginationLabel() { + if (_customSqlActive) { + if (_rowsOnPage == 0) return '0 rows (custom SQL)'; + return '$_rowsOnPage row${_rowsOnPage == 1 ? '' : 's'} (custom SQL)'; + } + if (_rowsOnPage == 0) { + final t = _totalRowCount; + if (t == null) return '0 rows'; + return '0 of $t'; + } + final start = _offset + 1; + final end = _offset + _rowsOnPage; + final total = _totalRowCount; + if (total != null) { + return '$start–$end of $total'; + } + return '$start–$end'; + } + + String? statusLine() { + final reason = editDisabledReason(); + final pag = paginationLabel(); + if (reason != null) return '$pag · $reason'; + return pag; + } + + Future refresh() async { + if (!await confirmDiscardIfNeeded()) return; + if (!mounted) return; + _schemaLoaded = false; + _schemaError = null; + if (_customSqlActive) { + await _fetchCustom(); + } else { + await _loadPage(refreshCount: true); + } + } + + Future exitCustomMode() async { + if (!await confirmDiscardIfNeeded()) return; + if (!mounted) return; + setState(() { + _customSqlActive = false; + _customSql = null; + }); + await _loadPage(refreshCount: true); + } + + Future runCustomSql(String sql) async { + final trimmed = sql.trim(); + if (!widget.delegate.isAllowedSelectQuery(trimmed)) return; + if (!await confirmDiscardIfNeeded()) return; + if (!mounted) return; + + final browse = widget.delegate.browseDataSql( + offset: _offset, + limit: widget.limit, + ).trim(); + + if (_browseSqlCompareKey(trimmed) == _browseSqlCompareKey(browse)) { + setState(() { + _customSqlActive = false; + _customSql = null; + }); + await _loadPage(refreshCount: true); + } else { + setState(() { + _customSqlActive = true; + _customSql = trimmed; + }); + await _fetchCustom(); + } + } + + String browseDataSql() => widget.delegate.browseDataSql( + offset: _offset, + limit: widget.limit, + ); + + Future navigateHome() async { + final home = widget.onNavigateHome; + if (home == null) return; + if (!await confirmDiscardIfNeeded()) return; + if (!mounted) return; + home(); + } + + Future applyStagedChanges() async { + if (widget.isReadOnly) return; + final buffer = _stagingBuffer; + if (buffer == null || !buffer.isDirty || _isSaving) return; + setState(() => _isSaving = true); + + final outcome = await applyTableViewStagedChanges( + context: context, + buffer: buffer, + dialect: widget.dialect, + tableName: widget.tableName, + schema: widget.schema, + primaryKeys: _primaryKeys, + columnDataTypes: _columnDataTypes.isEmpty ? null : _columnDataTypes, + columnMeta: _columnMeta.isEmpty ? null : _columnMeta, + execute: (plan) async { + await widget.delegate.applyStagedChanges( + plan: plan, + buffer: buffer, + ); + }, + ); + + if (!mounted) return; + if (outcome.isApplied) { + if (buffer.insertedRowCount > 0) { + buffer.dispose(); + setState(() { + _stagingBuffer = null; + _isSaving = false; + }); + showAppToast( + context: context, + message: tableViewSavedMessage(outcome.statementCount), + variant: AppToastVariant.success, + ); + await _loadPage(refreshCount: true); + return; + } + final newRows = buffer.committedRows; + buffer.dispose(); + setState(() { + _rows = newRows; + _rowsOnPage = newRows.length; + _stagingBuffer = replaceTableViewStagingBuffer( + previous: null, + columns: _columnNames, + rows: newRows, + enabled: editingEnabled, + primaryKeys: _primaryKeys, + ); + _isSaving = false; + }); + showAppToast( + context: context, + message: tableViewSavedMessage(outcome.statementCount), + variant: AppToastVariant.success, + ); + return; + } + setState(() => _isSaving = false); + if (outcome.isFailed && outcome.error != null) { + await showTableViewSaveFailedDialog( + context: context, + error: outcome.error!, + ); + } + } + + material.Widget _buildDefaultToolbar() { + final cs = Theme.of(context).colorScheme; + final title = '${effectiveTableTitle}${widget.isMaterializedView ? ' (materialized view)' : widget.isView ? ' (view)' : ''}'; + + return material.Container( + height: 48, + padding: const material.EdgeInsets.symmetric(horizontal: 12), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.35), + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.4), + ), + ), + ), + child: material.Row( + children: [ + if (widget.onNavigateHome != null) ...[ + material.Tooltip( + message: 'Return to overview', + child: OutlineButton( + size: ButtonSize.small, + onPressed: () => unawaited(navigateHome()), + leading: const material.Icon( + material.Icons.dns_outlined, + size: 14, + ), + child: const Text('Overview'), + ), + ), + const Gap(10), + ], + material.Icon( + widget.isMaterializedView + ? material.Icons.dynamic_feed_rounded + : widget.isView + ? material.Icons.view_agenda_rounded + : material.Icons.table_chart_rounded, + size: 18, + color: cs.primary, + ), + const Gap(8), + material.Expanded( + child: material.Text( + title, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + ), + ), + ), + material.Expanded( + flex: 2, + child: material.LayoutBuilder( + builder: (context, constraints) { + return material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.ConstrainedBox( + constraints: material.BoxConstraints( + minWidth: constraints.maxWidth, + ), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.4), + borderRadius: material.BorderRadius.circular(4), + ), + child: material.Text( + paginationLabel(), + style: material.TextStyle( + fontSize: 11, + color: cs.mutedForeground, + ), + ), + ), + const Gap(6), + if (!widget.isView && !widget.isMaterializedView) ...[ + buildEditModeButton(), + const Gap(4), + ], + if (_customSqlActive) ...[ + OutlineButton( + size: ButtonSize.small, + onPressed: _loading ? null : () => unawaited(exitCustomMode()), + leading: const material.Icon( + material.Icons.table_chart_rounded, + size: 15, + ), + child: const Text('Browse'), + ), + const Gap(4), + ], + OutlineButton( + size: ButtonSize.small, + onPressed: (!canGoPrevious || _loading) ? null : goToPreviousPage, + leading: const material.Icon( + material.Icons.chevron_left_rounded, + size: 16, + ), + child: const Text('Prev'), + ), + const Gap(4), + OutlineButton( + size: ButtonSize.small, + onPressed: (!canGoNext || _loading) ? null : goToNextPage, + leading: const material.Icon( + material.Icons.chevron_right_rounded, + size: 16, + ), + child: const Text('Next'), + ), + const Gap(8), + OutlineButton( + size: ButtonSize.small, + onPressed: _loading ? null : () => unawaited(refresh()), + leading: const material.Icon( + material.Icons.refresh_rounded, + size: 14, + ), + child: const Text('Refresh'), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final buffer = _stagingBuffer; + + final toolbar = widget.customToolbarBuilder != null + ? widget.customToolbarBuilder!(context, this) + : _buildDefaultToolbar(); + + return material.CallbackShortcuts( + bindings: { + const material.SingleActivator(LogicalKeyboardKey.keyE, control: true): + toggleEditMode, + const material.SingleActivator(LogicalKeyboardKey.keyE, meta: true): + toggleEditMode, + const material.SingleActivator(LogicalKeyboardKey.f5): () { + if (!_loading) unawaited(refresh()); + }, + }, + child: material.Focus( + autofocus: true, + child: material.Container( + color: cs.background, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + if (buffer == null) + toolbar + else + ListenableBuilder( + listenable: buffer, + builder: (context, _) => toolbar, + ), + if (widget.subToolbar != null) widget.subToolbar!, + material.Expanded( + child: ResultsTab( + columns: _columnNames, + rows: _rows, + errorMessage: _error, + isLoading: _loading, + statusLine: statusLine(), + showExportToolbar: widget.showExportToolbar, + stagingBuffer: _stagingBuffer, + columnDataTypes: + _columnDataTypes.isEmpty ? null : _columnDataTypes, + onApplyChanges: + _stagingBuffer != null ? applyStagedChanges : null, + isSaving: _isSaving, + errorAction: widget.errorAction, + ), + ), + ], + ), + ), + ), + ); + } + + static String _browseSqlCompareKey(String sql) { + var s = sql.trim(); + while (s.endsWith(';')) { + s = s.substring(0, s.length - 1).trimRight(); + } + return s.replaceAll(RegExp(r'\s+'), ' '); + } +} diff --git a/lib/features/workspace/table_data_delegate.dart b/lib/features/workspace/table_data_delegate.dart new file mode 100644 index 00000000..5fd4f544 --- /dev/null +++ b/lib/features/workspace/table_data_delegate.dart @@ -0,0 +1,75 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/core/database/table_schema_meta.dart'; + +/// Represents a loaded data page with column names and string rows. +@immutable +class TableDataPage { + const TableDataPage({ + required this.columns, + required this.rows, + this.totalRowCount, + }); + + final List columns; + final List> rows; + final int? totalRowCount; +} + +/// Metadata about the table schema needed for in-place editing. +@immutable +class TableDataSchemaInfo { + const TableDataSchemaInfo({ + this.primaryKeys = const [], + this.columnDataTypes = const {}, + this.columnMeta = const {}, + this.schemaError, + }); + + final List primaryKeys; + final Map columnDataTypes; + final Map columnMeta; + final Object? schemaError; + + bool get hasPrimaryKey => primaryKeys.isNotEmpty; +} + +/// Delegate interface for database-specific table data browsing, custom SQL +/// loading, schema retrieval, and staging DML mutations in [GenericTableView]. +abstract class TableDataMutationDelegate { + /// Loads table data for the given pagination parameters. + /// When [refreshCount] is true, delegates that support table row estimates + /// should recalculate the total row count. + Future loadPage({ + required int offset, + required int limit, + bool refreshCount = false, + }); + + /// Loads custom SQL query data. + Future loadCustomSql(String sql); + + /// Resolves table schema (primary keys, column types, column metadata). + Future loadSchema(); + + /// Applies staged changes. Implementations can either execute the SQL plan + /// or perform driver-native batch mutations. + Future applyStagedChanges({ + required TableMutationPlan plan, + required DataGridStagingBuffer buffer, + Duration? timeout, + }); + + /// SQL query string used to browse table data at the given offset/limit. + String browseDataSql({required int offset, required int limit}); + + /// Checks if [sql] is an allowed select query for custom SQL execution. + bool isAllowedSelectQuery(String sql); + + /// Cancels an in-flight operation if supported. + void cancel({bool interruptIfBusy = false}) {} + + /// Releases delegate resources (leases, connections, sessions). + void dispose() {} +} diff --git a/lib/features/workspace/workspace.dart b/lib/features/workspace/workspace.dart index 56d0bc3f..137c80f3 100644 --- a/lib/features/workspace/workspace.dart +++ b/lib/features/workspace/workspace.dart @@ -7,6 +7,7 @@ export 'data_grid_value_panel.dart'; export 'destructive_query_dialog.dart'; export 'dml_preview_dialog.dart'; export 'generic_sql_workspace.dart'; +export 'generic_table_view.dart'; export 'grid_cell_editor.dart'; export 'grid_cell_popover_inspector.dart'; export 'grid_data_type_validator.dart'; @@ -23,5 +24,6 @@ export 'sql_query_history_dialog.dart'; export 'sql_query_tab_bar.dart'; export 'sql_query_tab_session.dart'; export 'sql_result_grid_schema.dart'; +export 'table_data_delegate.dart'; export 'table_view_staging.dart'; export 'xml_html_formatter.dart'; From 4829b8be1496ac1971b1012fc437d740ca3d9505 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 5 Oct 2026 23:04:03 +0300 Subject: [PATCH 2/2] fix(workspace): resolve analyzer warnings and compilation errors in unified table views --- lib/features/extensions/extension_table_view.dart | 4 ++-- lib/features/mysql/mysql_table_view.dart | 1 - lib/features/postgresql/postgres_table_view.dart | 1 - lib/features/sqlite/sqlite_table_view.dart | 7 +++---- lib/features/workspace/generic_table_view.dart | 2 +- lib/features/workspace/table_data_delegate.dart | 1 + 6 files changed, 7 insertions(+), 9 deletions(-) diff --git a/lib/features/extensions/extension_table_view.dart b/lib/features/extensions/extension_table_view.dart index e0baa628..06a33010 100644 --- a/lib/features/extensions/extension_table_view.dart +++ b/lib/features/extensions/extension_table_view.dart @@ -358,7 +358,7 @@ class _ExtensionTableViewState extends material.State { } } - Future _openDdlDialog(material.BuildContext context) async { + Future _openDdlDialog() async { final navigator = material.Navigator.of(context, rootNavigator: true); unawaited(showAppDialog( context: context, @@ -547,7 +547,7 @@ class _ExtensionTableViewState extends material.State { _filterActive = !_filterActive; }); }, - onOpenDdl: () => unawaited(_openDdlDialog(ctx)), + onOpenDdl: () => unawaited(_openDdlDialog()), onGoPrevious: state.goToPreviousPage, onGoNext: state.goToNextPage, onRefresh: () => unawaited(state.refresh()), diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index c4ca8508..1f5483d4 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -8,7 +8,6 @@ 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_limit.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/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/mysql/mysql_sql_editor_dialog.dart'; diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index 272ff3fa..1157a9d4 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -6,7 +6,6 @@ import 'package:querya_desktop/core/database/postgres_connection.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/table_mutation_engine.dart'; -import 'package:querya_desktop/core/database/table_schema_meta.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/postgresql/postgres_result_utils.dart'; import 'package:querya_desktop/features/postgresql/postgres_sql_editor_dialog.dart'; diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index 81747b4c..c17bec70 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/sqlite_connection.dart'; import 'package:querya_desktop/core/database/sqlite_service.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/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -97,7 +96,7 @@ class SqliteTableDataDelegate extends TableDataMutationDelegate { } final loaded = await loadTableViewSchema( - () => conn.getTableSchema(tableName), + () => conn.getTableSchema(table: tableName), ); final s = loaded.schema; if (s != null) { @@ -297,7 +296,7 @@ class _SqliteTableViewState extends material.State { ); } - Future _showDdlDialog(material.BuildContext context) async { + Future _showDdlDialog() async { final conn = await _delegate.ensureBrowseConnection(); if (!mounted || conn == null || !conn.isConnected) return; final navigator = material.Navigator.of(context, rootNavigator: true); @@ -470,7 +469,7 @@ class _SqliteTableViewState extends material.State { size: ButtonSize.small, onPressed: state.isLoading ? null - : () => unawaited(_showDdlDialog(ctx)), + : () => unawaited(_showDdlDialog()), child: const Text('DDL'), ), const Gap(4), diff --git a/lib/features/workspace/generic_table_view.dart b/lib/features/workspace/generic_table_view.dart index 4f82a213..6983a0af 100644 --- a/lib/features/workspace/generic_table_view.dart +++ b/lib/features/workspace/generic_table_view.dart @@ -527,7 +527,7 @@ class GenericTableViewState extends material.State { material.Widget _buildDefaultToolbar() { final cs = Theme.of(context).colorScheme; - final title = '${effectiveTableTitle}${widget.isMaterializedView ? ' (materialized view)' : widget.isView ? ' (view)' : ''}'; + final title = '$effectiveTableTitle${widget.isMaterializedView ? ' (materialized view)' : widget.isView ? ' (view)' : ''}'; return material.Container( height: 48, diff --git a/lib/features/workspace/table_data_delegate.dart b/lib/features/workspace/table_data_delegate.dart index 5fd4f544..7ea09330 100644 --- a/lib/features/workspace/table_data_delegate.dart +++ b/lib/features/workspace/table_data_delegate.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/foundation.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/features/workspace/data_grid_staging_buffer.dart'; /// Represents a loaded data page with column names and string rows. @immutable