128 lines
5.1 KiB
C#
128 lines
5.1 KiB
C#
namespace Adapters.Driven.Persistence.Repositories;
|
|
|
|
using Adapters.Driven.Persistence.Context;
|
|
using Domain.Entities;
|
|
using Domain.Ports.Repositories;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using System.Linq.Expressions;
|
|
|
|
public class StudentRepository(AppDbContext context) : IStudentRepository
|
|
{
|
|
public async Task<Student?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
|
await context.Students.FindAsync([id], ct);
|
|
|
|
public async Task<Student?> GetByIdWithEnrollmentsAsync(int id, CancellationToken ct = default) =>
|
|
await context.Students
|
|
.Include(s => s.Enrollments)
|
|
.ThenInclude(e => e.Subject)
|
|
.ThenInclude(sub => sub.Professor)
|
|
.AsSplitQuery() // Avoid cartesian explosion
|
|
.FirstOrDefaultAsync(s => s.Id == id, ct);
|
|
|
|
public async Task<IReadOnlyList<Student>> GetAllAsync(CancellationToken ct = default) =>
|
|
await context.Students.AsNoTracking().ToListAsync(ct);
|
|
|
|
public async Task<IReadOnlyList<Student>> GetAllWithEnrollmentsAsync(CancellationToken ct = default) =>
|
|
await context.Students
|
|
.Include(s => s.Enrollments)
|
|
.ThenInclude(e => e.Subject)
|
|
.ThenInclude(sub => sub.Professor)
|
|
.AsSplitQuery() // Avoid cartesian explosion
|
|
.AsNoTrackingWithIdentityResolution() // Better than AsNoTracking with navigations
|
|
.ToListAsync(ct);
|
|
|
|
public async Task<bool> ExistsAsync(int id, CancellationToken ct = default) =>
|
|
await CompiledQueries.StudentExists(context, id);
|
|
|
|
public async Task<bool> EmailExistsAsync(string email, int? excludeId = null, CancellationToken ct = default)
|
|
{
|
|
var normalizedEmail = email.Trim().ToLowerInvariant();
|
|
|
|
// Use raw SQL to avoid value object translation issues
|
|
// SQL Server requires column alias for SqlQueryRaw
|
|
var sql = excludeId.HasValue
|
|
? "SELECT 1 AS Value FROM Students WHERE LOWER(Email) = {0} AND Id != {1}"
|
|
: "SELECT 1 AS Value FROM Students WHERE LOWER(Email) = {0}";
|
|
|
|
var parameters = excludeId.HasValue
|
|
? new object[] { normalizedEmail, excludeId.Value }
|
|
: new object[] { normalizedEmail };
|
|
|
|
return await context.Database
|
|
.SqlQueryRaw<int>(sql, parameters)
|
|
.AnyAsync(ct);
|
|
}
|
|
|
|
// Direct projection - query only needed columns
|
|
public async Task<IReadOnlyList<TResult>> GetAllProjectedAsync<TResult>(
|
|
Expression<Func<Student, TResult>> selector,
|
|
CancellationToken ct = default) =>
|
|
await context.Students
|
|
.AsNoTracking()
|
|
.Select(selector)
|
|
.ToListAsync(ct);
|
|
|
|
public async Task<TResult?> GetByIdProjectedAsync<TResult>(
|
|
int id,
|
|
Expression<Func<Student, TResult>> selector,
|
|
CancellationToken ct = default) =>
|
|
await context.Students
|
|
.Where(s => s.Id == id)
|
|
.AsNoTracking()
|
|
.Select(selector)
|
|
.FirstOrDefaultAsync(ct);
|
|
|
|
// Keyset pagination - more efficient than OFFSET for large datasets
|
|
public async Task<(IReadOnlyList<TResult> Items, int? NextCursor, int TotalCount)> GetPagedProjectedAsync<TResult>(
|
|
Expression<Func<Student, TResult>> selector,
|
|
int? afterId = null,
|
|
int pageSize = 10,
|
|
CancellationToken ct = default)
|
|
{
|
|
var query = context.Students.AsNoTracking();
|
|
|
|
// Keyset: WHERE Id > afterId (uses index efficiently)
|
|
if (afterId.HasValue)
|
|
query = query.Where(s => s.Id > afterId.Value);
|
|
|
|
// Get one extra to determine if there's a next page
|
|
var items = await query
|
|
.OrderBy(s => s.Id)
|
|
.Take(pageSize + 1)
|
|
.Select(selector)
|
|
.ToListAsync(ct);
|
|
|
|
var hasMore = items.Count > pageSize;
|
|
var resultItems = hasMore ? items.Take(pageSize).ToList() : items;
|
|
|
|
// Get next cursor from last item (requires Id in projection or separate query)
|
|
int? nextCursor = null;
|
|
if (hasMore && resultItems.Count > 0)
|
|
{
|
|
var lastId = await context.Students
|
|
.AsNoTracking()
|
|
.Where(s => afterId.HasValue ? s.Id > afterId.Value : true)
|
|
.OrderBy(s => s.Id)
|
|
.Skip(pageSize - 1)
|
|
.Select(s => s.Id)
|
|
.FirstOrDefaultAsync(ct);
|
|
nextCursor = lastId > 0 ? lastId : null;
|
|
}
|
|
|
|
// Use compiled query for count
|
|
var totalCount = await CompiledQueries.CountStudents(context);
|
|
|
|
return (resultItems, nextCursor, totalCount);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<Student>> GetPendingActivationAsync(CancellationToken ct = default) =>
|
|
await context.Students
|
|
.Where(s => s.ActivationCodeHash != null && s.ActivationExpiresAt > DateTime.UtcNow)
|
|
.AsNoTracking()
|
|
.ToListAsync(ct);
|
|
|
|
public void Add(Student student) => context.Students.Add(student);
|
|
public void Update(Student student) => context.Students.Update(student);
|
|
public void Delete(Student student) => context.Students.Remove(student);
|
|
}
|