refactor(database): share watch action queries
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user