From b41402f910280377ab1d080f68691821f3dc194d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 16:52:57 +0300 Subject: [PATCH] fix(grid): distinguish schema load failure from missing PK --- CHANGELOG.md | 1 + lib/features/mysql/mysql_table_view.dart | 20 ++++- .../postgresql/postgres_table_view.dart | 20 ++++- lib/features/sqlite/sqlite_table_view.dart | 21 +++-- .../workspace/table_view_staging.dart | 36 +++++++++ .../workspace/table_view_staging_test.dart | 79 +++++++++++++++++++ 6 files changed, 164 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbef70cf..d82d5ec7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Table Browser schema load vs missing PK (#772)** — A failed `getTableSchema` (permissions, disconnect) is no longer shown as “Cannot edit: no primary key detected”. Status is “schema unavailable” plus the real error and Refresh retries schema. Editing stays off until schema loads. Genuine missing PKs still use the old copy. SQLite implicit `rowid` is applied only after a successful schema load. - **SQLite implicit rowid Table Browser (#774)** — Tables with no declared PRIMARY KEY (`CREATE TABLE t (name TEXT)`) use implicit `rowid` as the DML key. Browse `SELECT` projects `"rowid", *` so Save can `UPDATE … WHERE rowid`. Status is no longer “no primary key”. `WITHOUT ROWID` tables keep their declared PK. - **Mongo 0-match write (#776)** — `updateOne` / `replaceOne` / `deleteOne` throw when `nMatched` / `nRemoved` is 0 (wrong `_id` type, deleted doc). Inspector and JSON editor surface Save Failed instead of a success toast. An identical `$set` (`nModified == 0`) still counts as a match. - **Mongo full-document Save (#778)** — JSON editor Save uses `replaceOne` (whole document, `_id` locked) instead of `$set`, so fields deleted in JSON — including nested keys — are removed on the server. diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index c9a14399..e25c14f3 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -65,6 +65,7 @@ class _MysqlTableViewState extends material.State { Map _columnDataTypes = {}; Map _columnMeta = {}; bool _schemaLoaded = false; + Object? _schemaError; bool _isSaving = false; String get _tableTitle => '${widget.database}.${widget.tableName}'; @@ -76,6 +77,7 @@ class _MysqlTableViewState extends material.State { customSqlActive: _customSqlActive, hasPrimaryKey: _primaryKeys.isNotEmpty, readOnly: widget.isReadOnly, + schemaError: _schemaError, ); String _qualifiedFrom() { @@ -130,6 +132,7 @@ class _MysqlTableViewState extends material.State { _columnDataTypes = {}; _columnMeta = {}; _schemaLoaded = false; + _schemaError = null; _isSaving = false; } @@ -264,23 +267,29 @@ class _MysqlTableViewState extends material.State { if (_schemaLoaded) return; if (widget.isView) { _schemaLoaded = true; + _schemaError = null; _primaryKeys = []; _columnDataTypes = {}; _columnMeta = {}; return; } - try { - final schema = await conn.getTableSchema( + 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); - } catch (_) { + _schemaError = null; + } else { _primaryKeys = []; _columnDataTypes = {}; _columnMeta = {}; + _schemaError = loaded.error; } _schemaLoaded = true; } @@ -509,6 +518,7 @@ class _MysqlTableViewState extends material.State { hasPrimaryKey: _primaryKeys.isNotEmpty, schemaLoaded: _schemaLoaded, readOnly: widget.isReadOnly, + schemaError: _schemaError, ); final pag = _paginationLabel(); if (reason != null) return '$pag · $reason'; @@ -518,6 +528,8 @@ class _MysqlTableViewState extends material.State { Future _onRefresh() async { if (!await _confirmDiscardIfNeeded()) return; if (!mounted) return; + _schemaLoaded = false; + _schemaError = null; if (_customSqlActive) { await _fetchCustom(); } else { diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index eed61a36..3d4c7c0a 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -74,6 +74,7 @@ class _PostgresTableViewState extends material.State { Map _columnDataTypes = {}; Map _columnMeta = {}; bool _schemaLoaded = false; + Object? _schemaError; bool _isSaving = false; String get _tableTitle => '${widget.schema}.${widget.tableName}'; @@ -85,6 +86,7 @@ class _PostgresTableViewState extends material.State { isMaterializedView: widget.isMaterializedView, customSqlActive: _customSqlActive, hasPrimaryKey: _primaryKeys.isNotEmpty, + schemaError: _schemaError, ); @override @@ -123,6 +125,7 @@ class _PostgresTableViewState extends material.State { _columnDataTypes = {}; _columnMeta = {}; _schemaLoaded = false; + _schemaError = null; _isSaving = false; } @@ -220,23 +223,29 @@ class _PostgresTableViewState extends material.State { if (_schemaLoaded) return; if (widget.isView || widget.isMaterializedView) { _schemaLoaded = true; + _schemaError = null; _primaryKeys = []; _columnDataTypes = {}; _columnMeta = {}; return; } - try { - final schema = await conn.getTableSchema( + final loaded = await loadTableViewSchema( + () => conn.getTableSchema( schema: widget.schema, table: widget.tableName, - ); + ), + ); + final schema = loaded.schema; + if (schema != null) { _primaryKeys = List.from(schema.primaryKeys); _columnDataTypes = columnDataTypesFromSchema(schema); _columnMeta = columnMetaFromSchema(schema); - } catch (_) { + _schemaError = null; + } else { _primaryKeys = []; _columnDataTypes = {}; _columnMeta = {}; + _schemaError = loaded.error; } _schemaLoaded = true; } @@ -528,6 +537,7 @@ class _PostgresTableViewState extends material.State { customSqlActive: _customSqlActive, hasPrimaryKey: _primaryKeys.isNotEmpty, schemaLoaded: _schemaLoaded, + schemaError: _schemaError, ); final pag = _paginationLabel(); if (reason != null) return '$pag · $reason'; @@ -537,6 +547,8 @@ class _PostgresTableViewState extends material.State { Future _onRefresh() async { if (!await _confirmDiscardIfNeeded()) return; if (!mounted) return; + _schemaLoaded = false; + _schemaError = null; if (_customSqlActive) { await _fetchCustom(); } else { diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index 8e855ec2..32c82b39 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -58,18 +58,19 @@ class _SqliteTableViewState extends material.State { 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; + bool get _readOnly => widget.isReadOnly || widget.connectionRow.useSSL; bool get _editingEnabled => tableViewEditingEnabled( isView: widget.isView, customSqlActive: false, hasPrimaryKey: _primaryKeys.isNotEmpty, readOnly: _readOnly, + schemaError: _schemaError, ); String _qualifiedFrom() { @@ -121,6 +122,7 @@ class _SqliteTableViewState extends material.State { _columnDataTypes = {}; _columnMeta = {}; _schemaLoaded = false; + _schemaError = null; _isSaving = false; } @@ -218,13 +220,17 @@ class _SqliteTableViewState extends material.State { if (_schemaLoaded) return; if (widget.isView) { _schemaLoaded = true; + _schemaError = null; _primaryKeys = []; _columnDataTypes = {}; _columnMeta = {}; return; } - try { - final schema = await conn.getTableSchema(table: widget.tableName); + final loaded = await loadTableViewSchema( + () => conn.getTableSchema(table: widget.tableName), + ); + final schema = loaded.schema; + if (schema != null) { _primaryKeys = sqliteTableBrowserPrimaryKeys( declaredPrimaryKeys: schema.primaryKeys, isView: widget.isView, @@ -240,10 +246,12 @@ class _SqliteTableViewState extends material.State { sqliteImplicitRowidColumn.dataType; _columnMeta[kSqliteImplicitRowid] = sqliteImplicitRowidColumn; } - } catch (_) { + _schemaError = null; + } else { _primaryKeys = []; _columnDataTypes = {}; _columnMeta = {}; + _schemaError = loaded.error; } _schemaLoaded = true; } @@ -349,6 +357,8 @@ class _SqliteTableViewState extends material.State { Future _onRefresh() async { if (!await _confirmDiscardIfNeeded()) return; if (!mounted) return; + _schemaLoaded = false; + _schemaError = null; await _fetch(); } @@ -503,6 +513,7 @@ class _SqliteTableViewState extends material.State { hasPrimaryKey: _primaryKeys.isNotEmpty, schemaLoaded: _schemaLoaded, readOnly: _readOnly, + schemaError: _schemaError, ); final pag = _paginationLabel(); if (pag.isEmpty) return reason; diff --git a/lib/features/workspace/table_view_staging.dart b/lib/features/workspace/table_view_staging.dart index f4441cb3..5fbee046 100644 --- a/lib/features/workspace/table_view_staging.dart +++ b/lib/features/workspace/table_view_staging.dart @@ -5,6 +5,36 @@ import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart' import 'package:querya_desktop/features/workspace/dml_preview_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; +/// Outcome of [loadTableViewSchema] (success vs swallowed getTableSchema error). +class TableViewSchemaLoad { + const TableViewSchemaLoad._({this.schema, this.error}); + + const TableViewSchemaLoad.ok(TableSchemaMeta schema) : this._(schema: schema); + + const TableViewSchemaLoad.failed(Object error) : this._(error: error); + + final TableSchemaMeta? schema; + final Object? error; + + bool get isOk => schema != null; +} + +/// Runs [fetch] and captures a failure instead of treating it as “no PK”. +Future loadTableViewSchema( + Future Function() fetch, +) async { + try { + return TableViewSchemaLoad.ok(await fetch()); + } catch (e) { + return TableViewSchemaLoad.failed(e); + } +} + +/// Status copy when [loadTableViewSchema] failed. Editing stays off. +String tableViewSchemaUnavailableReason(Object error) { + return 'Cannot edit: schema unavailable. $error. Refresh to retry.'; +} + /// Whether Table Browser should attach a [DataGridStagingBuffer] for this page. bool tableViewEditingEnabled({ required bool isView, @@ -12,8 +42,10 @@ bool tableViewEditingEnabled({ required bool customSqlActive, required bool hasPrimaryKey, bool readOnly = false, + Object? schemaError, }) { if (readOnly || isView || isMaterializedView || customSqlActive) return false; + if (schemaError != null) return false; return hasPrimaryKey; } @@ -25,10 +57,14 @@ String? tableViewEditDisabledReason({ required bool hasPrimaryKey, required bool schemaLoaded, bool readOnly = false, + Object? schemaError, }) { if (readOnly) return 'Read-only session'; if (isView || isMaterializedView) return 'Views are read-only'; if (customSqlActive) return 'Custom SQL results are read-only'; + if (schemaError != null) { + return tableViewSchemaUnavailableReason(schemaError); + } if (schemaLoaded && !hasPrimaryKey) { return 'Cannot edit: no primary key detected'; } diff --git a/test/features/workspace/table_view_staging_test.dart b/test/features/workspace/table_view_staging_test.dart index a17823ed..c39ece84 100644 --- a/test/features/workspace/table_view_staging_test.dart +++ b/test/features/workspace/table_view_staging_test.dart @@ -63,6 +63,15 @@ void main() { ), isFalse, ); + expect( + tableViewEditingEnabled( + isView: false, + customSqlActive: false, + hasPrimaryKey: true, + schemaError: StateError('permission denied'), + ), + isFalse, + ); }); }); @@ -133,6 +142,76 @@ void main() { ), isNull, ); + expect( + tableViewEditDisabledReason( + isView: false, + customSqlActive: false, + hasPrimaryKey: false, + schemaLoaded: true, + schemaError: StateError('permission denied'), + ), + 'Cannot edit: schema unavailable. ' + 'Bad state: permission denied. Refresh to retry.', + ); + }); + + test('schema load failure is not reported as a missing PK', () { + final reason = tableViewEditDisabledReason( + isView: false, + customSqlActive: false, + hasPrimaryKey: false, + schemaLoaded: true, + schemaError: Exception('information_schema denied'), + ); + expect(reason, contains('schema unavailable')); + expect(reason, contains('information_schema denied')); + expect(reason, contains('Refresh to retry')); + expect(reason, isNot(contains('no primary key'))); + }); + }); + + group('loadTableViewSchema', () { + test('returns schema on success', () async { + const meta = TableSchemaMeta( + tableName: 'users', + primaryKeys: ['id'], + ); + final loaded = await loadTableViewSchema(() async => meta); + expect(loaded.isOk, isTrue); + expect(loaded.schema, same(meta)); + expect(loaded.error, isNull); + }); + + test('captures a throwing getTableSchema stub instead of empty PKs', + () async { + Future throwingStub() async { + throw StateError('permission denied'); + } + + final loaded = await loadTableViewSchema(throwingStub); + expect(loaded.isOk, isFalse); + expect(loaded.schema, isNull); + expect(loaded.error, isA()); + expect( + tableViewEditDisabledReason( + isView: false, + customSqlActive: false, + hasPrimaryKey: loaded.schema?.hasPrimaryKey ?? false, + schemaLoaded: true, + schemaError: loaded.error, + ), + contains('schema unavailable'), + ); + expect( + tableViewEditDisabledReason( + isView: false, + customSqlActive: false, + hasPrimaryKey: false, + schemaLoaded: true, + schemaError: loaded.error, + ), + isNot(contains('no primary key')), + ); }); });