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

- **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.
Expand Down
53 changes: 51 additions & 2 deletions lib/features/sqlite/sqlite_table_utils.dart
Original file line number Diff line number Diff line change
@@ -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<String> sqliteTableBrowserPrimaryKeys({
required List<String> declaredPrimaryKeys,
required bool isView,
}) {
if (isView) return const [];
if (declaredPrimaryKeys.isNotEmpty) {
return List<String>.from(declaredPrimaryKeys);
}
return const [kSqliteImplicitRowid];
}

/// True when browse SELECT must project `rowid` (it is not in `SELECT *`).
bool sqliteBrowseNeedsRowidColumn({
required List<String> primaryKeys,
required bool isView,
}) {
if (isView) return false;
return primaryKeys.length == 1 && primaryKeys.first == kSqliteImplicitRowid;
}

/// Columns for Table Browser `ORDER BY`.
///
Expand All @@ -9,11 +49,13 @@ List<String> sqliteBrowseOrderColumns({
required bool isView,
}) {
if (primaryKeys.isNotEmpty) return List<String>.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<String> primaryKeys,
Expand All @@ -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';
}
14 changes: 13 additions & 1 deletion lib/features/sqlite/sqlite_table_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -225,9 +225,21 @@ class _SqliteTableViewState extends material.State<SqliteTableView> {
}
try {
final schema = await conn.getTableSchema(table: widget.tableName);
_primaryKeys = List<String>.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 = {};
Expand Down
100 changes: 100 additions & 0 deletions test/core/database/sqlite_connection_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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(
Expand Down
49 changes: 47 additions & 2 deletions test/features/sqlite/sqlite_table_utils_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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"',
Expand All @@ -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',
);
});

Expand Down
Loading