Refactors repositories for improved structure

Improves repository structure by introducing a generic DbCrud class and dedicated repositories for each entity.

This change enhances code maintainability and testability by separating concerns.
It also introduces soft delete functionality to the generic repository.
This commit is contained in:
2025-10-24 00:44:53 +03:00
parent 55bebbdcae
commit 5d5d091dbe
11 changed files with 242 additions and 63 deletions

View File

@@ -3,4 +3,9 @@ namespace LiquidCode.Api.Authentication.Requests;
/// <summary>
/// Модель запроса для входа пользователя
/// </summary>
public record LoginRequest(string Username, string Password);
/// <param name="Username">Имя пользователя</param>
/// <param name="Password">Пароль</param>
public record LoginRequest(
string Username,
string Password
);

View File

@@ -35,12 +35,12 @@ public interface IMissionRepository : IRepository<DbMission>
/// <summary>
/// Добавляет текстовые данные миссии
/// </summary>
Task AddMissionTextAsync(DbMissionPublicTextData textData, CancellationToken cancellationToken = default);
Task CreateMissionTextAsync(DbMissionPublicTextData textData, CancellationToken cancellationToken = default);
/// <summary>
/// Добавляет несколько записей текстовых данных миссии
/// </summary>
Task AddMissionTextsAsync(IEnumerable<DbMissionPublicTextData> textData, CancellationToken cancellationToken = default);
Task CreateMissionTextsAsync(IEnumerable<DbMissionPublicTextData> textData, CancellationToken cancellationToken = default);
/// <summary>
/// Подсчитывает общее количество миссий

View File

@@ -1,10 +1,12 @@
using LiquidCode.Infrastructure.Database.Entities;
namespace LiquidCode.Domain.Interfaces.Repositories;
/// <summary>
/// Базовый интерфейс репозитория для общих операций CRUD
/// </summary>
/// <typeparam name="TEntity">Тип сущности, управляемой этим репозиторием</typeparam>
public interface IRepository<TEntity> where TEntity : class
public interface IRepository<TEntity> where TEntity : class, ISoftDeletable
{
/// <summary>
/// Находит сущность по ее ID
@@ -12,14 +14,19 @@ public interface IRepository<TEntity> where TEntity : class
Task<TEntity?> FindByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>
/// Получает все сущности
/// Получает все сущности с пагинацией
/// </summary>
Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default);
/// <param name="pageSize">Количество элементов на странице</param>
/// <param name="pageNumber">Номер страницы (начиная с 0)</param>
/// <param name="cancellationToken">Токен отмены</param>
/// <returns>Кортеж (сущности, естьСледующаяСтраница)</returns>
Task<(IEnumerable<TEntity> Items, bool HasNextPage)> GetPageAsync(
int pageSize, int pageNumber, CancellationToken cancellationToken = default);
/// <summary>
/// Добавляет новую сущность
/// </summary>
Task AddAsync(TEntity entity, CancellationToken cancellationToken = default);
Task CreateAsync(TEntity entity, CancellationToken cancellationToken = default);
/// <summary>
/// Обновляет существующую сущность
@@ -29,7 +36,12 @@ public interface IRepository<TEntity> where TEntity : class
/// <summary>
/// Удаляет сущность
/// </summary>
Task RemoveAsync(TEntity entity, CancellationToken cancellationToken = default);
Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default);
/// <summary>
/// Мягко удаляет сущность
/// </summary>
Task SoftDeleteAsync(TEntity entity, CancellationToken cancellationToken = default);
/// <summary>
/// Сохраняет все изменения, сделанные в базе данных

View File

@@ -55,7 +55,7 @@ public class AuthenticationService : IAuthenticationService
Salt = "" // BCrypt управляет солью внутренне
};
await _userRepository.AddAsync(newUser, cancellationToken);
await _userRepository.CreateAsync(newUser, cancellationToken);
_logger.LogInformation("User registered successfully: {Username}", request.Username);
// Автоматически войти пользователю

View File

@@ -83,7 +83,7 @@ public class MissionService : IMissionService
UpdatedAt = DateTime.UtcNow
};
await _missionRepository.AddAsync(dbMission, cancellationToken);
await _missionRepository.CreateAsync(dbMission, cancellationToken);
// Распарсить и сохранить текстовые данные миссии
var missionTexts = ExtractMissionTexts(statementSectionsPath, dbMission.Id);
@@ -104,7 +104,7 @@ public class MissionService : IMissionService
}
// Добавить текстовые данные миссии в базу данных
await _missionRepository.AddMissionTextsAsync(missionTexts, cancellationToken);
await _missionRepository.CreateMissionTextsAsync(missionTexts, cancellationToken);
await _missionRepository.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Mission uploaded successfully: {MissionId}", dbMission.Id);

View File

@@ -72,7 +72,7 @@ public class SubmitService : ISubmitService
Solution = solution
};
await _submitRepository.AddAsync(submission, cancellationToken);
await _submitRepository.CreateAsync(submission, cancellationToken);
_logger.LogInformation("Solution submitted: UserId={UserId}, MissionId={MissionId}, SolutionId={SolutionId}", userId, missionId, solution.Id);
return solution;

View File

@@ -0,0 +1,70 @@
using LiquidCode.Domain.Interfaces.Repositories;
using LiquidCode.Infrastructure.Database;
using LiquidCode.Infrastructure.Database.Entities;
using Microsoft.EntityFrameworkCore;
namespace LiquidCode.Infrastructure.Database.Repositories;
/// <summary>
/// Базовая реализация репозитория, предоставляющая общие операции CRUD
/// </summary>
public class DbCrud<TEntity> : IRepository<TEntity> where TEntity : class, ISoftDeletable
{
private readonly LiquidDbContext _dbContext;
public readonly DbSet<TEntity> DbSet;
public DbCrud(LiquidDbContext dbContext)
{
_dbContext = dbContext;
DbSet = dbContext.Set<TEntity>();
}
public virtual async Task<TEntity?> FindByIdAsync(int id, CancellationToken cancellationToken = default) =>
await DbSet.FindAsync(new object?[] { id }, cancellationToken);
public virtual async Task<(IEnumerable<TEntity> Items, bool HasNextPage)> GetPageAsync(
int pageSize, int pageNumber, CancellationToken cancellationToken = default)
{
if (pageSize <= 0 || pageNumber < 0)
throw new ArgumentException("Page size must be positive, page number must be non-negative");
var totalCount = await DbSet.CountAsync(cancellationToken);
var hasNextPage = totalCount > pageSize * (pageNumber + 1);
var items = await DbSet
.OrderBy(x => EF.Property<int>(x, "Id"))
.Skip(pageSize * pageNumber)
.Take(pageSize)
.ToListAsync(cancellationToken);
return (items, hasNextPage);
}
public virtual async Task CreateAsync(TEntity entity, CancellationToken cancellationToken = default)
{
await DbSet.AddAsync(entity, cancellationToken);
await SaveChangesAsync(cancellationToken);
}
public virtual async Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default)
{
DbSet.Update(entity);
await SaveChangesAsync(cancellationToken);
}
public virtual async Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default)
{
DbSet.Remove(entity);
await SaveChangesAsync(cancellationToken);
}
public virtual async Task SoftDeleteAsync(TEntity entity, CancellationToken cancellationToken = default)
{
entity.IsDeleted = true;
DbSet.Update(entity);
await SaveChangesAsync(cancellationToken);
}
public async Task SaveChangesAsync(CancellationToken cancellationToken = default) =>
await _dbContext.SaveChangesAsync(cancellationToken);
}

View File

@@ -8,52 +8,63 @@ namespace LiquidCode.Infrastructure.Database.Repositories;
/// <summary>
/// Реализация репозитория для операций с базой данных, связанных с миссиями
/// </summary>
public class MissionRepository : Repository<DbMission>, IMissionRepository
public class MissionRepository : IMissionRepository
{
public MissionRepository(LiquidDbContext dbContext) : base(dbContext)
private readonly LiquidDbContext _dbContext;
private readonly DbCrud<DbMission> _missionRepository;
public MissionRepository(LiquidDbContext dbContext)
{
_dbContext = dbContext;
_missionRepository = new DbCrud<DbMission>(dbContext);
}
public async Task<(IEnumerable<DbMission> Missions, bool HasNextPage)> GetMissionsPageAsync(
int pageSize, int pageNumber, CancellationToken cancellationToken = default)
{
if (pageSize <= 0 || pageNumber < 0)
throw new ArgumentException("Page size must be positive, page number must be non-negative");
// IRepository<DbMission> implementation (delegated to _missionRepository)
public Task<DbMission?> FindByIdAsync(int id, CancellationToken cancellationToken = default) =>
_missionRepository.FindByIdAsync(id, cancellationToken);
var totalCount = await DbSet.CountAsync(cancellationToken);
var hasNextPage = totalCount > pageSize * (pageNumber + 1);
public Task<(IEnumerable<DbMission> Items, bool HasNextPage)> GetPageAsync(
int pageSize, int pageNumber, CancellationToken cancellationToken = default) =>
_missionRepository.GetPageAsync(pageSize, pageNumber, cancellationToken);
var missions = await DbSet
.OrderBy(m => m.Id)
.Skip(pageSize * pageNumber)
.Take(pageSize)
.ToListAsync(cancellationToken);
public Task CreateAsync(DbMission entity, CancellationToken cancellationToken = default) =>
_missionRepository.CreateAsync(entity, cancellationToken);
return (missions, hasNextPage);
}
public Task UpdateAsync(DbMission entity, CancellationToken cancellationToken = default) =>
_missionRepository.UpdateAsync(entity, cancellationToken);
public Task DeleteAsync(DbMission entity, CancellationToken cancellationToken = default) =>
_missionRepository.DeleteAsync(entity, cancellationToken);
public Task SoftDeleteAsync(DbMission entity, CancellationToken cancellationToken = default) =>
_missionRepository.SoftDeleteAsync(entity, cancellationToken);
public Task SaveChangesAsync(CancellationToken cancellationToken = default) =>
_missionRepository.SaveChangesAsync(cancellationToken);
// IMissionRepository specific methods
public async Task<IEnumerable<DbMission>> GetMissionsByAuthorAsync(int authorId, CancellationToken cancellationToken = default) =>
await DbSet
await _dbContext.Set<DbMission>()
.Where(m => m.Author.Id == authorId)
.OrderByDescending(m => m.CreatedAt)
.ToListAsync(cancellationToken);
public async Task<DbMissionPublicTextData?> GetMissionTextAsync(int missionId, string language, CancellationToken cancellationToken = default) =>
await DbContext.MissionsTextData
await _dbContext.MissionsTextData
.FirstOrDefaultAsync(m => m.MissionId == missionId && m.Language == language, cancellationToken);
public async Task<IEnumerable<string>> GetMissionLanguagesAsync(int missionId, CancellationToken cancellationToken = default) =>
await DbContext.MissionsTextData
await _dbContext.MissionsTextData
.Where(m => m.MissionId == missionId)
.Select(m => m.Language)
.ToListAsync(cancellationToken);
public async Task AddMissionTextAsync(DbMissionPublicTextData textData, CancellationToken cancellationToken = default) =>
await DbContext.MissionsTextData.AddAsync(textData, cancellationToken);
public async Task CreateMissionTextAsync(DbMissionPublicTextData textData, CancellationToken cancellationToken = default) =>
await _dbContext.MissionsTextData.AddAsync(textData, cancellationToken);
public async Task AddMissionTextsAsync(IEnumerable<DbMissionPublicTextData> textData, CancellationToken cancellationToken = default) =>
await DbContext.MissionsTextData.AddRangeAsync(textData, cancellationToken);
public async Task CreateMissionTextsAsync(IEnumerable<DbMissionPublicTextData> textData, CancellationToken cancellationToken = default) =>
await _dbContext.MissionsTextData.AddRangeAsync(textData, cancellationToken);
public async Task<int> CountMissionsAsync(CancellationToken cancellationToken = default) =>
await DbSet.CountAsync(cancellationToken);
await _dbContext.Set<DbMission>().CountAsync(cancellationToken);
}

View File

@@ -1,5 +1,6 @@
using LiquidCode.Domain.Interfaces.Repositories;
using LiquidCode.Infrastructure.Database;
using LiquidCode.Infrastructure.Database.Entities;
using Microsoft.EntityFrameworkCore;
namespace LiquidCode.Infrastructure.Database.Repositories;
@@ -7,24 +8,39 @@ namespace LiquidCode.Infrastructure.Database.Repositories;
/// <summary>
/// Базовая реализация репозитория, предоставляющая общие операции CRUD
/// </summary>
public class Repository<TEntity> : IRepository<TEntity> where TEntity : class
public class DbCrud<TEntity> : IRepository<TEntity> where TEntity : class, ISoftDeletable
{
protected readonly LiquidDbContext DbContext;
protected readonly DbSet<TEntity> DbSet;
private readonly LiquidDbContext _dbContext;
public readonly DbSet<TEntity> DbSet;
public Repository(LiquidDbContext dbContext)
public DbCrud(LiquidDbContext dbContext)
{
DbContext = dbContext;
_dbContext = dbContext;
DbSet = dbContext.Set<TEntity>();
}
public virtual async Task<TEntity?> FindByIdAsync(int id, CancellationToken cancellationToken = default) =>
await DbSet.FindAsync(new object?[] { id }, cancellationToken);
public virtual async Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default) =>
await DbSet.ToListAsync(cancellationToken);
public virtual async Task<(IEnumerable<TEntity> Items, bool HasNextPage)> GetPageAsync(
int pageSize, int pageNumber, CancellationToken cancellationToken = default)
{
if (pageSize <= 0 || pageNumber < 0)
throw new ArgumentException("Page size must be positive, page number must be non-negative");
public virtual async Task AddAsync(TEntity entity, CancellationToken cancellationToken = default)
var totalCount = await DbSet.CountAsync(cancellationToken);
var hasNextPage = totalCount > pageSize * (pageNumber + 1);
var items = await DbSet
.OrderBy(x => EF.Property<int>(x, "Id"))
.Skip(pageSize * pageNumber)
.Take(pageSize)
.ToListAsync(cancellationToken);
return (items, hasNextPage);
}
public virtual async Task CreateAsync(TEntity entity, CancellationToken cancellationToken = default)
{
await DbSet.AddAsync(entity, cancellationToken);
await SaveChangesAsync(cancellationToken);
@@ -36,12 +52,19 @@ public class Repository<TEntity> : IRepository<TEntity> where TEntity : class
await SaveChangesAsync(cancellationToken);
}
public virtual async Task RemoveAsync(TEntity entity, CancellationToken cancellationToken = default)
public virtual async Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default)
{
DbSet.Remove(entity);
await SaveChangesAsync(cancellationToken);
}
public virtual async Task SoftDeleteAsync(TEntity entity, CancellationToken cancellationToken = default)
{
entity.IsDeleted = true;
DbSet.Update(entity);
await SaveChangesAsync(cancellationToken);
}
public async Task SaveChangesAsync(CancellationToken cancellationToken = default) =>
await DbContext.SaveChangesAsync(cancellationToken);
await _dbContext.SaveChangesAsync(cancellationToken);
}

View File

@@ -8,34 +8,63 @@ namespace LiquidCode.Infrastructure.Database.Repositories;
/// <summary>
/// Реализация репозитория для операций с базой данных, связанных с отправками пользователей
/// </summary>
public class SubmitRepository : Repository<DbUserSubmit>, ISubmitRepository
public class SubmitRepository : ISubmitRepository
{
public SubmitRepository(LiquidDbContext dbContext) : base(dbContext)
private readonly LiquidDbContext _dbContext;
private readonly DbCrud<DbUserSubmit> _submitRepository;
public SubmitRepository(LiquidDbContext dbContext)
{
_dbContext = dbContext;
_submitRepository = new DbCrud<DbUserSubmit>(dbContext);
}
// IRepository<DbUserSubmit> implementation (delegated to _submitRepository)
public Task<DbUserSubmit?> FindByIdAsync(int id, CancellationToken cancellationToken = default) =>
_submitRepository.FindByIdAsync(id, cancellationToken);
public Task<(IEnumerable<DbUserSubmit> Items, bool HasNextPage)> GetPageAsync(
int pageSize, int pageNumber, CancellationToken cancellationToken = default) =>
_submitRepository.GetPageAsync(pageSize, pageNumber, cancellationToken);
public Task CreateAsync(DbUserSubmit entity, CancellationToken cancellationToken = default) =>
_submitRepository.CreateAsync(entity, cancellationToken);
public Task UpdateAsync(DbUserSubmit entity, CancellationToken cancellationToken = default) =>
_submitRepository.UpdateAsync(entity, cancellationToken);
public Task DeleteAsync(DbUserSubmit entity, CancellationToken cancellationToken = default) =>
_submitRepository.DeleteAsync(entity, cancellationToken);
public Task SoftDeleteAsync(DbUserSubmit entity, CancellationToken cancellationToken = default) =>
_submitRepository.SoftDeleteAsync(entity, cancellationToken);
public Task SaveChangesAsync(CancellationToken cancellationToken = default) =>
_submitRepository.SaveChangesAsync(cancellationToken);
// ISubmitRepository specific methods
public async Task<IEnumerable<DbUserSubmit>> GetSubmissionsByUserAsync(int userId, CancellationToken cancellationToken = default) =>
await DbSet
await _dbContext.Set<DbUserSubmit>()
.Where(s => s.User.Id == userId)
.OrderByDescending(s => s.Solution!.Time)
.ToListAsync(cancellationToken);
public async Task<IEnumerable<DbUserSubmit>> GetSubmissionsByMissionAsync(int missionId, CancellationToken cancellationToken = default) =>
await DbSet
await _dbContext.Set<DbUserSubmit>()
.Where(s => s.Solution!.Mission.Id == missionId)
.OrderByDescending(s => s.Solution!.Time)
.ToListAsync(cancellationToken);
public async Task<DbUserSubmit?> GetSubmissionWithDetailsAsync(int submissionId, CancellationToken cancellationToken = default) =>
await DbSet
await _dbContext.Set<DbUserSubmit>()
.Include(s => s.User)
.Include(s => s.Solution)
.FirstOrDefaultAsync(s => s.Id == submissionId, cancellationToken);
public async Task<DbSolution?> GetSolutionAsync(int solutionId, CancellationToken cancellationToken = default) =>
await DbContext.Solutions
await _dbContext.Solutions
.FirstOrDefaultAsync(s => s.Id == solutionId, cancellationToken);
public async Task AddSolutionAsync(DbSolution solution, CancellationToken cancellationToken = default) =>
await DbContext.Solutions.AddAsync(solution, cancellationToken);
await _dbContext.Solutions.AddAsync(solution, cancellationToken);
}

View File

@@ -8,39 +8,68 @@ namespace LiquidCode.Infrastructure.Database.Repositories;
/// <summary>
/// Реализация репозитория для операций с базой данных, связанных с пользователями
/// </summary>
public class UserRepository : Repository<DbUser>, IUserRepository
public class UserRepository : IUserRepository
{
public UserRepository(LiquidDbContext dbContext) : base(dbContext)
private readonly LiquidDbContext _dbContext;
private readonly DbCrud<DbUser> _userRepository;
public UserRepository(LiquidDbContext dbContext)
{
_dbContext = dbContext;
_userRepository = new DbCrud<DbUser>(dbContext);
}
// IRepository<DbUser> implementation (delegated to _userRepository)
public Task<DbUser?> FindByIdAsync(int id, CancellationToken cancellationToken = default) =>
_userRepository.FindByIdAsync(id, cancellationToken);
public Task<(IEnumerable<DbUser> Items, bool HasNextPage)> GetPageAsync(
int pageSize, int pageNumber, CancellationToken cancellationToken = default) =>
_userRepository.GetPageAsync(pageSize, pageNumber, cancellationToken);
public Task CreateAsync(DbUser entity, CancellationToken cancellationToken = default) =>
_userRepository.CreateAsync(entity, cancellationToken);
public Task UpdateAsync(DbUser entity, CancellationToken cancellationToken = default) =>
_userRepository.UpdateAsync(entity, cancellationToken);
public Task DeleteAsync(DbUser entity, CancellationToken cancellationToken = default) =>
_userRepository.DeleteAsync(entity, cancellationToken);
public Task SoftDeleteAsync(DbUser entity, CancellationToken cancellationToken = default) =>
_userRepository.SoftDeleteAsync(entity, cancellationToken);
public Task SaveChangesAsync(CancellationToken cancellationToken = default) =>
_userRepository.SaveChangesAsync(cancellationToken);
// IUserRepository specific methods
public async Task<DbUser?> FindByUsernameAsync(string username, CancellationToken cancellationToken = default) =>
await DbSet.FirstOrDefaultAsync(u => u.Username == username, cancellationToken);
await _dbContext.Users.FirstOrDefaultAsync(u => u.Username == username, cancellationToken);
public async Task<bool> UserExistsAsync(string username, CancellationToken cancellationToken = default) =>
await DbSet.AnyAsync(u => u.Username == username, cancellationToken);
await _dbContext.Users.AnyAsync(u => u.Username == username, cancellationToken);
public async Task<int> GetRefreshTokenCountAsync(int userId, CancellationToken cancellationToken = default) =>
await DbContext.RefreshTokens.CountAsync(t => t.DbUser.Id == userId, cancellationToken);
await _dbContext.RefreshTokens.CountAsync(t => t.DbUser.Id == userId, cancellationToken);
public async Task<DbRefreshToken?> GetOldestRefreshTokenAsync(int userId, CancellationToken cancellationToken = default) =>
await DbContext.RefreshTokens
await _dbContext.RefreshTokens
.Where(t => t.DbUser.Id == userId)
.OrderBy(t => t.Expires)
.FirstOrDefaultAsync(cancellationToken);
public async Task AddRefreshTokenAsync(DbRefreshToken token, CancellationToken cancellationToken = default) =>
await DbContext.RefreshTokens.AddAsync(token, cancellationToken);
await _dbContext.RefreshTokens.AddAsync(token, cancellationToken);
public async Task RemoveRefreshTokenAsync(string tokenString, CancellationToken cancellationToken = default)
{
var token = await DbContext.RefreshTokens.FirstOrDefaultAsync(t => t.Token == tokenString, cancellationToken);
var token = await _dbContext.RefreshTokens.FirstOrDefaultAsync(t => t.Token == tokenString, cancellationToken);
if (token != null)
DbContext.RefreshTokens.Remove(token);
_dbContext.RefreshTokens.Remove(token);
}
public async Task<DbRefreshToken?> FindRefreshTokenAsync(string tokenString, CancellationToken cancellationToken = default) =>
await DbContext.RefreshTokens
await _dbContext.RefreshTokens
.Include(t => t.DbUser)
.FirstOrDefaultAsync(t => t.Token == tokenString, cancellationToken);
}