Skip to content

Commit 3f1811c

Browse files
authored
Merge pull request #566 from DevKor-github/feature/issue-541-drift-indexes-preparation-order
Fix Drift indexes and preparation ordering
2 parents cc18236 + d952f88 commit 3f1811c

10 files changed

Lines changed: 385 additions & 75 deletions

CONTEXT.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,18 @@ _Avoid_: Tracking vendor, analytics SDK
6161
A Product Usage Event that marks completion or failure of a meaningful user workflow step.
6262
_Avoid_: Tap event, raw navigation log, interaction trace
6363

64+
**Schedule**:
65+
A planned commitment with a target time that OnTime helps the user prepare for.
66+
_Avoid_: Event, appointment, alarm
67+
68+
**Preparation**:
69+
The ordered set of steps a user completes before a Schedule.
70+
_Avoid_: Preparation chain, task list
71+
72+
**Preparation Step**:
73+
One named action with an expected duration inside a Preparation.
74+
_Avoid_: Task, checklist item, alarm step
75+
6476
**Provider Authentication Completed**:
6577
The state where the external Apple or Google account prompt has returned credentials to OnTime.
6678
_Avoid_: Login completed, signed in, session ready
@@ -174,6 +186,9 @@ _Avoid_: Loaded range, stream range, cached range
174186
- An **Analytics Event Parameter** must not contain user-authored text, direct identifiers, tokens, raw exception strings, request bodies, or response bodies.
175187
- A **Product Usage Event** uses a stable snake_case name and includes a schema version.
176188
- A changed **Product Usage Event** meaning requires a new event name or schema version.
189+
- A **Schedule** may have one **Preparation** for that specific commitment.
190+
- A **Preparation** contains zero or more **Preparation Steps** in user-defined order.
191+
- A user's default **Preparation** may be applied to a **Schedule** and then changed for that Schedule.
177192
- A **Schedule** has a **Preparation** whose **Preparation Duration** contributes to preparation-start timing.
178193
- **Preparation Duration**, move time, and **Schedule Spare Time** are distinct schedule timing inputs.
179194
- User-facing copy should call a scheduled notification a **Schedule Notification**, not an **Alarm**, unless it opens an OnTime screen without the user first tapping a notification.
@@ -245,4 +260,5 @@ _Avoid_: Loaded range, stream range, cached range
245260
- "Time Sensitive" was too platform-specific for default user-facing status; resolved: fallback iOS delivery should be called notification.
246261
- "Status label" was ambiguous across platforms; resolved: Android uses precise notification or notification status, while iOS uses alarm status only for **iOS AlarmKit Alarm**.
247262
- "No scheduled alarm" was too capability-specific for an empty state; resolved: use **No Scheduled Notification** across platforms.
263+
- "Preparation chain" describes storage reconstruction, not product language; resolved: the domain concept is an ordered **Preparation** made of **Preparation Steps**.
248264
- "Total duration" was ambiguous between **Preparation Duration** and the broader preparation-start timing calculation; resolved: **Preparation Duration** is steps only, while move time and **Schedule Spare Time** are separate inputs.

lib/core/database/database.dart

Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -18,42 +18,51 @@ import 'package:uuid/uuid.dart';
1818
part 'database.g.dart';
1919

2020
@Singleton()
21-
@DriftDatabase(tables: [
22-
Places,
23-
Schedules,
24-
Users,
25-
PreparationSchedules,
26-
PreparationUsers,
27-
], daos: [
28-
ScheduleDao,
29-
PlaceDao,
30-
UserDao,
31-
PreparationScheduleDao,
32-
PreparationUserDao
33-
])
21+
@DriftDatabase(
22+
tables: [Places, Schedules, Users, PreparationSchedules, PreparationUsers],
23+
daos: [
24+
ScheduleDao,
25+
PlaceDao,
26+
UserDao,
27+
PreparationScheduleDao,
28+
PreparationUserDao,
29+
],
30+
)
3431
class AppDatabase extends _$AppDatabase {
3532
AppDatabase() : super(_openConnection());
3633

3734
AppDatabase.forTesting(super.e);
3835

3936
@override
40-
int get schemaVersion => 3;
37+
int get schemaVersion => 4;
4138

4239
@override
4340
MigrationStrategy get migration => MigrationStrategy(
44-
onCreate: (Migrator m) async {
45-
await m.createAll();
46-
},
47-
onUpgrade: (Migrator m, int from, int to) async {
48-
if (from == 1) {
49-
await m.createTable(preparationSchedules);
50-
await m.createTable(preparationUsers);
51-
}
52-
},
53-
beforeOpen: (details) async {
54-
await customStatement('PRAGMA foreign_keys = ON');
55-
},
56-
);
41+
onCreate: (Migrator m) async {
42+
await m.createAll();
43+
},
44+
onUpgrade: (Migrator m, int from, int to) async {
45+
if (from < 3) {
46+
await m.createTable(preparationSchedules);
47+
await m.createTable(preparationUsers);
48+
}
49+
if (from < 4) {
50+
await _createLookupIndexes(m);
51+
}
52+
},
53+
beforeOpen: (details) async {
54+
await customStatement('PRAGMA foreign_keys = ON');
55+
},
56+
);
57+
58+
Future<void> _createLookupIndexes(Migrator m) async {
59+
await m.createIndex(schedulesScheduleTimeIdx);
60+
await m.createIndex(schedulesPlaceIdIdx);
61+
await m.createIndex(preparationSchedulesScheduleIdIdx);
62+
await m.createIndex(preparationSchedulesNextPreparationIdIdx);
63+
await m.createIndex(preparationUsersUserIdIdx);
64+
await m.createIndex(preparationUsersNextPreparationIdIdx);
65+
}
5766

5867
static QueryExecutor _openConnection() {
5968
return driftDatabase(

lib/data/daos/preparation_schedule_dao.dart

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import 'package:collection/collection.dart';
21
import 'package:drift/drift.dart';
32
import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart';
43
import 'package:on_time_front/domain/entities/preparation_step_entity.dart';
@@ -59,29 +58,17 @@ class PreparationScheduleDao extends DatabaseAccessor<AppDatabase>
5958
return PreparationEntity(preparationStepList: []);
6059
}
6160

62-
final firstStep = allSteps.firstWhere(
63-
(step) => allSteps.every((other) => other.nextPreparationId != step.id),
64-
orElse: () => allSteps.first,
65-
);
66-
67-
final List<PreparationStepEntity> orderedSteps = [];
68-
PreparationSchedule? currentStep = firstStep;
69-
70-
while (currentStep != null) {
71-
orderedSteps.add(
72-
PreparationStepEntity(
73-
id: currentStep.id,
74-
preparationName: currentStep.preparationName,
75-
preparationTime: Duration(minutes: currentStep.preparationTime),
76-
nextPreparationId: currentStep.nextPreparationId,
77-
),
78-
);
79-
currentStep = allSteps.firstWhereOrNull(
80-
(step) => step.id == currentStep!.nextPreparationId,
81-
);
82-
}
83-
84-
return PreparationEntity(preparationStepList: orderedSteps);
61+
return PreparationEntity(
62+
preparationStepList: [
63+
for (final step in allSteps)
64+
PreparationStepEntity(
65+
id: step.id,
66+
preparationName: step.preparationName,
67+
preparationTime: Duration(minutes: step.preparationTime),
68+
nextPreparationId: step.nextPreparationId,
69+
),
70+
],
71+
).ordered;
8572
}
8673

8774
Future<PreparationStepEntity> getPreparationStepById(

lib/data/daos/preparation_user_dao.dart

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import 'package:collection/collection.dart';
21
import 'package:drift/drift.dart';
32
import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart';
43
import 'package:on_time_front/domain/entities/preparation_step_entity.dart';
@@ -49,29 +48,17 @@ class PreparationUserDao extends DatabaseAccessor<AppDatabase>
4948
return PreparationEntity(preparationStepList: []);
5049
}
5150

52-
final firstStep = allSteps.firstWhere(
53-
(step) => allSteps.every((other) => other.nextPreparationId != step.id),
54-
orElse: () => allSteps.first,
55-
);
56-
57-
final List<PreparationStepEntity> orderedSteps = [];
58-
PreparationUser? currentStep = firstStep;
59-
60-
while (currentStep != null) {
61-
orderedSteps.add(
62-
PreparationStepEntity(
63-
id: currentStep.id,
64-
preparationName: currentStep.preparationName,
65-
preparationTime: Duration(minutes: currentStep.preparationTime),
66-
nextPreparationId: currentStep.nextPreparationId,
67-
),
68-
);
69-
currentStep = allSteps.firstWhereOrNull(
70-
(step) => step.id == currentStep!.nextPreparationId,
71-
);
72-
}
73-
74-
return PreparationEntity(preparationStepList: orderedSteps);
51+
return PreparationEntity(
52+
preparationStepList: [
53+
for (final step in allSteps)
54+
PreparationStepEntity(
55+
id: step.id,
56+
preparationName: step.preparationName,
57+
preparationTime: Duration(minutes: step.preparationTime),
58+
nextPreparationId: step.nextPreparationId,
59+
),
60+
],
61+
).ordered;
7562
}
7663

7764
Future<PreparationStepEntity> getPreparationStepById(

lib/data/tables/preparation_schedule_table.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@ import 'package:drift/drift.dart';
22
import 'package:on_time_front/data/tables/schedules_table.dart';
33
import 'package:uuid/uuid.dart';
44

5+
@TableIndex(
6+
name: 'preparation_schedules_schedule_id_idx',
7+
columns: {#scheduleId},
8+
)
9+
@TableIndex(
10+
name: 'preparation_schedules_next_preparation_id_idx',
11+
columns: {#nextPreparationId},
12+
)
513
class PreparationSchedules extends Table {
614
TextColumn get id => text().clientDefault(() => Uuid().v7())();
715
TextColumn get scheduleId => text().references(Schedules, #id)();

lib/data/tables/preparation_user_table.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import 'package:drift/drift.dart';
22
import 'package:on_time_front/data/tables/user_table.dart';
33
import 'package:uuid/uuid.dart';
44

5+
@TableIndex(name: 'preparation_users_user_id_idx', columns: {#userId})
6+
@TableIndex(
7+
name: 'preparation_users_next_preparation_id_idx',
8+
columns: {#nextPreparationId},
9+
)
510
class PreparationUsers extends Table {
611
TextColumn get id => text().clientDefault(() => Uuid().v7())();
712
TextColumn get userId => text().references(Users, #id)();

lib/data/tables/schedules_table.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import 'package:on_time_front/core/utils/json_converters/duration_json_converter
33
import 'package:on_time_front/data/tables/places_table.dart';
44
import 'package:uuid/uuid.dart';
55

6+
@TableIndex(name: 'schedules_schedule_time_idx', columns: {#scheduleTime})
7+
@TableIndex(name: 'schedules_place_id_idx', columns: {#placeId})
68
class Schedules extends Table {
79
TextColumn get id => text().clientDefault(() => Uuid().v7())();
810
TextColumn get placeId => text().references(Places, #id)();
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import 'package:drift/drift.dart' as drift;
2+
import 'package:drift/native.dart';
3+
import 'package:flutter_test/flutter_test.dart';
4+
import 'package:on_time_front/core/database/database.dart';
5+
6+
void main() {
7+
late AppDatabase database;
8+
9+
setUp(() {
10+
database = AppDatabase.forTesting(NativeDatabase.memory());
11+
});
12+
13+
tearDown(() async {
14+
await database.close();
15+
});
16+
17+
test(
18+
'creates lookup indexes for schedule and preparation DAO predicates',
19+
() async {
20+
for (final entry in _expectedLookupIndexesByTable.entries) {
21+
final indexNames = await _indexNames(database, entry.key);
22+
23+
expect(
24+
indexNames,
25+
containsAll(entry.value),
26+
reason: '${entry.key} should declare indexes for DAO lookup paths',
27+
);
28+
}
29+
},
30+
);
31+
32+
test('adds lookup indexes when upgrading a schema 3 database', () async {
33+
expect(database.schemaVersion, 4);
34+
35+
await _dropExpectedLookupIndexes(database);
36+
37+
for (final entry in _expectedLookupIndexesByTable.entries) {
38+
expect(
39+
await _indexNames(database, entry.key),
40+
isNot(containsAll(entry.value)),
41+
);
42+
}
43+
44+
await database.migration.onUpgrade(database.createMigrator(), 3, 4);
45+
46+
for (final entry in _expectedLookupIndexesByTable.entries) {
47+
final indexNames = await _indexNames(database, entry.key);
48+
49+
expect(
50+
indexNames,
51+
containsAll(entry.value),
52+
reason: '${entry.key} indexes should be added by the 3 -> 4 migration',
53+
);
54+
}
55+
});
56+
}
57+
58+
const _expectedLookupIndexesByTable = {
59+
'preparation_schedules': {
60+
'preparation_schedules_schedule_id_idx',
61+
'preparation_schedules_next_preparation_id_idx',
62+
},
63+
'preparation_users': {
64+
'preparation_users_user_id_idx',
65+
'preparation_users_next_preparation_id_idx',
66+
},
67+
'schedules': {'schedules_schedule_time_idx', 'schedules_place_id_idx'},
68+
};
69+
70+
Future<Set<String>> _indexNames(AppDatabase database, String tableName) async {
71+
final rows = await database
72+
.customSelect(
73+
'''
74+
SELECT name
75+
FROM sqlite_master
76+
WHERE type = 'index' AND tbl_name = ?
77+
''',
78+
variables: [drift.Variable.withString(tableName)],
79+
)
80+
.get();
81+
82+
return {for (final row in rows) row.read<String>('name')};
83+
}
84+
85+
Future<void> _dropExpectedLookupIndexes(AppDatabase database) async {
86+
for (final indexNames in _expectedLookupIndexesByTable.values) {
87+
for (final indexName in indexNames) {
88+
await database.customStatement('DROP INDEX IF EXISTS $indexName');
89+
}
90+
}
91+
}

0 commit comments

Comments
 (0)