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 GetByIdAsync(int id, CancellationToken ct = default) => await context.Students.FindAsync([id], ct); public async Task 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> GetAllAsync(CancellationToken ct = default) => await context.Students.AsNoTracking().ToListAsync(ct); public async Task> 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 ExistsAsync(int id, CancellationToken ct = default) => await CompiledQueries.StudentExists(context, id); public async Task 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(sql, parameters) .AnyAsync(ct); } // Direct projection - query only needed columns public async Task> GetAllProjectedAsync( Expression> selector, CancellationToken ct = default) => await context.Students .AsNoTracking() .Select(selector) .ToListAsync(ct); public async Task GetByIdProjectedAsync( int id, Expression> 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 Items, int? NextCursor, int TotalCount)> GetPagedProjectedAsync( Expression> 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 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); }