Skip to content

Commit 7885344

Browse files
committed
fix(db): make task edit and tag replace atomic (#643)
Wrap saveEditedTaskInDB field update and tag replacement in one transaction so a crash between them cannot leave stale tags.
1 parent f058b4a commit 7885344

2 files changed

Lines changed: 102 additions & 19 deletions

File tree

lib/app/v3/db/task_database.dart

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -251,24 +251,48 @@ class TaskDatabase {
251251
await ensureDatabaseIsOpen();
252252

253253
debugPrint('task in saveEditedTaskInDB: $uuid with due $newDue');
254-
await _database!.update(
255-
'Tasks',
256-
{
257-
'description': newDescription,
258-
'project': newProject,
259-
'status': newStatus,
260-
'priority': newPriority,
261-
'due': newDue,
262-
'modified': DateTime.now().toIso8601String(),
263-
},
264-
where: 'uuid = ?',
265-
whereArgs: [uuid],
266-
);
267-
debugPrint('task${uuid}edited');
268-
if (newTags.isNotEmpty) {
269-
TaskForC? task = await getTaskByUuid(uuid);
270-
await setTagsForTask(uuid, task?.id ?? 0, newTags.toList());
271-
}
254+
// Keep task fields and tag replacement atomic so a crash between the
255+
// former update() and setTagsForTask() cannot leave stale tags.
256+
await _database!.transaction((txn) async {
257+
await txn.update(
258+
'Tasks',
259+
{
260+
'description': newDescription,
261+
'project': newProject,
262+
'status': newStatus,
263+
'priority': newPriority,
264+
'due': newDue,
265+
'modified': DateTime.now().toIso8601String(),
266+
},
267+
where: 'uuid = ?',
268+
whereArgs: [uuid],
269+
);
270+
debugPrint('task${uuid}edited');
271+
if (newTags.isNotEmpty) {
272+
final taskMaps = await txn.query(
273+
'Tasks',
274+
columns: ['id'],
275+
where: 'uuid = ?',
276+
whereArgs: [uuid],
277+
limit: 1,
278+
);
279+
final taskId =
280+
taskMaps.isNotEmpty ? (taskMaps.first['id'] as int? ?? 0) : 0;
281+
await txn.delete(
282+
'Tags',
283+
where: 'task_uuid = ? AND task_id = ?',
284+
whereArgs: [uuid, taskId],
285+
);
286+
for (final tag in newTags) {
287+
if (tag.trim().isNotEmpty) {
288+
await txn.insert(
289+
'Tags',
290+
{'name': tag, 'task_uuid': uuid, 'task_id': taskId},
291+
);
292+
}
293+
}
294+
}
295+
});
272296
}
273297

274298
Future<List<TaskForC>> findTasksWithoutUUIDs() async {

test/api_service_test.dart

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'dart:convert';
2+
import 'dart:io';
23

34
import 'package:flutter/services.dart';
45
import 'package:flutter_test/flutter_test.dart';
@@ -24,10 +25,12 @@ void main() {
2425

2526
databaseFactory = databaseFactoryFfi;
2627
MockClient mockClient = MockClient();
28+
late Directory docsDir;
2729

2830
setUpAll(() {
2931
sqfliteFfiInit();
30-
32+
docsDir = Directory.systemTemp.createTempSync('taskwarrior_test_docs_');
33+
3134
// Mock SharedPreferences plugin
3235
const MethodChannel('plugins.flutter.io/shared_preferences')
3336
.setMockMethodCallHandler((MethodCall methodCall) async {
@@ -36,6 +39,20 @@ void main() {
3639
}
3740
return null;
3841
});
42+
43+
const MethodChannel('plugins.flutter.io/path_provider')
44+
.setMockMethodCallHandler((MethodCall methodCall) async {
45+
if (methodCall.method == 'getApplicationDocumentsDirectory') {
46+
return docsDir.path;
47+
}
48+
return null;
49+
});
50+
});
51+
52+
tearDownAll(() {
53+
if (docsDir.existsSync()) {
54+
docsDir.deleteSync(recursive: true);
55+
}
3956
});
4057

4158
group('Tasks model', () {
@@ -191,5 +208,47 @@ void main() {
191208
// This will throw "Bad state: No element" when there are no tasks
192209
expect(() => taskDatabase.fetchTasksFromDatabase(), throwsStateError);
193210
});
211+
212+
test('saveEditedTaskInDB updates description and tags together', () async {
213+
final task = TaskForC(
214+
id: 7,
215+
description: 'Old description',
216+
project: 'Project 1',
217+
status: 'pending',
218+
uuid: 'edit-uuid',
219+
urgency: 5.0,
220+
priority: 'H',
221+
due: '2024-12-31',
222+
end: '',
223+
entry: '2024-01-01',
224+
modified: '2024-11-01',
225+
tags: ['old'],
226+
start: '',
227+
wait: '',
228+
rtype: '',
229+
recur: '',
230+
depends: [],
231+
annotations: []);
232+
233+
await taskDatabase.insertTask(task);
234+
235+
await taskDatabase.saveEditedTaskInDB(
236+
'edit-uuid',
237+
'New description',
238+
'Project 2',
239+
'pending',
240+
'M',
241+
'2025-01-01',
242+
['new-a', 'new-b'],
243+
);
244+
245+
final edited = await taskDatabase.getTaskByUuid('edit-uuid');
246+
expect(edited, isNotNull);
247+
expect(edited!.description, 'New description');
248+
expect(edited.project, 'Project 2');
249+
expect(edited.priority, 'M');
250+
expect(edited.due, '2025-01-01');
251+
expect(edited.tags, unorderedEquals(['new-a', 'new-b']));
252+
});
194253
});
195254
}

0 commit comments

Comments
 (0)