refactor(database): share watch action queries

This commit is contained in:
edde746
2026-07-12 08:42:24 +02:00
parent 649eaa4cce
commit 2e830d3f97
2 changed files with 255 additions and 53 deletions
+43 -53
View File
@@ -277,6 +277,23 @@ class AppDatabase extends _$AppDatabase {
.get();
}
SimpleSelectStatement<$OfflineWatchProgressTable, OfflineWatchProgressItem> _watchActionsQuery(
Expression<bool> Function($OfflineWatchProgressTable table) matchesKey, {
String? profileId,
bool filterProfile = false,
String? clientScopeId,
bool filterClientScope = false,
}) {
return select(offlineWatchProgress)
..where(
(t) =>
matchesKey(t) &
(filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)) &
(filterClientScope ? _clientScopePredicate(t.clientScopeId, clientScopeId) : const Constant(true)),
)
..orderBy([(t) => OrderingTerm.desc(t.updatedAt), (t) => OrderingTerm.desc(t.id)]);
}
/// Get the latest action for a specific item
Future<OfflineWatchProgressItem?> getLatestWatchAction(
String globalKey, {
@@ -285,16 +302,13 @@ class AppDatabase extends _$AppDatabase {
String? clientScopeId,
bool filterClientScope = false,
}) {
return (select(offlineWatchProgress)
..where(
(t) =>
t.globalKey.equals(globalKey) &
(filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)) &
(filterClientScope ? _clientScopePredicate(t.clientScopeId, clientScopeId) : const Constant(true)),
)
..orderBy([(t) => OrderingTerm.desc(t.updatedAt), (t) => OrderingTerm.desc(t.id)])
..limit(1))
.getSingleOrNull();
return (_watchActionsQuery(
(t) => t.globalKey.equals(globalKey),
profileId: profileId,
filterProfile: filterProfile,
clientScopeId: clientScopeId,
filterClientScope: filterClientScope,
)..limit(1)).getSingleOrNull();
}
Future<List<OfflineWatchProgressItem>> getWatchActionsForKey(
@@ -304,15 +318,13 @@ class AppDatabase extends _$AppDatabase {
String? clientScopeId,
bool filterClientScope = false,
}) {
return (select(offlineWatchProgress)
..where(
(t) =>
t.globalKey.equals(globalKey) &
(filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)) &
(filterClientScope ? _clientScopePredicate(t.clientScopeId, clientScopeId) : const Constant(true)),
)
..orderBy([(t) => OrderingTerm.desc(t.updatedAt), (t) => OrderingTerm.desc(t.id)]))
.get();
return _watchActionsQuery(
(t) => t.globalKey.equals(globalKey),
profileId: profileId,
filterProfile: filterProfile,
clientScopeId: clientScopeId,
filterClientScope: filterClientScope,
).get();
}
Future<Map<String, List<OfflineWatchProgressItem>>> getWatchActionsForKeys(
@@ -322,15 +334,11 @@ class AppDatabase extends _$AppDatabase {
Map<String, String?>? clientScopeIdsByGlobalKey,
}) async {
if (globalKeys.isEmpty) return const {};
final rows =
await (select(offlineWatchProgress)
..where(
(t) =>
t.globalKey.isIn(globalKeys) &
(filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)),
)
..orderBy([(t) => OrderingTerm.desc(t.updatedAt), (t) => OrderingTerm.desc(t.id)]))
.get();
final rows = await _watchActionsQuery(
(t) => t.globalKey.isIn(globalKeys),
profileId: profileId,
filterProfile: filterProfile,
).get();
final result = <String, List<OfflineWatchProgressItem>>{};
for (final action in rows) {
@@ -353,31 +361,13 @@ class AppDatabase extends _$AppDatabase {
bool filterProfile = false,
Map<String, String?>? clientScopeIdsByGlobalKey,
}) async {
if (globalKeys.isEmpty) return {};
// Query all actions for the given keys
final allActions =
await (select(offlineWatchProgress)
..where(
(t) =>
t.globalKey.isIn(globalKeys) &
(filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)),
)
..orderBy([(t) => OrderingTerm.desc(t.updatedAt), (t) => OrderingTerm.desc(t.id)]))
.get();
// Group by globalKey and take the latest (first due to ordering)
final result = <String, OfflineWatchProgressItem>{};
for (final action in allActions) {
if (clientScopeIdsByGlobalKey != null && clientScopeIdsByGlobalKey.containsKey(action.globalKey)) {
final expectedScope = clientScopeIdsByGlobalKey[action.globalKey];
if (!_clientScopeValuesMatch(action.clientScopeId, expectedScope)) continue;
}
// Only keep the first (latest) action for each key
result.putIfAbsent(action.globalKey, () => action);
}
return result;
final actionsByKey = await getWatchActionsForKeys(
globalKeys,
profileId: profileId,
filterProfile: filterProfile,
clientScopeIdsByGlobalKey: clientScopeIdsByGlobalKey,
);
return {for (final entry in actionsByKey.entries) entry.key: entry.value.first};
}
bool _clientScopeValuesMatch(String? actual, String? expected) {
+212
View File
@@ -451,6 +451,30 @@ class _AppDatabaseTestSuite {
// ============================================================
group('OfflineWatchProgress', () {
Future<int> insertAction({
String serverId = 's',
required String ratingKey,
String? profileId,
String? clientScopeId,
required String actionType,
required int updatedAt,
}) {
return db
.into(db.offlineWatchProgress)
.insert(
OfflineWatchProgressCompanion.insert(
serverId: serverId,
profileId: Value(profileId),
clientScopeId: Value(clientScopeId),
ratingKey: ratingKey,
globalKey: '$serverId:$ratingKey',
actionType: actionType,
createdAt: updatedAt,
updatedAt: updatedAt,
),
);
}
test('upsertProgressAction inserts a new progress row', () async {
await db.upsertProgressAction(
serverId: ServerId('srv'),
@@ -671,6 +695,194 @@ class _AppDatabaseTestSuite {
expect(await db.getLatestWatchAction('nope:nope'), isNull);
});
test('single and batched watch-action reads preserve newest-first ordering', () async {
final oldestId = await insertAction(
ratingKey: 'ordered',
actionType: OfflineActionType.progress.id,
updatedAt: 100,
);
final olderTieId = await insertAction(
ratingKey: 'ordered',
actionType: OfflineActionType.watched.id,
updatedAt: 200,
);
final newerTieId = await insertAction(
ratingKey: 'ordered',
actionType: OfflineActionType.unwatched.id,
updatedAt: 200,
);
final expectedIds = [newerTieId, olderTieId, oldestId];
final single = await db.getWatchActionsForKey('s:ordered');
final batched = await db.getWatchActionsForKeys({'s:ordered'});
expect(single.map((action) => action.id), expectedIds);
expect(batched['s:ordered']!.map((action) => action.id), expectedIds);
expect((await db.getLatestWatchAction('s:ordered'))!.id, newerTieId);
expect((await db.getLatestWatchActionsForKeys({'s:ordered'}))['s:ordered']!.id, newerTieId);
});
test('single and batched watch-action reads distinguish null and compound scopes', () async {
final nullScopeId = await insertAction(
ratingKey: 'scoped',
actionType: OfflineActionType.unwatched.id,
updatedAt: 100,
);
final compoundScopeId = await insertAction(
ratingKey: 'scoped',
clientScopeId: 's/user-a',
actionType: OfflineActionType.watched.id,
updatedAt: 200,
);
final singleNull = await db.getWatchActionsForKey('s:scoped', filterClientScope: true);
final singleCompound = await db.getWatchActionsForKey(
's:scoped',
clientScopeId: 's/user-a',
filterClientScope: true,
);
final batchedNull = await db.getWatchActionsForKeys(
{'s:scoped'},
clientScopeIdsByGlobalKey: {'s:scoped': null},
);
final batchedCompound = await db.getWatchActionsForKeys(
{'s:scoped'},
clientScopeIdsByGlobalKey: {'s:scoped': 's/user-a'},
);
expect(singleNull.map((action) => action.id), [nullScopeId]);
expect(singleCompound.map((action) => action.id), [compoundScopeId]);
expect(batchedNull['s:scoped']!.map((action) => action.id), [nullScopeId]);
expect(batchedCompound['s:scoped']!.map((action) => action.id), [compoundScopeId]);
expect((await db.getLatestWatchAction('s:scoped', filterClientScope: true))!.id, nullScopeId);
expect(
(await db.getLatestWatchAction('s:scoped', clientScopeId: 's/user-a', filterClientScope: true))!.id,
compoundScopeId,
);
expect(
(await db.getLatestWatchActionsForKeys(
{'s:scoped'},
clientScopeIdsByGlobalKey: {'s:scoped': null},
))['s:scoped']!.id,
nullScopeId,
);
expect(
(await db.getLatestWatchActionsForKeys(
{'s:scoped'},
clientScopeIdsByGlobalKey: {'s:scoped': 's/user-a'},
))['s:scoped']!.id,
compoundScopeId,
);
});
test('single and batched watch-action reads isolate profiles', () async {
final profileAId = await insertAction(
ratingKey: 'profiled',
profileId: 'profile-a',
actionType: OfflineActionType.unwatched.id,
updatedAt: 100,
);
await insertAction(
ratingKey: 'profiled',
profileId: 'profile-b',
actionType: OfflineActionType.watched.id,
updatedAt: 200,
);
final single = await db.getWatchActionsForKey('s:profiled', profileId: 'profile-a', filterProfile: true);
final batched = await db.getWatchActionsForKeys({'s:profiled'}, profileId: 'profile-a', filterProfile: true);
expect(single.map((action) => action.id), [profileAId]);
expect(batched['s:profiled']!.map((action) => action.id), [profileAId]);
expect(
(await db.getLatestWatchAction('s:profiled', profileId: 'profile-a', filterProfile: true))!.id,
profileAId,
);
expect(
(await db.getLatestWatchActionsForKeys(
{'s:profiled'},
profileId: 'profile-a',
filterProfile: true,
))['s:profiled']!.id,
profileAId,
);
});
test('watch-action query values treat wildcard characters literally', () async {
final exactId = await insertAction(
serverId: 'server_%',
ratingKey: 'item%_',
profileId: 'profile_%',
clientScopeId: 'server_%/user_1',
actionType: OfflineActionType.watched.id,
updatedAt: 100,
);
await insertAction(
serverId: 'server_%',
ratingKey: 'item%_',
profileId: 'profile_abc',
clientScopeId: 'server_%/user_1',
actionType: OfflineActionType.unwatched.id,
updatedAt: 400,
);
await insertAction(
serverId: 'server_%',
ratingKey: 'item%_',
profileId: 'profile_%',
clientScopeId: 'server_abc/user_a',
actionType: OfflineActionType.unwatched.id,
updatedAt: 300,
);
await insertAction(
serverId: 'server_abc',
ratingKey: 'itemZZZx',
profileId: 'profile_%',
clientScopeId: 'server_%/user_1',
actionType: OfflineActionType.unwatched.id,
updatedAt: 200,
);
const globalKey = 'server_%:item%_';
const scopes = {globalKey: 'server_%/user_1'};
final single = await db.getWatchActionsForKey(
globalKey,
profileId: 'profile_%',
filterProfile: true,
clientScopeId: 'server_%/user_1',
filterClientScope: true,
);
final batched = await db.getWatchActionsForKeys(
{globalKey},
profileId: 'profile_%',
filterProfile: true,
clientScopeIdsByGlobalKey: scopes,
);
expect(single.map((action) => action.id), [exactId]);
expect(single.single.serverId, 'server_%');
expect(single.single.ratingKey, 'item%_');
expect(batched[globalKey]!.map((action) => action.id), [exactId]);
expect(
(await db.getLatestWatchAction(
globalKey,
profileId: 'profile_%',
filterProfile: true,
clientScopeId: 'server_%/user_1',
filterClientScope: true,
))!.id,
exactId,
);
expect(
(await db.getLatestWatchActionsForKeys(
{globalKey},
profileId: 'profile_%',
filterProfile: true,
clientScopeIdsByGlobalKey: scopes,
))[globalKey]!.id,
exactId,
);
});
test('getLatestWatchActionsForKeys batches lookups, latest per key', () async {
final now = DateTime.now().millisecondsSinceEpoch;
await db