From a7dde52e026645cfd0426e5f2685eb49a79daddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Eduardo=20Garc=C3=ADa=20M=C3=A1rquez?= Date: Wed, 7 Jan 2026 22:59:38 -0500 Subject: [PATCH] feat(adapters): add driven and driving adapters Driven Adapters (Persistence): - AppDbContext with EF Core configurations - Repository implementations (Student, Subject, Professor, Enrollment) - UnitOfWork pattern for transactions - DataLoaders for GraphQL N+1 optimization - Database seeding with 5 professors and 10 subjects - EF Core migrations for SQL Server Driving Adapters (API): - GraphQL API with HotChocolate - Query and Mutation types - Type definitions for all entities - GraphQLErrorFilter for domain exceptions Co-Authored-By: Claude Opus 4.5 --- .../Adapters.Driven.Persistence.csproj | 23 ++ .../Driven/Persistence/CompiledQueries.cs | 55 ++++ .../Configurations/EnrollmentConfiguration.cs | 30 ++ .../Configurations/ProfessorConfiguration.cs | 23 ++ .../Configurations/StudentConfiguration.cs | 45 +++ .../Configurations/SubjectConfiguration.cs | 33 +++ .../Persistence/Context/AppDbContext.cs | 20 ++ .../DataLoaders/ProfessorByIdDataLoader.cs | 30 ++ .../DataLoaders/StudentByIdDataLoader.cs | 30 ++ .../DataLoaders/SubjectByIdDataLoader.cs | 31 ++ .../Driven/Persistence/DependencyInjection.cs | 29 ++ .../20260107212215_InitialCreate.Designer.cs | 174 +++++++++++ .../20260107212215_InitialCreate.cs | 128 ++++++++ .../20260107212421_SeedData.Designer.cs | 273 ++++++++++++++++++ .../Migrations/20260107212421_SeedData.cs | 124 ++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 270 +++++++++++++++++ .../Repositories/EnrollmentRepository.cs | 75 +++++ .../Repositories/ProfessorRepository.cs | 31 ++ .../Repositories/StudentRepository.cs | 121 ++++++++ .../Repositories/SubjectRepository.cs | 54 ++++ .../Persistence/Repositories/UnitOfWork.cs | 10 + .../Driven/Persistence/Seeding/DataSeeder.cs | 32 ++ .../Driving/Api/Adapters.Driving.Api.csproj | 20 ++ .../Api/Extensions/GraphQLExtensions.cs | 54 ++++ .../Api/Middleware/GraphQLErrorFilter.cs | 29 ++ .../Api/Types/Enrollments/EnrollmentType.cs | 22 ++ .../Adapters/Driving/Api/Types/Mutation.cs | 72 +++++ .../Api/Types/Professors/ProfessorType.cs | 11 + .../Adapters/Driving/Api/Types/Query.cs | 61 ++++ .../Driving/Api/Types/Students/StudentType.cs | 16 + .../Driving/Api/Types/Subjects/SubjectType.cs | 15 + 31 files changed, 1941 insertions(+) create mode 100644 src/backend/Adapters/Driven/Persistence/Adapters.Driven.Persistence.csproj create mode 100644 src/backend/Adapters/Driven/Persistence/CompiledQueries.cs create mode 100644 src/backend/Adapters/Driven/Persistence/Configurations/EnrollmentConfiguration.cs create mode 100644 src/backend/Adapters/Driven/Persistence/Configurations/ProfessorConfiguration.cs create mode 100644 src/backend/Adapters/Driven/Persistence/Configurations/StudentConfiguration.cs create mode 100644 src/backend/Adapters/Driven/Persistence/Configurations/SubjectConfiguration.cs create mode 100644 src/backend/Adapters/Driven/Persistence/Context/AppDbContext.cs create mode 100644 src/backend/Adapters/Driven/Persistence/DataLoaders/ProfessorByIdDataLoader.cs create mode 100644 src/backend/Adapters/Driven/Persistence/DataLoaders/StudentByIdDataLoader.cs create mode 100644 src/backend/Adapters/Driven/Persistence/DataLoaders/SubjectByIdDataLoader.cs create mode 100644 src/backend/Adapters/Driven/Persistence/DependencyInjection.cs create mode 100644 src/backend/Adapters/Driven/Persistence/Migrations/20260107212215_InitialCreate.Designer.cs create mode 100644 src/backend/Adapters/Driven/Persistence/Migrations/20260107212215_InitialCreate.cs create mode 100644 src/backend/Adapters/Driven/Persistence/Migrations/20260107212421_SeedData.Designer.cs create mode 100644 src/backend/Adapters/Driven/Persistence/Migrations/20260107212421_SeedData.cs create mode 100644 src/backend/Adapters/Driven/Persistence/Migrations/AppDbContextModelSnapshot.cs create mode 100644 src/backend/Adapters/Driven/Persistence/Repositories/EnrollmentRepository.cs create mode 100644 src/backend/Adapters/Driven/Persistence/Repositories/ProfessorRepository.cs create mode 100644 src/backend/Adapters/Driven/Persistence/Repositories/StudentRepository.cs create mode 100644 src/backend/Adapters/Driven/Persistence/Repositories/SubjectRepository.cs create mode 100644 src/backend/Adapters/Driven/Persistence/Repositories/UnitOfWork.cs create mode 100644 src/backend/Adapters/Driven/Persistence/Seeding/DataSeeder.cs create mode 100644 src/backend/Adapters/Driving/Api/Adapters.Driving.Api.csproj create mode 100644 src/backend/Adapters/Driving/Api/Extensions/GraphQLExtensions.cs create mode 100644 src/backend/Adapters/Driving/Api/Middleware/GraphQLErrorFilter.cs create mode 100644 src/backend/Adapters/Driving/Api/Types/Enrollments/EnrollmentType.cs create mode 100644 src/backend/Adapters/Driving/Api/Types/Mutation.cs create mode 100644 src/backend/Adapters/Driving/Api/Types/Professors/ProfessorType.cs create mode 100644 src/backend/Adapters/Driving/Api/Types/Query.cs create mode 100644 src/backend/Adapters/Driving/Api/Types/Students/StudentType.cs create mode 100644 src/backend/Adapters/Driving/Api/Types/Subjects/SubjectType.cs diff --git a/src/backend/Adapters/Driven/Persistence/Adapters.Driven.Persistence.csproj b/src/backend/Adapters/Driven/Persistence/Adapters.Driven.Persistence.csproj new file mode 100644 index 0000000..4a9ad77 --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/Adapters.Driven.Persistence.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/src/backend/Adapters/Driven/Persistence/CompiledQueries.cs b/src/backend/Adapters/Driven/Persistence/CompiledQueries.cs new file mode 100644 index 0000000..27b0478 --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/CompiledQueries.cs @@ -0,0 +1,55 @@ +namespace Adapters.Driven.Persistence; + +using Adapters.Driven.Persistence.Context; +using Domain.Entities; +using Microsoft.EntityFrameworkCore; + +/// +/// Pre-compiled EF Core queries for frequently used operations. +/// Compiled queries skip expression tree parsing on each execution. +/// +public static class CompiledQueries +{ + // Student queries + public static readonly Func> GetStudentById = + EF.CompileAsyncQuery((AppDbContext ctx, int id) => + ctx.Students.FirstOrDefault(s => s.Id == id)); + + public static readonly Func> StudentExists = + EF.CompileAsyncQuery((AppDbContext ctx, int id) => + ctx.Students.Any(s => s.Id == id)); + + // EmailExists removed - using raw SQL in repository due to value object translation issues + + // Subject queries + public static readonly Func> GetSubjectById = + EF.CompileAsyncQuery((AppDbContext ctx, int id) => + ctx.Subjects.FirstOrDefault(s => s.Id == id)); + + public static readonly Func> GetAllSubjects = + EF.CompileAsyncQuery((AppDbContext ctx) => + ctx.Subjects.AsNoTracking()); + + // Professor queries + public static readonly Func> GetProfessorById = + EF.CompileAsyncQuery((AppDbContext ctx, int id) => + ctx.Professors.FirstOrDefault(p => p.Id == id)); + + // Enrollment queries + public static readonly Func> CountStudentEnrollments = + EF.CompileAsyncQuery((AppDbContext ctx, int studentId) => + ctx.Enrollments.Count(e => e.StudentId == studentId)); + + public static readonly Func> EnrollmentExists = + EF.CompileAsyncQuery((AppDbContext ctx, int studentId, int subjectId) => + ctx.Enrollments.Any(e => e.StudentId == studentId && e.SubjectId == subjectId)); + + // Count queries (for pagination) + public static readonly Func> CountStudents = + EF.CompileAsyncQuery((AppDbContext ctx) => + ctx.Students.Count()); + + public static readonly Func> CountSubjects = + EF.CompileAsyncQuery((AppDbContext ctx) => + ctx.Subjects.Count()); +} diff --git a/src/backend/Adapters/Driven/Persistence/Configurations/EnrollmentConfiguration.cs b/src/backend/Adapters/Driven/Persistence/Configurations/EnrollmentConfiguration.cs new file mode 100644 index 0000000..6ba1331 --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/Configurations/EnrollmentConfiguration.cs @@ -0,0 +1,30 @@ +namespace Adapters.Driven.Persistence.Configurations; + +using Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class EnrollmentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Enrollments"); + + builder.HasKey(e => e.Id); + + builder.Property(e => e.EnrolledAt) + .IsRequired(); + + builder.HasIndex(e => new { e.StudentId, e.SubjectId }).IsUnique(); + + builder.HasOne(e => e.Student) + .WithMany(s => s.Enrollments) + .HasForeignKey(e => e.StudentId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(e => e.Subject) + .WithMany(s => s.Enrollments) + .HasForeignKey(e => e.SubjectId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/src/backend/Adapters/Driven/Persistence/Configurations/ProfessorConfiguration.cs b/src/backend/Adapters/Driven/Persistence/Configurations/ProfessorConfiguration.cs new file mode 100644 index 0000000..5a60869 --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/Configurations/ProfessorConfiguration.cs @@ -0,0 +1,23 @@ +namespace Adapters.Driven.Persistence.Configurations; + +using Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class ProfessorConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Professors"); + + builder.HasKey(p => p.Id); + + builder.Property(p => p.Name) + .IsRequired() + .HasMaxLength(100); + + builder.HasMany(p => p.Subjects) + .WithOne(s => s.Professor) + .HasForeignKey(s => s.ProfessorId); + } +} diff --git a/src/backend/Adapters/Driven/Persistence/Configurations/StudentConfiguration.cs b/src/backend/Adapters/Driven/Persistence/Configurations/StudentConfiguration.cs new file mode 100644 index 0000000..c8aafde --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/Configurations/StudentConfiguration.cs @@ -0,0 +1,45 @@ +namespace Adapters.Driven.Persistence.Configurations; + +using Domain.Entities; +using Domain.ValueObjects; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class StudentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Students"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Name) + .IsRequired() + .HasMaxLength(100); + + // Email value object with proper converter and comparer + var emailComparer = new ValueComparer( + (e1, e2) => e1 != null && e2 != null && e1.Value == e2.Value, + e => e.Value.GetHashCode(), + e => Email.Create(e.Value)); + + builder.Property(s => s.Email) + .IsRequired() + .HasMaxLength(150) + .HasConversion( + e => e.Value, + v => Email.Create(v)) + .Metadata.SetValueComparer(emailComparer); + + // Use raw column name for index to avoid value object issues + builder.HasIndex("Email").IsUnique(); + + builder.HasMany(s => s.Enrollments) + .WithOne(e => e.Student) + .HasForeignKey(e => e.StudentId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Navigation(s => s.Enrollments).AutoInclude(false); + } +} diff --git a/src/backend/Adapters/Driven/Persistence/Configurations/SubjectConfiguration.cs b/src/backend/Adapters/Driven/Persistence/Configurations/SubjectConfiguration.cs new file mode 100644 index 0000000..c3adb3e --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/Configurations/SubjectConfiguration.cs @@ -0,0 +1,33 @@ +namespace Adapters.Driven.Persistence.Configurations; + +using Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class SubjectConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Subjects"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Name) + .IsRequired() + .HasMaxLength(100); + + builder.Property(s => s.Credits) + .IsRequired() + .HasDefaultValue(3); + + builder.HasOne(s => s.Professor) + .WithMany(p => p.Subjects) + .HasForeignKey(s => s.ProfessorId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasMany(s => s.Enrollments) + .WithOne(e => e.Subject) + .HasForeignKey(e => e.SubjectId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/src/backend/Adapters/Driven/Persistence/Context/AppDbContext.cs b/src/backend/Adapters/Driven/Persistence/Context/AppDbContext.cs new file mode 100644 index 0000000..10a568a --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/Context/AppDbContext.cs @@ -0,0 +1,20 @@ +namespace Adapters.Driven.Persistence.Context; + +using Adapters.Driven.Persistence.Seeding; +using Domain.Entities; +using Microsoft.EntityFrameworkCore; + +public class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Students => Set(); + public DbSet Subjects => Set(); + public DbSet Professors => Set(); + public DbSet Enrollments => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); + DataSeeder.Seed(modelBuilder); + base.OnModelCreating(modelBuilder); + } +} diff --git a/src/backend/Adapters/Driven/Persistence/DataLoaders/ProfessorByIdDataLoader.cs b/src/backend/Adapters/Driven/Persistence/DataLoaders/ProfessorByIdDataLoader.cs new file mode 100644 index 0000000..e7d588e --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/DataLoaders/ProfessorByIdDataLoader.cs @@ -0,0 +1,30 @@ +namespace Adapters.Driven.Persistence.DataLoaders; + +using Adapters.Driven.Persistence.Context; +using Domain.Entities; +using GreenDonut; +using Microsoft.EntityFrameworkCore; + +public class ProfessorByIdDataLoader : BatchDataLoader +{ + private readonly IDbContextFactory _contextFactory; + + public ProfessorByIdDataLoader( + IDbContextFactory contextFactory, + IBatchScheduler batchScheduler, + DataLoaderOptions? options = null) + : base(batchScheduler, options ?? new DataLoaderOptions()) + { + _contextFactory = contextFactory; + } + + protected override async Task> LoadBatchAsync( + IReadOnlyList keys, + CancellationToken ct) + { + await using var context = await _contextFactory.CreateDbContextAsync(ct); + return await context.Professors + .Where(p => keys.Contains(p.Id)) + .ToDictionaryAsync(p => p.Id, ct); + } +} diff --git a/src/backend/Adapters/Driven/Persistence/DataLoaders/StudentByIdDataLoader.cs b/src/backend/Adapters/Driven/Persistence/DataLoaders/StudentByIdDataLoader.cs new file mode 100644 index 0000000..1aa3eda --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/DataLoaders/StudentByIdDataLoader.cs @@ -0,0 +1,30 @@ +namespace Adapters.Driven.Persistence.DataLoaders; + +using Adapters.Driven.Persistence.Context; +using Domain.Entities; +using GreenDonut; +using Microsoft.EntityFrameworkCore; + +public class StudentByIdDataLoader : BatchDataLoader +{ + private readonly IDbContextFactory _contextFactory; + + public StudentByIdDataLoader( + IDbContextFactory contextFactory, + IBatchScheduler batchScheduler, + DataLoaderOptions? options = null) + : base(batchScheduler, options ?? new DataLoaderOptions()) + { + _contextFactory = contextFactory; + } + + protected override async Task> LoadBatchAsync( + IReadOnlyList keys, + CancellationToken ct) + { + await using var context = await _contextFactory.CreateDbContextAsync(ct); + return await context.Students + .Where(s => keys.Contains(s.Id)) + .ToDictionaryAsync(s => s.Id, ct); + } +} diff --git a/src/backend/Adapters/Driven/Persistence/DataLoaders/SubjectByIdDataLoader.cs b/src/backend/Adapters/Driven/Persistence/DataLoaders/SubjectByIdDataLoader.cs new file mode 100644 index 0000000..7756864 --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/DataLoaders/SubjectByIdDataLoader.cs @@ -0,0 +1,31 @@ +namespace Adapters.Driven.Persistence.DataLoaders; + +using Adapters.Driven.Persistence.Context; +using Domain.Entities; +using GreenDonut; +using Microsoft.EntityFrameworkCore; + +public class SubjectByIdDataLoader : BatchDataLoader +{ + private readonly IDbContextFactory _contextFactory; + + public SubjectByIdDataLoader( + IDbContextFactory contextFactory, + IBatchScheduler batchScheduler, + DataLoaderOptions? options = null) + : base(batchScheduler, options ?? new DataLoaderOptions()) + { + _contextFactory = contextFactory; + } + + protected override async Task> LoadBatchAsync( + IReadOnlyList keys, + CancellationToken ct) + { + await using var context = await _contextFactory.CreateDbContextAsync(ct); + return await context.Subjects + .Include(s => s.Professor) + .Where(s => keys.Contains(s.Id)) + .ToDictionaryAsync(s => s.Id, ct); + } +} diff --git a/src/backend/Adapters/Driven/Persistence/DependencyInjection.cs b/src/backend/Adapters/Driven/Persistence/DependencyInjection.cs new file mode 100644 index 0000000..c58fcbc --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/DependencyInjection.cs @@ -0,0 +1,29 @@ +namespace Adapters.Driven.Persistence; + +using Adapters.Driven.Persistence.Context; +using Adapters.Driven.Persistence.Repositories; +using Domain.Ports.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +public static class DependencyInjection +{ + public static IServiceCollection AddPersistence( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddDbContext(options => + options.UseSqlServer( + configuration.GetConnectionString("DefaultConnection"), + b => b.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName))); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/src/backend/Adapters/Driven/Persistence/Migrations/20260107212215_InitialCreate.Designer.cs b/src/backend/Adapters/Driven/Persistence/Migrations/20260107212215_InitialCreate.Designer.cs new file mode 100644 index 0000000..7c8e61a --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/Migrations/20260107212215_InitialCreate.Designer.cs @@ -0,0 +1,174 @@ +// +using System; +using Adapters.Driven.Persistence.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Adapters.Driven.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260107212215_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.Enrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EnrolledAt") + .HasColumnType("datetime2"); + + b.Property("StudentId") + .HasColumnType("int"); + + b.Property("SubjectId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubjectId"); + + b.HasIndex("StudentId", "SubjectId") + .IsUnique(); + + b.ToTable("Enrollments", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Professor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.ToTable("Professors", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Email") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Students", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Subject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Credits") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ProfessorId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProfessorId"); + + b.ToTable("Subjects", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Enrollment", b => + { + b.HasOne("Domain.Entities.Student", "Student") + .WithMany("Enrollments") + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.Subject", "Subject") + .WithMany("Enrollments") + .HasForeignKey("SubjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Student"); + + b.Navigation("Subject"); + }); + + modelBuilder.Entity("Domain.Entities.Subject", b => + { + b.HasOne("Domain.Entities.Professor", "Professor") + .WithMany("Subjects") + .HasForeignKey("ProfessorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Professor"); + }); + + modelBuilder.Entity("Domain.Entities.Professor", b => + { + b.Navigation("Subjects"); + }); + + modelBuilder.Entity("Domain.Entities.Student", b => + { + b.Navigation("Enrollments"); + }); + + modelBuilder.Entity("Domain.Entities.Subject", b => + { + b.Navigation("Enrollments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/backend/Adapters/Driven/Persistence/Migrations/20260107212215_InitialCreate.cs b/src/backend/Adapters/Driven/Persistence/Migrations/20260107212215_InitialCreate.cs new file mode 100644 index 0000000..784ad55 --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/Migrations/20260107212215_InitialCreate.cs @@ -0,0 +1,128 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Adapters.Driven.Persistence.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Professors", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Professors", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Students", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Email = table.Column(type: "nvarchar(150)", maxLength: 150, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Students", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Subjects", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Credits = table.Column(type: "int", nullable: false, defaultValue: 3), + ProfessorId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Subjects", x => x.Id); + table.ForeignKey( + name: "FK_Subjects_Professors_ProfessorId", + column: x => x.ProfessorId, + principalTable: "Professors", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Enrollments", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + StudentId = table.Column(type: "int", nullable: false), + SubjectId = table.Column(type: "int", nullable: false), + EnrolledAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Enrollments", x => x.Id); + table.ForeignKey( + name: "FK_Enrollments_Students_StudentId", + column: x => x.StudentId, + principalTable: "Students", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Enrollments_Subjects_SubjectId", + column: x => x.SubjectId, + principalTable: "Subjects", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_Enrollments_StudentId_SubjectId", + table: "Enrollments", + columns: new[] { "StudentId", "SubjectId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Enrollments_SubjectId", + table: "Enrollments", + column: "SubjectId"); + + migrationBuilder.CreateIndex( + name: "IX_Students_Email", + table: "Students", + column: "Email", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Subjects_ProfessorId", + table: "Subjects", + column: "ProfessorId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Enrollments"); + + migrationBuilder.DropTable( + name: "Students"); + + migrationBuilder.DropTable( + name: "Subjects"); + + migrationBuilder.DropTable( + name: "Professors"); + } + } +} diff --git a/src/backend/Adapters/Driven/Persistence/Migrations/20260107212421_SeedData.Designer.cs b/src/backend/Adapters/Driven/Persistence/Migrations/20260107212421_SeedData.Designer.cs new file mode 100644 index 0000000..ffcde33 --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/Migrations/20260107212421_SeedData.Designer.cs @@ -0,0 +1,273 @@ +// +using System; +using Adapters.Driven.Persistence.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Adapters.Driven.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260107212421_SeedData")] + partial class SeedData + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.Enrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EnrolledAt") + .HasColumnType("datetime2"); + + b.Property("StudentId") + .HasColumnType("int"); + + b.Property("SubjectId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubjectId"); + + b.HasIndex("StudentId", "SubjectId") + .IsUnique(); + + b.ToTable("Enrollments", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Professor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.ToTable("Professors", (string)null); + + b.HasData( + new + { + Id = 1, + Name = "Dr. García" + }, + new + { + Id = 2, + Name = "Dra. Martínez" + }, + new + { + Id = 3, + Name = "Dr. López" + }, + new + { + Id = 4, + Name = "Dra. Rodríguez" + }, + new + { + Id = 5, + Name = "Dr. Hernández" + }); + }); + + modelBuilder.Entity("Domain.Entities.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Email") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Students", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Subject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Credits") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ProfessorId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProfessorId"); + + b.ToTable("Subjects", (string)null); + + b.HasData( + new + { + Id = 1, + Credits = 3, + Name = "Matemáticas I", + ProfessorId = 1 + }, + new + { + Id = 2, + Credits = 3, + Name = "Matemáticas II", + ProfessorId = 1 + }, + new + { + Id = 3, + Credits = 3, + Name = "Física I", + ProfessorId = 2 + }, + new + { + Id = 4, + Credits = 3, + Name = "Física II", + ProfessorId = 2 + }, + new + { + Id = 5, + Credits = 3, + Name = "Programación I", + ProfessorId = 3 + }, + new + { + Id = 6, + Credits = 3, + Name = "Programación II", + ProfessorId = 3 + }, + new + { + Id = 7, + Credits = 3, + Name = "Base de Datos I", + ProfessorId = 4 + }, + new + { + Id = 8, + Credits = 3, + Name = "Base de Datos II", + ProfessorId = 4 + }, + new + { + Id = 9, + Credits = 3, + Name = "Redes I", + ProfessorId = 5 + }, + new + { + Id = 10, + Credits = 3, + Name = "Redes II", + ProfessorId = 5 + }); + }); + + modelBuilder.Entity("Domain.Entities.Enrollment", b => + { + b.HasOne("Domain.Entities.Student", "Student") + .WithMany("Enrollments") + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.Subject", "Subject") + .WithMany("Enrollments") + .HasForeignKey("SubjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Student"); + + b.Navigation("Subject"); + }); + + modelBuilder.Entity("Domain.Entities.Subject", b => + { + b.HasOne("Domain.Entities.Professor", "Professor") + .WithMany("Subjects") + .HasForeignKey("ProfessorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Professor"); + }); + + modelBuilder.Entity("Domain.Entities.Professor", b => + { + b.Navigation("Subjects"); + }); + + modelBuilder.Entity("Domain.Entities.Student", b => + { + b.Navigation("Enrollments"); + }); + + modelBuilder.Entity("Domain.Entities.Subject", b => + { + b.Navigation("Enrollments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/backend/Adapters/Driven/Persistence/Migrations/20260107212421_SeedData.cs b/src/backend/Adapters/Driven/Persistence/Migrations/20260107212421_SeedData.cs new file mode 100644 index 0000000..fc645ab --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/Migrations/20260107212421_SeedData.cs @@ -0,0 +1,124 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace Adapters.Driven.Persistence.Migrations +{ + /// + public partial class SeedData : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.InsertData( + table: "Professors", + columns: new[] { "Id", "Name" }, + values: new object[,] + { + { 1, "Dr. García" }, + { 2, "Dra. Martínez" }, + { 3, "Dr. López" }, + { 4, "Dra. Rodríguez" }, + { 5, "Dr. Hernández" } + }); + + migrationBuilder.InsertData( + table: "Subjects", + columns: new[] { "Id", "Credits", "Name", "ProfessorId" }, + values: new object[,] + { + { 1, 3, "Matemáticas I", 1 }, + { 2, 3, "Matemáticas II", 1 }, + { 3, 3, "Física I", 2 }, + { 4, 3, "Física II", 2 }, + { 5, 3, "Programación I", 3 }, + { 6, 3, "Programación II", 3 }, + { 7, 3, "Base de Datos I", 4 }, + { 8, 3, "Base de Datos II", 4 }, + { 9, 3, "Redes I", 5 }, + { 10, 3, "Redes II", 5 } + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DeleteData( + table: "Subjects", + keyColumn: "Id", + keyValue: 1); + + migrationBuilder.DeleteData( + table: "Subjects", + keyColumn: "Id", + keyValue: 2); + + migrationBuilder.DeleteData( + table: "Subjects", + keyColumn: "Id", + keyValue: 3); + + migrationBuilder.DeleteData( + table: "Subjects", + keyColumn: "Id", + keyValue: 4); + + migrationBuilder.DeleteData( + table: "Subjects", + keyColumn: "Id", + keyValue: 5); + + migrationBuilder.DeleteData( + table: "Subjects", + keyColumn: "Id", + keyValue: 6); + + migrationBuilder.DeleteData( + table: "Subjects", + keyColumn: "Id", + keyValue: 7); + + migrationBuilder.DeleteData( + table: "Subjects", + keyColumn: "Id", + keyValue: 8); + + migrationBuilder.DeleteData( + table: "Subjects", + keyColumn: "Id", + keyValue: 9); + + migrationBuilder.DeleteData( + table: "Subjects", + keyColumn: "Id", + keyValue: 10); + + migrationBuilder.DeleteData( + table: "Professors", + keyColumn: "Id", + keyValue: 1); + + migrationBuilder.DeleteData( + table: "Professors", + keyColumn: "Id", + keyValue: 2); + + migrationBuilder.DeleteData( + table: "Professors", + keyColumn: "Id", + keyValue: 3); + + migrationBuilder.DeleteData( + table: "Professors", + keyColumn: "Id", + keyValue: 4); + + migrationBuilder.DeleteData( + table: "Professors", + keyColumn: "Id", + keyValue: 5); + } + } +} diff --git a/src/backend/Adapters/Driven/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/backend/Adapters/Driven/Persistence/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..9a1c90e --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,270 @@ +// +using System; +using Adapters.Driven.Persistence.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Adapters.Driven.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.Enrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EnrolledAt") + .HasColumnType("datetime2"); + + b.Property("StudentId") + .HasColumnType("int"); + + b.Property("SubjectId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubjectId"); + + b.HasIndex("StudentId", "SubjectId") + .IsUnique(); + + b.ToTable("Enrollments", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Professor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.ToTable("Professors", (string)null); + + b.HasData( + new + { + Id = 1, + Name = "Dr. García" + }, + new + { + Id = 2, + Name = "Dra. Martínez" + }, + new + { + Id = 3, + Name = "Dr. López" + }, + new + { + Id = 4, + Name = "Dra. Rodríguez" + }, + new + { + Id = 5, + Name = "Dr. Hernández" + }); + }); + + modelBuilder.Entity("Domain.Entities.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Email") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Students", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Subject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Credits") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ProfessorId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProfessorId"); + + b.ToTable("Subjects", (string)null); + + b.HasData( + new + { + Id = 1, + Credits = 3, + Name = "Matemáticas I", + ProfessorId = 1 + }, + new + { + Id = 2, + Credits = 3, + Name = "Matemáticas II", + ProfessorId = 1 + }, + new + { + Id = 3, + Credits = 3, + Name = "Física I", + ProfessorId = 2 + }, + new + { + Id = 4, + Credits = 3, + Name = "Física II", + ProfessorId = 2 + }, + new + { + Id = 5, + Credits = 3, + Name = "Programación I", + ProfessorId = 3 + }, + new + { + Id = 6, + Credits = 3, + Name = "Programación II", + ProfessorId = 3 + }, + new + { + Id = 7, + Credits = 3, + Name = "Base de Datos I", + ProfessorId = 4 + }, + new + { + Id = 8, + Credits = 3, + Name = "Base de Datos II", + ProfessorId = 4 + }, + new + { + Id = 9, + Credits = 3, + Name = "Redes I", + ProfessorId = 5 + }, + new + { + Id = 10, + Credits = 3, + Name = "Redes II", + ProfessorId = 5 + }); + }); + + modelBuilder.Entity("Domain.Entities.Enrollment", b => + { + b.HasOne("Domain.Entities.Student", "Student") + .WithMany("Enrollments") + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.Subject", "Subject") + .WithMany("Enrollments") + .HasForeignKey("SubjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Student"); + + b.Navigation("Subject"); + }); + + modelBuilder.Entity("Domain.Entities.Subject", b => + { + b.HasOne("Domain.Entities.Professor", "Professor") + .WithMany("Subjects") + .HasForeignKey("ProfessorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Professor"); + }); + + modelBuilder.Entity("Domain.Entities.Professor", b => + { + b.Navigation("Subjects"); + }); + + modelBuilder.Entity("Domain.Entities.Student", b => + { + b.Navigation("Enrollments"); + }); + + modelBuilder.Entity("Domain.Entities.Subject", b => + { + b.Navigation("Enrollments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/backend/Adapters/Driven/Persistence/Repositories/EnrollmentRepository.cs b/src/backend/Adapters/Driven/Persistence/Repositories/EnrollmentRepository.cs new file mode 100644 index 0000000..f0d5c36 --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/Repositories/EnrollmentRepository.cs @@ -0,0 +1,75 @@ +namespace Adapters.Driven.Persistence.Repositories; + +using Adapters.Driven.Persistence.Context; +using Domain.Entities; +using Domain.Ports.Repositories; +using Microsoft.EntityFrameworkCore; + +public class EnrollmentRepository(AppDbContext context) : IEnrollmentRepository +{ + public async Task GetByIdAsync(int id, CancellationToken ct = default) => + await context.Enrollments.FindAsync([id], ct); + + public async Task GetByStudentAndSubjectAsync(int studentId, int subjectId, CancellationToken ct = default) => + await context.Enrollments + .FirstOrDefaultAsync(e => e.StudentId == studentId && e.SubjectId == subjectId, ct); + + public async Task> GetByStudentIdAsync(int studentId, CancellationToken ct = default) => + await context.Enrollments + .Include(e => e.Subject) + .ThenInclude(s => s.Professor) + .Where(e => e.StudentId == studentId) + .AsNoTracking() + .ToListAsync(ct); + + public async Task> GetBySubjectIdAsync(int subjectId, CancellationToken ct = default) => + await context.Enrollments + .Include(e => e.Student) + .Where(e => e.SubjectId == subjectId) + .AsNoTracking() + .ToListAsync(ct); + + public async Task> GetClassmatesAsync(int studentId, int subjectId, CancellationToken ct = default) => + await context.Enrollments + .Where(e => e.SubjectId == subjectId && e.StudentId != studentId) + .Select(e => e.Student) + .AsNoTracking() + .ToListAsync(ct); + + public async Task>> GetClassmatesBatchAsync( + int studentId, IEnumerable subjectIds, CancellationToken ct = default) + { + var subjectIdList = subjectIds.ToList(); + if (subjectIdList.Count == 0) + return new Dictionary>(); + + // Single query to get all classmates for all subjects + var enrollments = await context.Enrollments + .Where(e => subjectIdList.Contains(e.SubjectId) && e.StudentId != studentId) + .Select(e => new { e.SubjectId, e.Student.Id, e.Student.Name }) + .AsNoTracking() + .ToListAsync(ct); + + // Group by SubjectId and project to Student (minimal data needed) + return enrollments + .GroupBy(e => e.SubjectId) + .ToDictionary( + g => g.Key, + g => (IReadOnlyList)g + .Select(e => CreateMinimalStudent(e.Id, e.Name)) + .ToList()); + } + + private static Student CreateMinimalStudent(int id, string name) + { + // Use reflection to set Id since it's private set + var student = (Student)System.Runtime.CompilerServices.RuntimeHelpers + .GetUninitializedObject(typeof(Student)); + typeof(Student).GetProperty("Id")!.SetValue(student, id); + typeof(Student).GetProperty("Name")!.SetValue(student, name); + return student; + } + + public void Add(Enrollment enrollment) => context.Enrollments.Add(enrollment); + public void Delete(Enrollment enrollment) => context.Enrollments.Remove(enrollment); +} diff --git a/src/backend/Adapters/Driven/Persistence/Repositories/ProfessorRepository.cs b/src/backend/Adapters/Driven/Persistence/Repositories/ProfessorRepository.cs new file mode 100644 index 0000000..82d1057 --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/Repositories/ProfessorRepository.cs @@ -0,0 +1,31 @@ +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 ProfessorRepository(AppDbContext context) : IProfessorRepository +{ + public async Task GetByIdAsync(int id, CancellationToken ct = default) => + await context.Professors.FindAsync([id], ct); + + public async Task> GetAllAsync(CancellationToken ct = default) => + await context.Professors.AsNoTracking().ToListAsync(ct); + + public async Task> GetAllWithSubjectsAsync(CancellationToken ct = default) => + await context.Professors + .Include(p => p.Subjects) + .AsNoTracking() + .ToListAsync(ct); + + // Direct projection - only SELECT needed columns + public async Task> GetAllProjectedAsync( + Expression> selector, + CancellationToken ct = default) => + await context.Professors + .AsNoTracking() + .Select(selector) + .ToListAsync(ct); +} diff --git a/src/backend/Adapters/Driven/Persistence/Repositories/StudentRepository.cs b/src/backend/Adapters/Driven/Persistence/Repositories/StudentRepository.cs new file mode 100644 index 0000000..df35836 --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/Repositories/StudentRepository.cs @@ -0,0 +1,121 @@ +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); +} diff --git a/src/backend/Adapters/Driven/Persistence/Repositories/SubjectRepository.cs b/src/backend/Adapters/Driven/Persistence/Repositories/SubjectRepository.cs new file mode 100644 index 0000000..2c7d6ad --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/Repositories/SubjectRepository.cs @@ -0,0 +1,54 @@ +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 SubjectRepository(AppDbContext context) : ISubjectRepository +{ + public async Task GetByIdAsync(int id, CancellationToken ct = default) => + await context.Subjects.FindAsync([id], ct); + + public async Task GetByIdWithProfessorAsync(int id, CancellationToken ct = default) => + await context.Subjects + .Include(s => s.Professor) + .FirstOrDefaultAsync(s => s.Id == id, ct); + + public async Task> GetAllAsync(CancellationToken ct = default) => + await context.Subjects.AsNoTracking().ToListAsync(ct); + + public async Task> GetAllWithProfessorsAsync(CancellationToken ct = default) => + await context.Subjects + .Include(s => s.Professor) + .AsNoTracking() + .ToListAsync(ct); + + public async Task> GetAvailableForStudentAsync(int studentId, CancellationToken ct = default) + { + // Optimized: single query instead of two separate queries + var enrollmentData = await context.Enrollments + .Where(e => e.StudentId == studentId) + .Select(e => new { e.SubjectId, e.Subject.ProfessorId }) + .ToListAsync(ct); + + var enrolledSubjectIds = enrollmentData.Select(e => e.SubjectId).ToHashSet(); + var enrolledProfessorIds = enrollmentData.Select(e => e.ProfessorId).ToHashSet(); + + return await context.Subjects + .Include(s => s.Professor) + .Where(s => !enrolledSubjectIds.Contains(s.Id) && !enrolledProfessorIds.Contains(s.ProfessorId)) + .AsNoTracking() + .ToListAsync(ct); + } + + // Direct projection - only SELECT needed columns + public async Task> GetAllProjectedAsync( + Expression> selector, + CancellationToken ct = default) => + await context.Subjects + .AsNoTracking() + .Select(selector) + .ToListAsync(ct); +} diff --git a/src/backend/Adapters/Driven/Persistence/Repositories/UnitOfWork.cs b/src/backend/Adapters/Driven/Persistence/Repositories/UnitOfWork.cs new file mode 100644 index 0000000..6ca5a8a --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/Repositories/UnitOfWork.cs @@ -0,0 +1,10 @@ +namespace Adapters.Driven.Persistence.Repositories; + +using Adapters.Driven.Persistence.Context; +using Domain.Ports.Repositories; + +public class UnitOfWork(AppDbContext context) : IUnitOfWork +{ + public async Task SaveChangesAsync(CancellationToken ct = default) => + await context.SaveChangesAsync(ct); +} diff --git a/src/backend/Adapters/Driven/Persistence/Seeding/DataSeeder.cs b/src/backend/Adapters/Driven/Persistence/Seeding/DataSeeder.cs new file mode 100644 index 0000000..b7c8f89 --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/Seeding/DataSeeder.cs @@ -0,0 +1,32 @@ +namespace Adapters.Driven.Persistence.Seeding; + +using Microsoft.EntityFrameworkCore; + +public static class DataSeeder +{ + public static void Seed(ModelBuilder modelBuilder) + { + // 5 Profesores + modelBuilder.Entity().HasData( + new { Id = 1, Name = "Dr. García" }, + new { Id = 2, Name = "Dra. Martínez" }, + new { Id = 3, Name = "Dr. López" }, + new { Id = 4, Name = "Dra. Rodríguez" }, + new { Id = 5, Name = "Dr. Hernández" } + ); + + // 10 Materias (2 por profesor, 3 créditos cada una) + modelBuilder.Entity().HasData( + new { Id = 1, Name = "Matemáticas I", Credits = 3, ProfessorId = 1 }, + new { Id = 2, Name = "Matemáticas II", Credits = 3, ProfessorId = 1 }, + new { Id = 3, Name = "Física I", Credits = 3, ProfessorId = 2 }, + new { Id = 4, Name = "Física II", Credits = 3, ProfessorId = 2 }, + new { Id = 5, Name = "Programación I", Credits = 3, ProfessorId = 3 }, + new { Id = 6, Name = "Programación II", Credits = 3, ProfessorId = 3 }, + new { Id = 7, Name = "Base de Datos I", Credits = 3, ProfessorId = 4 }, + new { Id = 8, Name = "Base de Datos II", Credits = 3, ProfessorId = 4 }, + new { Id = 9, Name = "Redes I", Credits = 3, ProfessorId = 5 }, + new { Id = 10, Name = "Redes II", Credits = 3, ProfessorId = 5 } + ); + } +} diff --git a/src/backend/Adapters/Driving/Api/Adapters.Driving.Api.csproj b/src/backend/Adapters/Driving/Api/Adapters.Driving.Api.csproj new file mode 100644 index 0000000..f775099 --- /dev/null +++ b/src/backend/Adapters/Driving/Api/Adapters.Driving.Api.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/src/backend/Adapters/Driving/Api/Extensions/GraphQLExtensions.cs b/src/backend/Adapters/Driving/Api/Extensions/GraphQLExtensions.cs new file mode 100644 index 0000000..0774c7a --- /dev/null +++ b/src/backend/Adapters/Driving/Api/Extensions/GraphQLExtensions.cs @@ -0,0 +1,54 @@ +namespace Adapters.Driving.Api.Extensions; + +using Adapters.Driven.Persistence.DataLoaders; +using Adapters.Driving.Api.Middleware; +using Adapters.Driving.Api.Types; +using HotChocolate.Execution.Options; +using Microsoft.Extensions.DependencyInjection; + +public static class GraphQLExtensions +{ + private const int MaxQueryDepth = 5; + private const int MaxQueryComplexity = 100; + private const int MaxQueryNodes = 50; + + public static IServiceCollection AddGraphQLApi(this IServiceCollection services) + { + services + .AddGraphQLServer() + .AddQueryType() + .AddMutationType() + .AddProjections() + .AddFiltering() + .AddSorting() + .AddErrorFilter() + .AddDataLoader() + .AddDataLoader() + .AddDataLoader() + // Security: Query depth, complexity, and execution limits + .ModifyRequestOptions(ConfigureRequestOptions) + .AddMaxExecutionDepthRule(MaxQueryDepth) + // Cost analysis for query complexity + .ModifyCostOptions(opt => + { + opt.MaxFieldCost = MaxQueryComplexity; + opt.MaxTypeCost = MaxQueryComplexity; + opt.EnforceCostLimits = true; + }) + // Pagination limits to prevent large result sets + .ModifyPagingOptions(opt => + { + opt.MaxPageSize = MaxQueryNodes; + opt.DefaultPageSize = 20; + opt.IncludeTotalCount = true; + }); + + return services; + } + + private static void ConfigureRequestOptions(RequestExecutorOptions opt) + { + opt.IncludeExceptionDetails = false; // Don't expose internal errors in production + opt.ExecutionTimeout = TimeSpan.FromSeconds(30); + } +} diff --git a/src/backend/Adapters/Driving/Api/Middleware/GraphQLErrorFilter.cs b/src/backend/Adapters/Driving/Api/Middleware/GraphQLErrorFilter.cs new file mode 100644 index 0000000..e25b615 --- /dev/null +++ b/src/backend/Adapters/Driving/Api/Middleware/GraphQLErrorFilter.cs @@ -0,0 +1,29 @@ +namespace Adapters.Driving.Api.Middleware; + +using Domain.Exceptions; +using FluentValidation; + +public class GraphQLErrorFilter : IErrorFilter +{ + public IError OnError(IError error) + { + return error.Exception switch + { + DomainException domainException => error + .WithMessage(domainException.Message) + .WithCode(domainException.Code), + + ValidationException validationException => error + .WithMessage("Validation failed") + .WithCode("VALIDATION_ERROR") + .SetExtension("errors", validationException.Errors + .Select(e => new { e.PropertyName, e.ErrorMessage })), + + ArgumentException argException => error + .WithMessage(argException.Message) + .WithCode("INVALID_ARGUMENT"), + + _ => error.WithMessage(error.Exception?.Message ?? error.Message) + }; + } +} diff --git a/src/backend/Adapters/Driving/Api/Types/Enrollments/EnrollmentType.cs b/src/backend/Adapters/Driving/Api/Types/Enrollments/EnrollmentType.cs new file mode 100644 index 0000000..b06b66b --- /dev/null +++ b/src/backend/Adapters/Driving/Api/Types/Enrollments/EnrollmentType.cs @@ -0,0 +1,22 @@ +namespace Adapters.Driving.Api.Types.Enrollments; + +using Adapters.Driven.Persistence.DataLoaders; +using Domain.Entities; + +[ObjectType] +public static partial class EnrollmentType +{ + [GraphQLDescription("Student enrolled")] + public static async Task GetStudent( + [Parent] Enrollment enrollment, + StudentByIdDataLoader dataLoader, + CancellationToken ct) => + await dataLoader.LoadAsync(enrollment.StudentId, ct); + + [GraphQLDescription("Subject enrolled in")] + public static async Task GetSubject( + [Parent] Enrollment enrollment, + SubjectByIdDataLoader dataLoader, + CancellationToken ct) => + await dataLoader.LoadAsync(enrollment.SubjectId, ct); +} diff --git a/src/backend/Adapters/Driving/Api/Types/Mutation.cs b/src/backend/Adapters/Driving/Api/Types/Mutation.cs new file mode 100644 index 0000000..95001e2 --- /dev/null +++ b/src/backend/Adapters/Driving/Api/Types/Mutation.cs @@ -0,0 +1,72 @@ +namespace Adapters.Driving.Api.Types; + +using Application.Enrollments.Commands; +using Application.Students.Commands; +using Application.Students.DTOs; +using MediatR; + +public class Mutation +{ + [GraphQLDescription("Create a new student")] + public async Task CreateStudent( + CreateStudentInput input, + [Service] IMediator mediator, + CancellationToken ct) + { + var result = await mediator.Send(new CreateStudentCommand(input.Name, input.Email), ct); + return new CreateStudentPayload(result); + } + + [GraphQLDescription("Update an existing student")] + public async Task UpdateStudent( + int id, + UpdateStudentInput input, + [Service] IMediator mediator, + CancellationToken ct) + { + var result = await mediator.Send(new UpdateStudentCommand(id, input.Name, input.Email), ct); + return new UpdateStudentPayload(result); + } + + [GraphQLDescription("Delete a student")] + public async Task DeleteStudent( + int id, + [Service] IMediator mediator, + CancellationToken ct) + { + var success = await mediator.Send(new DeleteStudentCommand(id), ct); + return new DeleteStudentPayload(success); + } + + [GraphQLDescription("Enroll a student in a subject")] + public async Task EnrollStudent( + EnrollStudentInput input, + [Service] IMediator mediator, + CancellationToken ct) + { + var result = await mediator.Send(new EnrollStudentCommand(input.StudentId, input.SubjectId), ct); + return new EnrollStudentPayload(result); + } + + [GraphQLDescription("Unenroll a student from a subject")] + public async Task UnenrollStudent( + int enrollmentId, + [Service] IMediator mediator, + CancellationToken ct) + { + var success = await mediator.Send(new UnenrollStudentCommand(enrollmentId), ct); + return new UnenrollStudentPayload(success); + } +} + +// Inputs +public record CreateStudentInput(string Name, string Email); +public record UpdateStudentInput(string Name, string Email); +public record EnrollStudentInput(int StudentId, int SubjectId); + +// Payloads +public record CreateStudentPayload(StudentDto Student); +public record UpdateStudentPayload(StudentDto Student); +public record DeleteStudentPayload(bool Success); +public record EnrollStudentPayload(EnrollmentDto Enrollment); +public record UnenrollStudentPayload(bool Success); diff --git a/src/backend/Adapters/Driving/Api/Types/Professors/ProfessorType.cs b/src/backend/Adapters/Driving/Api/Types/Professors/ProfessorType.cs new file mode 100644 index 0000000..d13a616 --- /dev/null +++ b/src/backend/Adapters/Driving/Api/Types/Professors/ProfessorType.cs @@ -0,0 +1,11 @@ +namespace Adapters.Driving.Api.Types.Professors; + +using Domain.Entities; + +[ObjectType] +public static partial class ProfessorType +{ + [GraphQLDescription("Subjects taught by this professor")] + public static IEnumerable GetSubjects([Parent] Professor professor) => + professor.Subjects; +} diff --git a/src/backend/Adapters/Driving/Api/Types/Query.cs b/src/backend/Adapters/Driving/Api/Types/Query.cs new file mode 100644 index 0000000..e030ee2 --- /dev/null +++ b/src/backend/Adapters/Driving/Api/Types/Query.cs @@ -0,0 +1,61 @@ +namespace Adapters.Driving.Api.Types; + +using Application.Enrollments.DTOs; +using Application.Enrollments.Queries; +using Application.Professors.DTOs; +using Application.Professors.Queries; +using Application.Students.DTOs; +using Application.Students.Queries; +using Application.Subjects.DTOs; +using Application.Subjects.Queries; +using MediatR; + +public class Query +{ + [GraphQLDescription("Get all students")] + public async Task> GetStudents( + [Service] IMediator mediator, + CancellationToken ct) => + await mediator.Send(new GetStudentsQuery(), ct); + + [GraphQLDescription("Get students with cursor-based pagination (keyset)")] + public async Task> GetStudentsPaged( + int? afterCursor, + int pageSize, + [Service] IMediator mediator, + CancellationToken ct) => + await mediator.Send(new GetStudentsPagedQuery(afterCursor, pageSize), ct); + + [GraphQLDescription("Get student by ID")] + public async Task GetStudent( + int id, + [Service] IMediator mediator, + CancellationToken ct) => + await mediator.Send(new GetStudentByIdQuery(id), ct); + + [GraphQLDescription("Get all subjects")] + public async Task> GetSubjects( + [Service] IMediator mediator, + CancellationToken ct) => + await mediator.Send(new GetSubjectsQuery(), ct); + + [GraphQLDescription("Get available subjects for a student")] + public async Task> GetAvailableSubjects( + int studentId, + [Service] IMediator mediator, + CancellationToken ct) => + await mediator.Send(new GetAvailableSubjectsQuery(studentId), ct); + + [GraphQLDescription("Get all professors")] + public async Task> GetProfessors( + [Service] IMediator mediator, + CancellationToken ct) => + await mediator.Send(new GetProfessorsQuery(), ct); + + [GraphQLDescription("Get classmates for a student by subject")] + public async Task> GetClassmates( + int studentId, + [Service] IMediator mediator, + CancellationToken ct) => + await mediator.Send(new GetClassmatesQuery(studentId), ct); +} diff --git a/src/backend/Adapters/Driving/Api/Types/Students/StudentType.cs b/src/backend/Adapters/Driving/Api/Types/Students/StudentType.cs new file mode 100644 index 0000000..f2ee869 --- /dev/null +++ b/src/backend/Adapters/Driving/Api/Types/Students/StudentType.cs @@ -0,0 +1,16 @@ +namespace Adapters.Driving.Api.Types.Students; + +using Domain.Entities; + +[ObjectType] +public static partial class StudentType +{ + public static string GetEmail([Parent] Student student) => student.Email.Value; + + [GraphQLDescription("Total credits enrolled")] + public static int GetTotalCredits([Parent] Student student) => student.TotalCredits; + + [GraphQLDescription("List of enrollments")] + public static IEnumerable GetEnrollments([Parent] Student student) => + student.Enrollments; +} diff --git a/src/backend/Adapters/Driving/Api/Types/Subjects/SubjectType.cs b/src/backend/Adapters/Driving/Api/Types/Subjects/SubjectType.cs new file mode 100644 index 0000000..97ae5f2 --- /dev/null +++ b/src/backend/Adapters/Driving/Api/Types/Subjects/SubjectType.cs @@ -0,0 +1,15 @@ +namespace Adapters.Driving.Api.Types.Subjects; + +using Adapters.Driven.Persistence.DataLoaders; +using Domain.Entities; + +[ObjectType] +public static partial class SubjectType +{ + [GraphQLDescription("Professor teaching this subject")] + public static async Task GetProfessor( + [Parent] Subject subject, + ProfessorByIdDataLoader dataLoader, + CancellationToken ct) => + await dataLoader.LoadAsync(subject.ProfessorId, ct); +}