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
This commit is contained in:
parent
68e420fdf2
commit
60d35f1040
|
|
@ -0,0 +1,23 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\Domain\Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="*" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="*" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="*">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="GreenDonut" Version="*" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
namespace Adapters.Driven.Persistence;
|
||||||
|
|
||||||
|
using Adapters.Driven.Persistence.Context;
|
||||||
|
using Domain.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pre-compiled EF Core queries for frequently used operations.
|
||||||
|
/// Compiled queries skip expression tree parsing on each execution.
|
||||||
|
/// </summary>
|
||||||
|
public static class CompiledQueries
|
||||||
|
{
|
||||||
|
// Student queries
|
||||||
|
public static readonly Func<AppDbContext, int, Task<Student?>> GetStudentById =
|
||||||
|
EF.CompileAsyncQuery((AppDbContext ctx, int id) =>
|
||||||
|
ctx.Students.FirstOrDefault(s => s.Id == id));
|
||||||
|
|
||||||
|
public static readonly Func<AppDbContext, int, Task<bool>> 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<AppDbContext, int, Task<Subject?>> GetSubjectById =
|
||||||
|
EF.CompileAsyncQuery((AppDbContext ctx, int id) =>
|
||||||
|
ctx.Subjects.FirstOrDefault(s => s.Id == id));
|
||||||
|
|
||||||
|
public static readonly Func<AppDbContext, IAsyncEnumerable<Subject>> GetAllSubjects =
|
||||||
|
EF.CompileAsyncQuery((AppDbContext ctx) =>
|
||||||
|
ctx.Subjects.AsNoTracking());
|
||||||
|
|
||||||
|
// Professor queries
|
||||||
|
public static readonly Func<AppDbContext, int, Task<Professor?>> GetProfessorById =
|
||||||
|
EF.CompileAsyncQuery((AppDbContext ctx, int id) =>
|
||||||
|
ctx.Professors.FirstOrDefault(p => p.Id == id));
|
||||||
|
|
||||||
|
// Enrollment queries
|
||||||
|
public static readonly Func<AppDbContext, int, Task<int>> CountStudentEnrollments =
|
||||||
|
EF.CompileAsyncQuery((AppDbContext ctx, int studentId) =>
|
||||||
|
ctx.Enrollments.Count(e => e.StudentId == studentId));
|
||||||
|
|
||||||
|
public static readonly Func<AppDbContext, int, int, Task<bool>> 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<AppDbContext, Task<int>> CountStudents =
|
||||||
|
EF.CompileAsyncQuery((AppDbContext ctx) =>
|
||||||
|
ctx.Students.Count());
|
||||||
|
|
||||||
|
public static readonly Func<AppDbContext, Task<int>> CountSubjects =
|
||||||
|
EF.CompileAsyncQuery((AppDbContext ctx) =>
|
||||||
|
ctx.Subjects.Count());
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
namespace Adapters.Driven.Persistence.Configurations;
|
||||||
|
|
||||||
|
using Domain.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
public class EnrollmentConfiguration : IEntityTypeConfiguration<Enrollment>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Enrollment> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
namespace Adapters.Driven.Persistence.Configurations;
|
||||||
|
|
||||||
|
using Domain.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
public class ProfessorConfiguration : IEntityTypeConfiguration<Professor>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Professor> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<Student>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Student> 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<Email>(
|
||||||
|
(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
namespace Adapters.Driven.Persistence.Configurations;
|
||||||
|
|
||||||
|
using Domain.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
public class SubjectConfiguration : IEntityTypeConfiguration<Subject>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Subject> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
namespace Adapters.Driven.Persistence.Context;
|
||||||
|
|
||||||
|
using Adapters.Driven.Persistence.Seeding;
|
||||||
|
using Domain.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
|
||||||
|
{
|
||||||
|
public DbSet<Student> Students => Set<Student>();
|
||||||
|
public DbSet<Subject> Subjects => Set<Subject>();
|
||||||
|
public DbSet<Professor> Professors => Set<Professor>();
|
||||||
|
public DbSet<Enrollment> Enrollments => Set<Enrollment>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
|
||||||
|
DataSeeder.Seed(modelBuilder);
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<int, Professor>
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _contextFactory;
|
||||||
|
|
||||||
|
public ProfessorByIdDataLoader(
|
||||||
|
IDbContextFactory<AppDbContext> contextFactory,
|
||||||
|
IBatchScheduler batchScheduler,
|
||||||
|
DataLoaderOptions? options = null)
|
||||||
|
: base(batchScheduler, options ?? new DataLoaderOptions())
|
||||||
|
{
|
||||||
|
_contextFactory = contextFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<IReadOnlyDictionary<int, Professor>> LoadBatchAsync(
|
||||||
|
IReadOnlyList<int> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<int, Student>
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _contextFactory;
|
||||||
|
|
||||||
|
public StudentByIdDataLoader(
|
||||||
|
IDbContextFactory<AppDbContext> contextFactory,
|
||||||
|
IBatchScheduler batchScheduler,
|
||||||
|
DataLoaderOptions? options = null)
|
||||||
|
: base(batchScheduler, options ?? new DataLoaderOptions())
|
||||||
|
{
|
||||||
|
_contextFactory = contextFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<IReadOnlyDictionary<int, Student>> LoadBatchAsync(
|
||||||
|
IReadOnlyList<int> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<int, Subject>
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _contextFactory;
|
||||||
|
|
||||||
|
public SubjectByIdDataLoader(
|
||||||
|
IDbContextFactory<AppDbContext> contextFactory,
|
||||||
|
IBatchScheduler batchScheduler,
|
||||||
|
DataLoaderOptions? options = null)
|
||||||
|
: base(batchScheduler, options ?? new DataLoaderOptions())
|
||||||
|
{
|
||||||
|
_contextFactory = contextFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<IReadOnlyDictionary<int, Subject>> LoadBatchAsync(
|
||||||
|
IReadOnlyList<int> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<AppDbContext>(options =>
|
||||||
|
options.UseSqlServer(
|
||||||
|
configuration.GetConnectionString("DefaultConnection"),
|
||||||
|
b => b.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName)));
|
||||||
|
|
||||||
|
services.AddScoped<IStudentRepository, StudentRepository>();
|
||||||
|
services.AddScoped<ISubjectRepository, SubjectRepository>();
|
||||||
|
services.AddScoped<IProfessorRepository, ProfessorRepository>();
|
||||||
|
services.AddScoped<IEnrollmentRepository, EnrollmentRepository>();
|
||||||
|
services.AddScoped<IUnitOfWork, UnitOfWork>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
174
src/backend/Adapters/Driven/Persistence/Migrations/20260107212215_InitialCreate.Designer.cs
generated
Normal file
174
src/backend/Adapters/Driven/Persistence/Migrations/20260107212215_InitialCreate.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
// <auto-generated />
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("EnrolledAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<int>("StudentId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("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<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Professors", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Domain.Entities.Student", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("nvarchar(150)");
|
||||||
|
|
||||||
|
b.Property<string>("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<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("Credits")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(3);
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<int>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Adapters.Driven.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialCreate : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Professors",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
Name = table.Column<string>(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<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||||
|
Email = table.Column<string>(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<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||||
|
Credits = table.Column<int>(type: "int", nullable: false, defaultValue: 3),
|
||||||
|
ProfessorId = table.Column<int>(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<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
StudentId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
SubjectId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
EnrolledAt = table.Column<DateTime>(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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Enrollments");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Students");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Subjects");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Professors");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
273
src/backend/Adapters/Driven/Persistence/Migrations/20260107212421_SeedData.Designer.cs
generated
Normal file
273
src/backend/Adapters/Driven/Persistence/Migrations/20260107212421_SeedData.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
// <auto-generated />
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("EnrolledAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<int>("StudentId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("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<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("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<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("nvarchar(150)");
|
||||||
|
|
||||||
|
b.Property<string>("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<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("Credits")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(3);
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<int>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||||
|
|
||||||
|
namespace Adapters.Driven.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class SeedData : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,270 @@
|
||||||
|
// <auto-generated />
|
||||||
|
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<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("EnrolledAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<int>("StudentId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("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<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("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<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("nvarchar(150)");
|
||||||
|
|
||||||
|
b.Property<string>("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<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("Credits")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(3);
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<int>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<Enrollment?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||||
|
await context.Enrollments.FindAsync([id], ct);
|
||||||
|
|
||||||
|
public async Task<Enrollment?> GetByStudentAndSubjectAsync(int studentId, int subjectId, CancellationToken ct = default) =>
|
||||||
|
await context.Enrollments
|
||||||
|
.FirstOrDefaultAsync(e => e.StudentId == studentId && e.SubjectId == subjectId, ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Enrollment>> 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<IReadOnlyList<Enrollment>> GetBySubjectIdAsync(int subjectId, CancellationToken ct = default) =>
|
||||||
|
await context.Enrollments
|
||||||
|
.Include(e => e.Student)
|
||||||
|
.Where(e => e.SubjectId == subjectId)
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Student>> 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<IReadOnlyDictionary<int, IReadOnlyList<Student>>> GetClassmatesBatchAsync(
|
||||||
|
int studentId, IEnumerable<int> subjectIds, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var subjectIdList = subjectIds.ToList();
|
||||||
|
if (subjectIdList.Count == 0)
|
||||||
|
return new Dictionary<int, IReadOnlyList<Student>>();
|
||||||
|
|
||||||
|
// 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<Student>)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);
|
||||||
|
}
|
||||||
|
|
@ -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<Professor?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||||
|
await context.Professors.FindAsync([id], ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Professor>> GetAllAsync(CancellationToken ct = default) =>
|
||||||
|
await context.Professors.AsNoTracking().ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Professor>> GetAllWithSubjectsAsync(CancellationToken ct = default) =>
|
||||||
|
await context.Professors
|
||||||
|
.Include(p => p.Subjects)
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// Direct projection - only SELECT needed columns
|
||||||
|
public async Task<IReadOnlyList<TResult>> GetAllProjectedAsync<TResult>(
|
||||||
|
Expression<Func<Professor, TResult>> selector,
|
||||||
|
CancellationToken ct = default) =>
|
||||||
|
await context.Professors
|
||||||
|
.AsNoTracking()
|
||||||
|
.Select(selector)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
@ -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<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 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);
|
||||||
|
}
|
||||||
|
|
@ -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<Subject?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||||
|
await context.Subjects.FindAsync([id], ct);
|
||||||
|
|
||||||
|
public async Task<Subject?> GetByIdWithProfessorAsync(int id, CancellationToken ct = default) =>
|
||||||
|
await context.Subjects
|
||||||
|
.Include(s => s.Professor)
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Subject>> GetAllAsync(CancellationToken ct = default) =>
|
||||||
|
await context.Subjects.AsNoTracking().ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Subject>> GetAllWithProfessorsAsync(CancellationToken ct = default) =>
|
||||||
|
await context.Subjects
|
||||||
|
.Include(s => s.Professor)
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Subject>> 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<IReadOnlyList<TResult>> GetAllProjectedAsync<TResult>(
|
||||||
|
Expression<Func<Subject, TResult>> selector,
|
||||||
|
CancellationToken ct = default) =>
|
||||||
|
await context.Subjects
|
||||||
|
.AsNoTracking()
|
||||||
|
.Select(selector)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
@ -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<int> SaveChangesAsync(CancellationToken ct = default) =>
|
||||||
|
await context.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
@ -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<Domain.Entities.Professor>().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<Domain.Entities.Subject>().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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\Application\Application.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Driven\Persistence\Adapters.Driven.Persistence.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="HotChocolate.AspNetCore" Version="*" />
|
||||||
|
<PackageReference Include="HotChocolate.Data" Version="*" />
|
||||||
|
<PackageReference Include="HotChocolate.Data.EntityFramework" Version="*" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
|
@ -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<Query>()
|
||||||
|
.AddMutationType<Mutation>()
|
||||||
|
.AddProjections()
|
||||||
|
.AddFiltering()
|
||||||
|
.AddSorting()
|
||||||
|
.AddErrorFilter<GraphQLErrorFilter>()
|
||||||
|
.AddDataLoader<StudentByIdDataLoader>()
|
||||||
|
.AddDataLoader<SubjectByIdDataLoader>()
|
||||||
|
.AddDataLoader<ProfessorByIdDataLoader>()
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
namespace Adapters.Driving.Api.Types.Enrollments;
|
||||||
|
|
||||||
|
using Adapters.Driven.Persistence.DataLoaders;
|
||||||
|
using Domain.Entities;
|
||||||
|
|
||||||
|
[ObjectType<Enrollment>]
|
||||||
|
public static partial class EnrollmentType
|
||||||
|
{
|
||||||
|
[GraphQLDescription("Student enrolled")]
|
||||||
|
public static async Task<Student?> GetStudent(
|
||||||
|
[Parent] Enrollment enrollment,
|
||||||
|
StudentByIdDataLoader dataLoader,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
await dataLoader.LoadAsync(enrollment.StudentId, ct);
|
||||||
|
|
||||||
|
[GraphQLDescription("Subject enrolled in")]
|
||||||
|
public static async Task<Subject?> GetSubject(
|
||||||
|
[Parent] Enrollment enrollment,
|
||||||
|
SubjectByIdDataLoader dataLoader,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
await dataLoader.LoadAsync(enrollment.SubjectId, ct);
|
||||||
|
}
|
||||||
|
|
@ -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<CreateStudentPayload> 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<UpdateStudentPayload> 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<DeleteStudentPayload> 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<EnrollStudentPayload> 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<UnenrollStudentPayload> 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);
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
namespace Adapters.Driving.Api.Types.Professors;
|
||||||
|
|
||||||
|
using Domain.Entities;
|
||||||
|
|
||||||
|
[ObjectType<Professor>]
|
||||||
|
public static partial class ProfessorType
|
||||||
|
{
|
||||||
|
[GraphQLDescription("Subjects taught by this professor")]
|
||||||
|
public static IEnumerable<Subject> GetSubjects([Parent] Professor professor) =>
|
||||||
|
professor.Subjects;
|
||||||
|
}
|
||||||
|
|
@ -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<IReadOnlyList<StudentDto>> GetStudents(
|
||||||
|
[Service] IMediator mediator,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
await mediator.Send(new GetStudentsQuery(), ct);
|
||||||
|
|
||||||
|
[GraphQLDescription("Get students with cursor-based pagination (keyset)")]
|
||||||
|
public async Task<PagedResult<StudentPagedDto>> 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<StudentDto> GetStudent(
|
||||||
|
int id,
|
||||||
|
[Service] IMediator mediator,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
await mediator.Send(new GetStudentByIdQuery(id), ct);
|
||||||
|
|
||||||
|
[GraphQLDescription("Get all subjects")]
|
||||||
|
public async Task<IReadOnlyList<SubjectDto>> GetSubjects(
|
||||||
|
[Service] IMediator mediator,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
await mediator.Send(new GetSubjectsQuery(), ct);
|
||||||
|
|
||||||
|
[GraphQLDescription("Get available subjects for a student")]
|
||||||
|
public async Task<IReadOnlyList<AvailableSubjectDto>> GetAvailableSubjects(
|
||||||
|
int studentId,
|
||||||
|
[Service] IMediator mediator,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
await mediator.Send(new GetAvailableSubjectsQuery(studentId), ct);
|
||||||
|
|
||||||
|
[GraphQLDescription("Get all professors")]
|
||||||
|
public async Task<IReadOnlyList<ProfessorDto>> GetProfessors(
|
||||||
|
[Service] IMediator mediator,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
await mediator.Send(new GetProfessorsQuery(), ct);
|
||||||
|
|
||||||
|
[GraphQLDescription("Get classmates for a student by subject")]
|
||||||
|
public async Task<IReadOnlyList<ClassmatesBySubjectDto>> GetClassmates(
|
||||||
|
int studentId,
|
||||||
|
[Service] IMediator mediator,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
await mediator.Send(new GetClassmatesQuery(studentId), ct);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
namespace Adapters.Driving.Api.Types.Students;
|
||||||
|
|
||||||
|
using Domain.Entities;
|
||||||
|
|
||||||
|
[ObjectType<Student>]
|
||||||
|
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<Enrollment> GetEnrollments([Parent] Student student) =>
|
||||||
|
student.Enrollments;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
namespace Adapters.Driving.Api.Types.Subjects;
|
||||||
|
|
||||||
|
using Adapters.Driven.Persistence.DataLoaders;
|
||||||
|
using Domain.Entities;
|
||||||
|
|
||||||
|
[ObjectType<Subject>]
|
||||||
|
public static partial class SubjectType
|
||||||
|
{
|
||||||
|
[GraphQLDescription("Professor teaching this subject")]
|
||||||
|
public static async Task<Professor?> GetProfessor(
|
||||||
|
[Parent] Subject subject,
|
||||||
|
ProfessorByIdDataLoader dataLoader,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
await dataLoader.LoadAsync(subject.ProfessorId, ct);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue