Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 16 additions & 4 deletions lib/features/mysql/mysql_table_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class _MysqlTableViewState extends material.State<MysqlTableView> {
Map<String, String> _columnDataTypes = {};
Map<String, TableColumnMeta> _columnMeta = {};
bool _schemaLoaded = false;
Object? _schemaError;
bool _isSaving = false;

String get _tableTitle => '${widget.database}.${widget.tableName}';
Expand All @@ -76,6 +77,7 @@ class _MysqlTableViewState extends material.State<MysqlTableView> {
customSqlActive: _customSqlActive,
hasPrimaryKey: _primaryKeys.isNotEmpty,
readOnly: widget.isReadOnly,
schemaError: _schemaError,
);

String _qualifiedFrom() {
Expand Down Expand Up @@ -130,6 +132,7 @@ class _MysqlTableViewState extends material.State<MysqlTableView> {
_columnDataTypes = {};
_columnMeta = {};
_schemaLoaded = false;
_schemaError = null;
_isSaving = false;
}

Expand Down Expand Up @@ -264,23 +267,29 @@ class _MysqlTableViewState extends material.State<MysqlTableView> {
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<String>.from(schema.primaryKeys);
_columnDataTypes = columnDataTypesFromSchema(schema);
_columnMeta = columnMetaFromSchema(schema);
} catch (_) {
_schemaError = null;
} else {
_primaryKeys = [];
_columnDataTypes = {};
_columnMeta = {};
_schemaError = loaded.error;
}
_schemaLoaded = true;
}
Expand Down Expand Up @@ -509,6 +518,7 @@ class _MysqlTableViewState extends material.State<MysqlTableView> {
hasPrimaryKey: _primaryKeys.isNotEmpty,
schemaLoaded: _schemaLoaded,
readOnly: widget.isReadOnly,
schemaError: _schemaError,
);
final pag = _paginationLabel();
if (reason != null) return '$pag · $reason';
Expand All @@ -518,6 +528,8 @@ class _MysqlTableViewState extends material.State<MysqlTableView> {
Future<void> _onRefresh() async {
if (!await _confirmDiscardIfNeeded()) return;
if (!mounted) return;
_schemaLoaded = false;
_schemaError = null;
if (_customSqlActive) {
await _fetchCustom();
} else {
Expand Down
20 changes: 16 additions & 4 deletions lib/features/postgresql/postgres_table_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ class _PostgresTableViewState extends material.State<PostgresTableView> {
Map<String, String> _columnDataTypes = {};
Map<String, TableColumnMeta> _columnMeta = {};
bool _schemaLoaded = false;
Object? _schemaError;
bool _isSaving = false;

String get _tableTitle => '${widget.schema}.${widget.tableName}';
Expand All @@ -85,6 +86,7 @@ class _PostgresTableViewState extends material.State<PostgresTableView> {
isMaterializedView: widget.isMaterializedView,
customSqlActive: _customSqlActive,
hasPrimaryKey: _primaryKeys.isNotEmpty,
schemaError: _schemaError,
);

@override
Expand Down Expand Up @@ -123,6 +125,7 @@ class _PostgresTableViewState extends material.State<PostgresTableView> {
_columnDataTypes = {};
_columnMeta = {};
_schemaLoaded = false;
_schemaError = null;
_isSaving = false;
}

Expand Down Expand Up @@ -220,23 +223,29 @@ class _PostgresTableViewState extends material.State<PostgresTableView> {
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<String>.from(schema.primaryKeys);
_columnDataTypes = columnDataTypesFromSchema(schema);
_columnMeta = columnMetaFromSchema(schema);
} catch (_) {
_schemaError = null;
} else {
_primaryKeys = [];
_columnDataTypes = {};
_columnMeta = {};
_schemaError = loaded.error;
}
_schemaLoaded = true;
}
Expand Down Expand Up @@ -528,6 +537,7 @@ class _PostgresTableViewState extends material.State<PostgresTableView> {
customSqlActive: _customSqlActive,
hasPrimaryKey: _primaryKeys.isNotEmpty,
schemaLoaded: _schemaLoaded,
schemaError: _schemaError,
);
final pag = _paginationLabel();
if (reason != null) return '$pag · $reason';
Expand All @@ -537,6 +547,8 @@ class _PostgresTableViewState extends material.State<PostgresTableView> {
Future<void> _onRefresh() async {
if (!await _confirmDiscardIfNeeded()) return;
if (!mounted) return;
_schemaLoaded = false;
_schemaError = null;
if (_customSqlActive) {
await _fetchCustom();
} else {
Expand Down
21 changes: 16 additions & 5 deletions lib/features/sqlite/sqlite_table_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -58,18 +58,19 @@ class _SqliteTableViewState extends material.State<SqliteTableView> {
Map<String, String> _columnDataTypes = {};
Map<String, TableColumnMeta> _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() {
Expand Down Expand Up @@ -121,6 +122,7 @@ class _SqliteTableViewState extends material.State<SqliteTableView> {
_columnDataTypes = {};
_columnMeta = {};
_schemaLoaded = false;
_schemaError = null;
_isSaving = false;
}

Expand Down Expand Up @@ -218,13 +220,17 @@ class _SqliteTableViewState extends material.State<SqliteTableView> {
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,
Expand All @@ -240,10 +246,12 @@ class _SqliteTableViewState extends material.State<SqliteTableView> {
sqliteImplicitRowidColumn.dataType;
_columnMeta[kSqliteImplicitRowid] = sqliteImplicitRowidColumn;
}
} catch (_) {
_schemaError = null;
} else {
_primaryKeys = [];
_columnDataTypes = {};
_columnMeta = {};
_schemaError = loaded.error;
}
_schemaLoaded = true;
}
Expand Down Expand Up @@ -349,6 +357,8 @@ class _SqliteTableViewState extends material.State<SqliteTableView> {
Future<void> _onRefresh() async {
if (!await _confirmDiscardIfNeeded()) return;
if (!mounted) return;
_schemaLoaded = false;
_schemaError = null;
await _fetch();
}

Expand Down Expand Up @@ -503,6 +513,7 @@ class _SqliteTableViewState extends material.State<SqliteTableView> {
hasPrimaryKey: _primaryKeys.isNotEmpty,
schemaLoaded: _schemaLoaded,
readOnly: _readOnly,
schemaError: _schemaError,
);
final pag = _paginationLabel();
if (pag.isEmpty) return reason;
Expand Down
36 changes: 36 additions & 0 deletions lib/features/workspace/table_view_staging.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,47 @@ 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<TableViewSchemaLoad> loadTableViewSchema(
Future<TableSchemaMeta> 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,
bool isMaterializedView = false,
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;
}

Expand All @@ -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';
}
Expand Down
79 changes: 79 additions & 0 deletions test/features/workspace/table_view_staging_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ void main() {
),
isFalse,
);
expect(
tableViewEditingEnabled(
isView: false,
customSqlActive: false,
hasPrimaryKey: true,
schemaError: StateError('permission denied'),
),
isFalse,
);
});
});

Expand Down Expand Up @@ -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<TableSchemaMeta> throwingStub() async {
throw StateError('permission denied');
}

final loaded = await loadTableViewSchema(throwingStub);
expect(loaded.isOk, isFalse);
expect(loaded.schema, isNull);
expect(loaded.error, isA<StateError>());
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')),
);
});
});

Expand Down
Loading