diff --git a/lib/features/workspace/table_view_staging.dart b/lib/features/workspace/table_view_staging.dart index 5fbee04..7c019ab 100644 --- a/lib/features/workspace/table_view_staging.dart +++ b/lib/features/workspace/table_view_staging.dart @@ -163,9 +163,20 @@ Future showDiscardTableEditsDialog({ ); } -/// Throws if a DML statement matched no rows (stale PK / concurrent delete). +/// Throws unless a staged single-row DML statement matched exactly one row. +/// +/// 0 rows means a stale key or concurrent delete; 2+ rows means an unkeyed +/// table has duplicates and the statement would silently rewrite all of them. +/// Callers run statements in a transaction, so throwing rolls the batch back. void expectDmlMatchedRows(int affectedRows) { - if (affectedRows >= 1) return; + if (affectedRows == 1) return; + if (affectedRows > 1) { + throw StateError( + 'Save failed: a statement matched $affectedRows rows instead of 1. ' + 'The table has duplicate rows or no unique key. ' + 'No changes were applied.', + ); + } throw StateError( 'Save failed: a statement matched 0 rows. ' 'The row may have been changed or deleted. Refresh and try again.', diff --git a/test/core/database/sqlite_connection_test.dart b/test/core/database/sqlite_connection_test.dart index 0d08e6e..5f75805 100644 --- a/test/core/database/sqlite_connection_test.dart +++ b/test/core/database/sqlite_connection_test.dart @@ -432,6 +432,51 @@ void main() { expect(rows.first['name'], 'keep'); }); + test('duplicate-row UPDATE without a key rolls back instead of succeeding', + () async { + await conn.connect(); + await conn.execute('CREATE TABLE t (name TEXT)'); + await conn.execute("INSERT INTO t (name) VALUES ('dup'), ('dup')"); + + await expectLater( + conn.runInTransaction(() async { + expectDmlMatchedRows( + await conn.executeAffected( + "UPDATE t SET name = 'changed' WHERE name = 'dup'", + ), + ); + }), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('matched 2 rows instead of 1'), + ), + ), + ); + + final rows = await conn.execute('SELECT name FROM t'); + expect(rows.map((r) => r['name']), ['dup', 'dup']); + }); + + test('rowid-addressed UPDATE touches only one of the duplicate rows', + () async { + await conn.connect(); + await conn.execute('CREATE TABLE t (name TEXT)'); + await conn.execute("INSERT INTO t (name) VALUES ('dup'), ('dup')"); + + await conn.runInTransaction(() async { + expectDmlMatchedRows( + await conn.executeAffected( + "UPDATE t SET name = 'changed' WHERE \"rowid\" = 1", + ), + ); + }); + + final rows = await conn.execute('SELECT name FROM t ORDER BY rowid'); + expect(rows.map((r) => r['name']), ['changed', 'dup']); + }); + test('handles quotes in quoteIdentifier helper', () { expect( SqliteConnection.quoteIdentifier('normal_table'), '"normal_table"'); diff --git a/test/core/database/table_mutation_engine_test.dart b/test/core/database/table_mutation_engine_test.dart index ef9efbc..99a6c2a 100644 --- a/test/core/database/table_mutation_engine_test.dart +++ b/test/core/database/table_mutation_engine_test.dart @@ -93,6 +93,32 @@ void main() { expect(tx.endsWith('COMMIT;\n'), isTrue); }); + test('addresses unkeyed SQLite rows by implicit rowid in UPDATE and DELETE', + () { + final plan = TableMutationEngine.generatePlan( + dialect: SqlDialect.sqlite, + tableName: 'notes', + columns: const ['rowid', 'body'], + primaryKeys: ['rowid'], + originalRows: const [ + ['7', 'dup'], + ['8', 'dup'], + ], + modifiedCells: { + 0: {1: 'edited'}, + }, + insertedRows: [], + deletedRowIndices: {1}, + ); + + expect(plan.statementCount, 2); + expect( + plan.statements[0].sql, + 'UPDATE "notes" SET "body" = \'edited\' WHERE "rowid" = 7', + ); + expect(plan.statements[1].sql, 'DELETE FROM "notes" WHERE "rowid" = 8'); + }); + test('generates UPDATE statement for MySQL dialect with backticks', () { final plan = TableMutationEngine.generatePlan( dialect: SqlDialect.mysql, diff --git a/test/features/extensions/extension_table_view_test.dart b/test/features/extensions/extension_table_view_test.dart index 611cf64..aac3799 100644 --- a/test/features/extensions/extension_table_view_test.dart +++ b/test/features/extensions/extension_table_view_test.dart @@ -107,7 +107,7 @@ void main() { expect(mutations[2]['where'], {'id': '2'}); }); - test('Extension driver mutation response validation requires integer affectedRows >= 1', () { + test('Extension driver mutation response validation requires integer affectedRows == 1', () { // 1. Missing affectedRows count void validateResponse(dynamic res) { if (res is! Map) throw StateError('Response is not map'); @@ -147,8 +147,18 @@ void main() { )), ); - // 4. Positive affectedRows succeeds - expect(() => validateResponse({'affectedRows': 3}), returnsNormally); + // 4. Multiple matched rows would silently rewrite duplicates + expect( + () => validateResponse({'affectedRows': 3}), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('matched 3 rows instead of 1'), + )), + ); + + // 5. Exactly one affected row succeeds + expect(() => validateResponse({'affectedRows': 1}), returnsNormally); }); test('Schema fetch failure is captured as unavailable and disables staging', () async { diff --git a/test/features/workspace/table_view_staging_test.dart b/test/features/workspace/table_view_staging_test.dart index 1f23a16..900ed81 100644 --- a/test/features/workspace/table_view_staging_test.dart +++ b/test/features/workspace/table_view_staging_test.dart @@ -385,9 +385,21 @@ void main() { }); group('expectDmlMatchedRows', () { - test('allows 1+ affected rows', () { + test('allows exactly 1 affected row', () { expect(() => expectDmlMatchedRows(1), returnsNormally); - expect(() => expectDmlMatchedRows(3), returnsNormally); + }); + + test('throws when a statement matched multiple rows', () { + expect( + () => expectDmlMatchedRows(3), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('matched 3 rows instead of 1'), + ), + ), + ); }); test('throws on 0-row DML so Save is a failure', () {