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
15 changes: 13 additions & 2 deletions lib/features/workspace/table_view_staging.dart
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,20 @@ Future<bool?> 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.',
Expand Down
45 changes: 45 additions & 0 deletions test/core/database/sqlite_connection_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<StateError>().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"');
Expand Down
26 changes: 26 additions & 0 deletions test/core/database/table_mutation_engine_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 13 additions & 3 deletions test/features/extensions/extension_table_view_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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<StateError>().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 {
Expand Down
16 changes: 14 additions & 2 deletions test/features/workspace/table_view_staging_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<StateError>().having(
(e) => e.message,
'message',
contains('matched 3 rows instead of 1'),
),
),
);
});

test('throws on 0-row DML so Save is a failure', () {
Expand Down
Loading