From 334ef367c51eaeeb94a545da0d0daf559022d6e4 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 16:46:17 +0300 Subject: [PATCH] fix(sqlite): edit Table Browser rows via implicit rowid --- CHANGELOG.md | 1 + lib/features/sqlite/sqlite_table_utils.dart | 53 +++++++++- lib/features/sqlite/sqlite_table_view.dart | 14 ++- .../core/database/sqlite_connection_test.dart | 100 ++++++++++++++++++ .../sqlite/sqlite_table_utils_test.dart | 49 ++++++++- 5 files changed, 212 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e44376..cbef70c 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 +- **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. - **Mongo document editor Back (#782)** — Dirty JSON in `MongoDocumentEditor` is registered with `UnsavedWorkRegistry`. Breadcrumb Back, Home, Close, and tree navigation confirm before discarding; Cancel keeps the editor. diff --git a/lib/features/sqlite/sqlite_table_utils.dart b/lib/features/sqlite/sqlite_table_utils.dart index 67ebcce..2cbb149 100644 --- a/lib/features/sqlite/sqlite_table_utils.dart +++ b/lib/features/sqlite/sqlite_table_utils.dart @@ -1,4 +1,44 @@ import 'package:querya_desktop/core/database/sqlite_connection.dart'; +import 'package:querya_desktop/core/database/table_schema_meta.dart'; + +/// Implicit SQLite `rowid` used as the Table Browser PK when none is declared. +const kSqliteImplicitRowid = 'rowid'; + +/// Synthetic column so INSERT omits `rowid` and DML types it as INTEGER. +const sqliteImplicitRowidColumn = TableColumnMeta( + name: kSqliteImplicitRowid, + dataType: 'INTEGER', + isNullable: false, + isPrimaryKey: true, + primaryKeyPosition: 1, + omitOnInsert: true, + hasServerDefault: true, +); + +/// PK columns for Table Browser DML. +/// +/// Declared PRIMARY KEY wins. Ordinary tables with no PK use implicit `rowid`. +/// `WITHOUT ROWID` tables always declare a PK, so they never hit this fallback. +/// Views have no `rowid` and stay read-only. +List sqliteTableBrowserPrimaryKeys({ + required List declaredPrimaryKeys, + required bool isView, +}) { + if (isView) return const []; + if (declaredPrimaryKeys.isNotEmpty) { + return List.from(declaredPrimaryKeys); + } + return const [kSqliteImplicitRowid]; +} + +/// True when browse SELECT must project `rowid` (it is not in `SELECT *`). +bool sqliteBrowseNeedsRowidColumn({ + required List primaryKeys, + required bool isView, +}) { + if (isView) return false; + return primaryKeys.length == 1 && primaryKeys.first == kSqliteImplicitRowid; +} /// Columns for Table Browser `ORDER BY`. /// @@ -9,11 +49,13 @@ List sqliteBrowseOrderColumns({ required bool isView, }) { if (primaryKeys.isNotEmpty) return List.from(primaryKeys); - if (!isView) return const ['rowid']; + if (!isView) return const [kSqliteImplicitRowid]; return const []; } /// Browse SELECT for Table Browser. PK / `rowid` keep LIMIT/OFFSET stable. +/// +/// Implicit-`rowid` tables project `"rowid", *` so DML WHERE can address the row. String sqliteBrowseDataSql({ required String qualifiedFrom, required List primaryKeys, @@ -25,8 +67,15 @@ String sqliteBrowseDataSql({ primaryKeys: primaryKeys, isView: isView, ); + final pks = sqliteTableBrowserPrimaryKeys( + declaredPrimaryKeys: primaryKeys, + isView: isView, + ); + final select = sqliteBrowseNeedsRowidColumn(primaryKeys: pks, isView: isView) + ? '${SqliteConnection.quoteIdentifier(kSqliteImplicitRowid)}, *' + : '*'; final order = orderCols.isEmpty ? '' : ' ORDER BY ${orderCols.map(SqliteConnection.quoteIdentifier).join(', ')}'; - return 'SELECT * FROM $qualifiedFrom$order LIMIT $limit OFFSET $offset'; + return 'SELECT $select FROM $qualifiedFrom$order LIMIT $limit OFFSET $offset'; } diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index d975671..8e855ec 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -225,9 +225,21 @@ class _SqliteTableViewState extends material.State { } try { final schema = await conn.getTableSchema(table: widget.tableName); - _primaryKeys = List.from(schema.primaryKeys); + _primaryKeys = sqliteTableBrowserPrimaryKeys( + declaredPrimaryKeys: schema.primaryKeys, + isView: widget.isView, + ); _columnDataTypes = columnDataTypesFromSchema(schema); _columnMeta = columnMetaFromSchema(schema); + if (sqliteBrowseNeedsRowidColumn( + primaryKeys: _primaryKeys, + isView: widget.isView, + ) && + !_columnMeta.containsKey(kSqliteImplicitRowid)) { + _columnDataTypes[kSqliteImplicitRowid] = + sqliteImplicitRowidColumn.dataType; + _columnMeta[kSqliteImplicitRowid] = sqliteImplicitRowidColumn; + } } catch (_) { _primaryKeys = []; _columnDataTypes = {}; diff --git a/test/core/database/sqlite_connection_test.dart b/test/core/database/sqlite_connection_test.dart index 557096b..be7e65e 100644 --- a/test/core/database/sqlite_connection_test.dart +++ b/test/core/database/sqlite_connection_test.dart @@ -5,6 +5,9 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; 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/features/sqlite/sqlite_table_utils.dart'; +import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; import 'package:querya_desktop/features/workspace/table_view_staging.dart'; void main() { @@ -168,6 +171,103 @@ void main() { expect(columns, containsAll(['id', 'name'])); }); + test('Table Browser UPDATE on implicit rowid table round-trips', () async { + await conn.connect(); + await conn.execute('CREATE TABLE t (name TEXT)'); + await conn.execute("INSERT INTO t (name) VALUES ('Ada')"); + + final schema = await conn.getTableSchema(table: 't'); + expect(schema.primaryKeys, isEmpty); + + final pks = sqliteTableBrowserPrimaryKeys( + declaredPrimaryKeys: schema.primaryKeys, + isView: false, + ); + expect(pks, ['rowid']); + expect( + tableViewEditingEnabled( + isView: false, + customSqlActive: false, + hasPrimaryKey: pks.isNotEmpty, + ), + isTrue, + ); + expect( + tableViewEditDisabledReason( + isView: false, + customSqlActive: false, + hasPrimaryKey: pks.isNotEmpty, + schemaLoaded: true, + ), + isNull, + ); + + final sql = sqliteBrowseDataSql( + qualifiedFrom: SqliteConnection.quoteIdentifier('t'), + primaryKeys: pks, + isView: false, + limit: 200, + offset: 0, + ); + final rs = await conn.execute(sql); + expect(rs, isNotEmpty); + final cols = rs.first.keys.toList(); + expect(cols, contains('rowid')); + expect(cols, contains('name')); + + final rows = [ + for (final row in rs) + [for (final c in cols) '${row[c]}'], + ]; + final buffer = DataGridStagingBuffer(columns: cols, rows: rows); + addTearDown(buffer.dispose); + buffer.setCell(0, cols.indexOf('name'), 'Grace'); + + final plan = buffer.generateMutationPlan( + dialect: SqlDialect.sqlite, + tableName: 't', + primaryKeys: pks, + columnDataTypes: { + kSqliteImplicitRowid: 'INTEGER', + 'name': 'TEXT', + }, + columnMeta: {kSqliteImplicitRowid: sqliteImplicitRowidColumn}, + ); + expect(plan.statements, hasLength(1)); + expect(plan.statements.first.sql, contains('WHERE "rowid" =')); + + expect(await conn.executeAffected(plan.statements.first.sql), 1); + final after = await conn.execute('SELECT name FROM t'); + expect(after.first['name'], 'Grace'); + }); + + test('WITHOUT ROWID tables keep the declared PK, not implicit rowid', + () async { + await conn.connect(); + await conn.execute( + 'CREATE TABLE wr (id INTEGER PRIMARY KEY, name TEXT) WITHOUT ROWID', + ); + final schema = await conn.getTableSchema(table: 'wr'); + expect(schema.primaryKeys, ['id']); + expect( + sqliteTableBrowserPrimaryKeys( + declaredPrimaryKeys: schema.primaryKeys, + isView: false, + ), + ['id'], + ); + expect( + sqliteBrowseDataSql( + qualifiedFrom: '"wr"', + primaryKeys: schema.primaryKeys, + isView: false, + limit: 200, + offset: 0, + ), + 'SELECT * FROM "wr" ORDER BY "id" LIMIT 200 OFFSET 0', + ); + }); + test('getObjectDdl returns CREATE SQL for a table that exists', () async { await conn.connect(); await conn.execute( diff --git a/test/features/sqlite/sqlite_table_utils_test.dart b/test/features/sqlite/sqlite_table_utils_test.dart index 3f6e25f..80750f1 100644 --- a/test/features/sqlite/sqlite_table_utils_test.dart +++ b/test/features/sqlite/sqlite_table_utils_test.dart @@ -2,6 +2,38 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/features/sqlite/sqlite_table_utils.dart'; void main() { + group('sqliteTableBrowserPrimaryKeys', () { + test('keeps a declared PRIMARY KEY', () { + expect( + sqliteTableBrowserPrimaryKeys( + declaredPrimaryKeys: const ['id'], + isView: false, + ), + ['id'], + ); + }); + + test('uses rowid when a table has no declared PK', () { + expect( + sqliteTableBrowserPrimaryKeys( + declaredPrimaryKeys: const [], + isView: false, + ), + ['rowid'], + ); + }); + + test('stays empty for views', () { + expect( + sqliteTableBrowserPrimaryKeys( + declaredPrimaryKeys: const [], + isView: true, + ), + isEmpty, + ); + }); + }); + group('sqliteBrowseOrderColumns', () { test('uses declared PK columns', () { expect( @@ -61,7 +93,7 @@ void main() { ); }); - test('orders by rowid when a table has no PK', () { + test('projects rowid and orders by it when a table has no PK', () { expect( sqliteBrowseDataSql( qualifiedFrom: '"t"', @@ -70,7 +102,20 @@ void main() { limit: 200, offset: 0, ), - 'SELECT * FROM "t" ORDER BY "rowid" LIMIT 200 OFFSET 0', + 'SELECT "rowid", * FROM "t" ORDER BY "rowid" LIMIT 200 OFFSET 0', + ); + }); + + test('projects rowid when PK was resolved to implicit rowid', () { + expect( + sqliteBrowseDataSql( + qualifiedFrom: '"t"', + primaryKeys: const ['rowid'], + isView: false, + limit: 200, + offset: 0, + ), + 'SELECT "rowid", * FROM "t" ORDER BY "rowid" LIMIT 200 OFFSET 0', ); });