2025-12-13 13:27:05 +00:00
|
|
|
|
import 'package:drift/drift.dart';
|
2025-12-13 23:35:14 +00:00
|
|
|
|
import 'package:drift_postgres/drift_postgres.dart';
|
2025-12-13 13:27:05 +00:00
|
|
|
|
import '../database.dart';
|
|
|
|
|
|
import '../tables/tests.dart';
|
|
|
|
|
|
|
|
|
|
|
|
part 'test_dao.g.dart';
|
|
|
|
|
|
|
2025-12-20 18:26:15 +00:00
|
|
|
|
@DriftAccessor(
|
|
|
|
|
|
tables: [Tests, TestQuestions, TestPackRelations, TestStatistics],
|
|
|
|
|
|
)
|
2025-12-13 13:27:05 +00:00
|
|
|
|
class TestDao extends DatabaseAccessor<AppDatabase> with _$TestDaoMixin {
|
|
|
|
|
|
TestDao(super.db);
|
2025-12-20 18:26:15 +00:00
|
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
|
// ==================== Tests ====================
|
2025-12-20 18:26:15 +00:00
|
|
|
|
|
2026-01-24 15:18:58 +00:00
|
|
|
|
/// Получить тест по ID (включая soft-deleted для доступа по прямой ссылке)
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Примечание: soft-deleted тесты не показываются в списке пака (getTestsByPackId),
|
|
|
|
|
|
/// но доступны по прямой ссылке для завершения начатых тестов.
|
2025-12-13 20:55:50 +00:00
|
|
|
|
Future<Test?> getTestById(String id) {
|
2026-01-24 15:18:58 +00:00
|
|
|
|
return (select(tests)..where((t) => t.id.equals(id))).getSingleOrNull();
|
2025-12-13 13:27:05 +00:00
|
|
|
|
}
|
2025-12-20 18:26:15 +00:00
|
|
|
|
|
2025-12-14 20:42:38 +00:00
|
|
|
|
/// Получить все тесты (только активные)
|
2025-12-13 13:27:05 +00:00
|
|
|
|
Future<List<Test>> getAllTests() {
|
2025-12-20 18:26:15 +00:00
|
|
|
|
return (select(tests)..where((t) => t.isDeleted.equals(false))).get();
|
2025-12-13 13:27:05 +00:00
|
|
|
|
}
|
2025-12-20 18:26:15 +00:00
|
|
|
|
|
2025-12-14 20:42:38 +00:00
|
|
|
|
/// Получить тесты пака (только активные)
|
2025-12-13 20:55:50 +00:00
|
|
|
|
Future<List<Test>> getTestsByPackId(String packId) async {
|
2025-12-13 13:27:05 +00:00
|
|
|
|
final query = select(tests).join([
|
|
|
|
|
|
innerJoin(
|
|
|
|
|
|
testPackRelations,
|
|
|
|
|
|
testPackRelations.testId.equalsExp(tests.id) &
|
2025-12-20 18:26:15 +00:00
|
|
|
|
testPackRelations.packId.equals(packId),
|
2025-12-13 13:27:05 +00:00
|
|
|
|
),
|
2025-12-20 18:26:15 +00:00
|
|
|
|
])..where(tests.isDeleted.equals(false));
|
|
|
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
|
return query.map((row) => row.readTable(tests)).get();
|
|
|
|
|
|
}
|
2025-12-20 18:26:15 +00:00
|
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
|
/// Создать тест
|
2025-12-13 20:55:50 +00:00
|
|
|
|
Future<String> createTest(TestsCompanion test) async {
|
|
|
|
|
|
final inserted = await into(tests).insertReturning(test);
|
|
|
|
|
|
return inserted.id;
|
2025-12-13 13:27:05 +00:00
|
|
|
|
}
|
2025-12-20 18:26:15 +00:00
|
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
|
/// Обновить тест
|
|
|
|
|
|
Future<bool> updateTest(Test test) {
|
|
|
|
|
|
return update(tests).replace(test);
|
|
|
|
|
|
}
|
2025-12-20 18:26:15 +00:00
|
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
|
/// Удалить тест (soft delete)
|
2025-12-13 20:55:50 +00:00
|
|
|
|
Future<void> softDeleteTest(String testId) {
|
2025-12-20 18:26:15 +00:00
|
|
|
|
return (update(tests)..where((t) => t.id.equals(testId))).write(
|
|
|
|
|
|
TestsCompanion(
|
|
|
|
|
|
isDeleted: const Value(true),
|
|
|
|
|
|
deletedAt: Value(PgDateTime(DateTime.now())),
|
|
|
|
|
|
updatedAt: Value(PgDateTime(DateTime.now())),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
2025-12-13 13:27:05 +00:00
|
|
|
|
}
|
2025-12-18 20:40:48 +00:00
|
|
|
|
|
2026-01-24 15:18:58 +00:00
|
|
|
|
/// Soft delete tests that have expired (expiresAt <= now)
|
|
|
|
|
|
///
|
|
|
|
|
|
/// When expiresAt is reached, test becomes soft-deleted (available only by direct link)
|
|
|
|
|
|
Future<int> softDeleteExpiredTests() async {
|
|
|
|
|
|
final now = DateTime.now();
|
|
|
|
|
|
|
|
|
|
|
|
final expiredTests = await (select(tests)..where(
|
|
|
|
|
|
(t) =>
|
|
|
|
|
|
t.expiresAt.isSmallerOrEqualValue(PgDateTime(now)) &
|
|
|
|
|
|
t.isDeleted.equals(false),
|
|
|
|
|
|
))
|
|
|
|
|
|
.get();
|
|
|
|
|
|
|
|
|
|
|
|
var deleted = 0;
|
|
|
|
|
|
for (final test in expiredTests) {
|
|
|
|
|
|
await softDeleteTest(test.id);
|
|
|
|
|
|
deleted++;
|
|
|
|
|
|
}
|
|
|
|
|
|
return deleted;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 20:40:48 +00:00
|
|
|
|
/// Hard delete old generated tests that were soft-deleted.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// This is important because we use soft delete for regular operations,
|
|
|
|
|
|
/// but generated tests are ephemeral and otherwise will accumulate in DB
|
|
|
|
|
|
/// (along with their questions/stats). Hard delete triggers FK cascades.
|
2026-01-24 15:18:58 +00:00
|
|
|
|
///
|
|
|
|
|
|
/// Hard deletes tests that were soft-deleted more than [olderThan] ago.
|
2025-12-18 20:40:48 +00:00
|
|
|
|
Future<int> hardDeleteOldSoftDeletedGeneratedTests({
|
|
|
|
|
|
required Duration olderThan,
|
|
|
|
|
|
}) async {
|
|
|
|
|
|
final threshold = DateTime.now().subtract(olderThan);
|
|
|
|
|
|
|
2025-12-20 18:26:15 +00:00
|
|
|
|
final toDelete =
|
|
|
|
|
|
await (select(tests)..where(
|
|
|
|
|
|
(t) =>
|
|
|
|
|
|
t.isDeleted.equals(true) &
|
|
|
|
|
|
t.version.equals('generated') &
|
|
|
|
|
|
t.deletedAt.isSmallerThanValue(PgDateTime(threshold)),
|
|
|
|
|
|
))
|
|
|
|
|
|
.get();
|
2025-12-18 20:40:48 +00:00
|
|
|
|
|
|
|
|
|
|
var deleted = 0;
|
|
|
|
|
|
for (final test in toDelete) {
|
|
|
|
|
|
deleted += await (delete(tests)..where((t) => t.id.equals(test.id))).go();
|
|
|
|
|
|
}
|
|
|
|
|
|
return deleted;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Hard delete generated tests that are not linked to any pack.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// These tests are unreachable from the product (no pack relation) and
|
|
|
|
|
|
/// should not accumulate forever. This also cleans up historical leftovers
|
|
|
|
|
|
/// from the earlier bug where generated tests were created but not linked.
|
|
|
|
|
|
Future<int> hardDeleteOrphanGeneratedTests({
|
|
|
|
|
|
required Duration olderThan,
|
|
|
|
|
|
}) async {
|
|
|
|
|
|
final threshold = DateTime.now().subtract(olderThan);
|
|
|
|
|
|
|
2025-12-20 18:26:15 +00:00
|
|
|
|
final rows =
|
|
|
|
|
|
await (select(tests).join([
|
|
|
|
|
|
leftOuterJoin(
|
|
|
|
|
|
testPackRelations,
|
|
|
|
|
|
testPackRelations.testId.equalsExp(tests.id),
|
|
|
|
|
|
),
|
|
|
|
|
|
])..where(
|
|
|
|
|
|
tests.version.equals('generated') &
|
|
|
|
|
|
tests.createdAt.isSmallerThanValue(PgDateTime(threshold)) &
|
|
|
|
|
|
testPackRelations.testId.isNull(),
|
|
|
|
|
|
))
|
|
|
|
|
|
.get();
|
2025-12-18 20:40:48 +00:00
|
|
|
|
|
|
|
|
|
|
final orphanTests = rows.map((r) => r.readTable(tests)).toList();
|
|
|
|
|
|
|
|
|
|
|
|
var deleted = 0;
|
|
|
|
|
|
for (final test in orphanTests) {
|
|
|
|
|
|
deleted += await (delete(tests)..where((t) => t.id.equals(test.id))).go();
|
|
|
|
|
|
}
|
|
|
|
|
|
return deleted;
|
|
|
|
|
|
}
|
2025-12-20 18:26:15 +00:00
|
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
|
/// Связать тест с паком
|
2025-12-13 20:55:50 +00:00
|
|
|
|
Future<void> linkTestToPack(String testId, String packId) async {
|
2025-12-13 13:27:05 +00:00
|
|
|
|
await into(testPackRelations).insert(
|
2025-12-20 18:26:15 +00:00
|
|
|
|
TestPackRelationsCompanion.insert(testId: testId, packId: packId),
|
2025-12-13 13:27:05 +00:00
|
|
|
|
mode: InsertMode.insertOrIgnore,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2025-12-17 00:41:47 +00:00
|
|
|
|
|
2025-12-17 19:54:48 +00:00
|
|
|
|
/// Удалить связь теста с паком
|
|
|
|
|
|
Future<void> unlinkTestFromPack(String testId, String packId) async {
|
2025-12-20 18:26:15 +00:00
|
|
|
|
await (delete(testPackRelations)..where(
|
|
|
|
|
|
(tpr) => tpr.testId.equals(testId) & tpr.packId.equals(packId),
|
|
|
|
|
|
))
|
|
|
|
|
|
.go();
|
2025-12-17 19:54:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 00:41:47 +00:00
|
|
|
|
/// Получить packId для теста
|
|
|
|
|
|
Future<String?> getPackIdForTest(String testId) async {
|
2025-12-20 18:26:15 +00:00
|
|
|
|
final relation =
|
|
|
|
|
|
await (select(testPackRelations)
|
|
|
|
|
|
..where((tpr) => tpr.testId.equals(testId))
|
|
|
|
|
|
..limit(1))
|
|
|
|
|
|
.getSingleOrNull();
|
2025-12-17 00:41:47 +00:00
|
|
|
|
return relation?.packId;
|
|
|
|
|
|
}
|
2025-12-17 19:54:48 +00:00
|
|
|
|
|
|
|
|
|
|
/// Получить все packId для теста
|
|
|
|
|
|
Future<List<String>> getPackIdsForTest(String testId) async {
|
2025-12-20 18:26:15 +00:00
|
|
|
|
final relations = await (select(
|
|
|
|
|
|
testPackRelations,
|
|
|
|
|
|
)..where((tpr) => tpr.testId.equals(testId))).get();
|
2025-12-17 19:54:48 +00:00
|
|
|
|
return relations.map((r) => r.packId).toList();
|
|
|
|
|
|
}
|
2025-12-20 18:26:15 +00:00
|
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
|
// ==================== TestQuestions ====================
|
2025-12-20 18:26:15 +00:00
|
|
|
|
|
2025-12-17 00:41:47 +00:00
|
|
|
|
/// Получить вопросы теста (только активные, отсортированные по orderIndex)
|
2025-12-13 20:55:50 +00:00
|
|
|
|
Future<List<TestQuestion>> getTestQuestions(String testId) {
|
2025-12-13 13:27:05 +00:00
|
|
|
|
return (select(testQuestions)
|
2025-12-20 18:26:15 +00:00
|
|
|
|
..where((tq) => tq.testId.equals(testId) & tq.isDeleted.equals(false))
|
|
|
|
|
|
..orderBy([(tq) => OrderingTerm(expression: tq.orderIndex)]))
|
|
|
|
|
|
.get();
|
2025-12-13 13:27:05 +00:00
|
|
|
|
}
|
2025-12-20 18:26:15 +00:00
|
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
|
/// Создать вопрос теста
|
2025-12-13 20:55:50 +00:00
|
|
|
|
Future<String> createTestQuestion(TestQuestionsCompanion question) async {
|
|
|
|
|
|
final inserted = await into(testQuestions).insertReturning(question);
|
|
|
|
|
|
return inserted.id;
|
2025-12-13 13:27:05 +00:00
|
|
|
|
}
|
2025-12-20 18:26:15 +00:00
|
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
|
/// Обновить вопрос теста
|
|
|
|
|
|
Future<bool> updateTestQuestion(TestQuestion question) {
|
|
|
|
|
|
return update(testQuestions).replace(question);
|
|
|
|
|
|
}
|
2025-12-20 18:26:15 +00:00
|
|
|
|
|
2025-12-14 20:42:38 +00:00
|
|
|
|
/// Удалить вопрос теста (soft delete)
|
|
|
|
|
|
Future<void> softDeleteTestQuestion(String questionId) {
|
2025-12-20 18:26:15 +00:00
|
|
|
|
return (update(
|
|
|
|
|
|
testQuestions,
|
|
|
|
|
|
)..where((tq) => tq.id.equals(questionId))).write(
|
|
|
|
|
|
TestQuestionsCompanion(
|
|
|
|
|
|
isDeleted: const Value(true),
|
|
|
|
|
|
deletedAt: Value(PgDateTime(DateTime.now())),
|
|
|
|
|
|
updatedAt: Value(PgDateTime(DateTime.now())),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
|
// ==================== TestStatistics ====================
|
2025-12-20 18:26:15 +00:00
|
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
|
/// Получить статистику теста пользователя
|
2025-12-13 20:55:50 +00:00
|
|
|
|
Future<TestStatistic?> getTestStatistics(String userId, String testId) {
|
2025-12-13 13:27:05 +00:00
|
|
|
|
return (select(testStatistics)
|
2025-12-20 18:26:15 +00:00
|
|
|
|
..where((ts) => ts.userId.equals(userId) & ts.testId.equals(testId)))
|
|
|
|
|
|
.getSingleOrNull();
|
2025-12-13 13:27:05 +00:00
|
|
|
|
}
|
2025-12-20 18:26:15 +00:00
|
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
|
/// Создать статистику теста
|
2025-12-20 18:26:15 +00:00
|
|
|
|
Future<String> createTestStatistics(
|
|
|
|
|
|
TestStatisticsCompanion statistics,
|
|
|
|
|
|
) async {
|
2025-12-13 20:55:50 +00:00
|
|
|
|
final inserted = await into(testStatistics).insertReturning(statistics);
|
|
|
|
|
|
return inserted.id;
|
2025-12-13 13:27:05 +00:00
|
|
|
|
}
|
2025-12-20 18:26:15 +00:00
|
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
|
/// Обновить статистику теста
|
|
|
|
|
|
Future<bool> updateTestStatistics(TestStatistic statistics) {
|
|
|
|
|
|
return update(testStatistics).replace(statistics);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|