diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index cc97a3f..c691985 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -4,6 +4,7 @@ using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.System; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Jiaowu.Api.Infrastructure.Persistence; @@ -90,6 +91,18 @@ public sealed class AppDbContext(DbContextOptions options) Set(); public DbSet AuditLogs => Set(); + protected override void ConfigureConventions( + ModelConfigurationBuilder configurationBuilder) + { + base.ConfigureConventions(configurationBuilder); + + // Connector/NET returns MySQL DATE values as DateTime. An explicit + // provider conversion prevents EF from asking the reader for DateOnly. + configurationBuilder.Properties() + .HaveConversion() + .HaveColumnType("date"); + } + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); @@ -904,3 +917,8 @@ public sealed class AppDbContext(DbContextOptions options) }); } } + +public sealed class DateOnlyDateTimeConverter() + : ValueConverter( + date => date.ToDateTime(TimeOnly.MinValue), + value => DateOnly.FromDateTime(value)); diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DemoDataSeeder.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DemoDataSeeder.cs index f3502d8..a59c781 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DemoDataSeeder.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DemoDataSeeder.cs @@ -8,6 +8,8 @@ public sealed class DemoDataSeeder( AppDbContext db, ILogger logger) { + // MySql.EntityFrameworkCore 10 cannot type-map parameterized primitive + // collections, so membership in definition/ID sets is checked after loading. private const int Grade = 2026; private const int ClassesPerMajor = 2; private const int StudentsPerClass = 35; @@ -176,14 +178,18 @@ public sealed class DemoDataSeeder( .SelectMany(x => x.Majors) .Select(x => x.Code) .ToHashSet(StringComparer.OrdinalIgnoreCase); - var majors = await db.Majors + var majors = (await db.Majors + .OrderBy(x => x.Code) + .ToListAsync(cancellationToken)) .Where(x => targetMajorCodes.Contains(x.Code)) + .ToList(); + var majorIds = majors.Select(x => x.Id).ToHashSet(); + var existingClasses = (await db.AdministrativeClasses + .Where(x => x.Grade == Grade) .OrderBy(x => x.Code) - .ToListAsync(cancellationToken); - var existingClasses = await db.AdministrativeClasses - .Where(x => x.Grade == Grade && targetMajorCodes.Contains(x.Major!.Code)) - .OrderBy(x => x.Code) - .ToListAsync(cancellationToken); + .ToListAsync(cancellationToken)) + .Where(x => majorIds.Contains(x.MajorId)) + .ToList(); var existingCodes = (await db.AdministrativeClasses .Select(x => x.Code) .ToListAsync(cancellationToken)) @@ -218,13 +224,16 @@ public sealed class DemoDataSeeder( var collegeCodes = CollegeDefinitions .Select(x => x.Code) .ToHashSet(StringComparer.OrdinalIgnoreCase); - var colleges = await db.Colleges - .Where(x => collegeCodes.Contains(x.Code)) + var colleges = (await db.Colleges .OrderBy(x => x.Code) - .ToListAsync(cancellationToken); - var existingTeachers = await db.Teachers - .Where(x => colleges.Select(c => c.Id).Contains(x.CollegeId)) - .ToListAsync(cancellationToken); + .ToListAsync(cancellationToken)) + .Where(x => collegeCodes.Contains(x.Code)) + .ToList(); + var collegeIds = colleges.Select(x => x.Id).ToHashSet(); + var existingTeachers = (await db.Teachers + .ToListAsync(cancellationToken)) + .Where(x => collegeIds.Contains(x.CollegeId)) + .ToList(); var existingNumbers = (await db.Teachers .Select(x => x.TeacherNumber) .ToListAsync(cancellationToken)) @@ -264,14 +273,23 @@ public sealed class DemoDataSeeder( .SelectMany(x => x.Majors) .Select(x => x.Code) .ToHashSet(StringComparer.OrdinalIgnoreCase); - var classes = await db.AdministrativeClasses - .Where(x => x.Grade == Grade && targetMajorCodes.Contains(x.Major!.Code)) + var targetMajorIds = (await db.Majors + .Select(x => new { x.Id, x.Code }) + .ToListAsync(cancellationToken)) + .Where(x => targetMajorCodes.Contains(x.Code)) + .Select(x => x.Id) + .ToHashSet(); + var classes = (await db.AdministrativeClasses + .Where(x => x.Grade == Grade) .OrderBy(x => x.Code) - .ToListAsync(cancellationToken); + .ToListAsync(cancellationToken)) + .Where(x => targetMajorIds.Contains(x.MajorId)) + .ToList(); var classIds = classes.Select(x => x.Id).ToHashSet(); - var existingStudents = await db.Students + var existingStudents = (await db.Students + .ToListAsync(cancellationToken)) .Where(x => classIds.Contains(x.AdministrativeClassId)) - .ToListAsync(cancellationToken); + .ToList(); var existingNumbers = (await db.Students .Select(x => x.StudentNumber) .ToListAsync(cancellationToken)) @@ -404,19 +422,23 @@ public sealed class DemoDataSeeder( var collegeCodes = CollegeDefinitions .Select(x => x.Code) .ToHashSet(StringComparer.OrdinalIgnoreCase); - var colleges = await db.Colleges + var colleges = (await db.Colleges + .OrderBy(x => x.Code) + .ToListAsync(cancellationToken)) .Where(x => collegeCodes.Contains(x.Code)) - .OrderBy(x => x.Code) - .ToListAsync(cancellationToken); + .ToList(); var collegeIds = colleges.Select(x => x.Id).ToHashSet(); - var teachers = await db.Teachers - .Where(x => collegeIds.Contains(x.CollegeId) && x.Status == TeacherStatus.Active) + var teachers = (await db.Teachers + .Where(x => x.Status == TeacherStatus.Active) .OrderBy(x => x.TeacherNumber) - .ToListAsync(cancellationToken); - var courses = await db.Courses + .ToListAsync(cancellationToken)) .Where(x => collegeIds.Contains(x.CollegeId)) + .ToList(); + var courses = (await db.Courses .OrderBy(x => x.Code) - .ToListAsync(cancellationToken); + .ToListAsync(cancellationToken)) + .Where(x => collegeIds.Contains(x.CollegeId)) + .ToList(); var publicCourses = courses .Where(x => x.Nature is CourseNature.GeneralRequired or CourseNature.GeneralElective) .ToList(); diff --git a/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs b/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs index 5ec7040..3209050 100644 --- a/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs +++ b/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs @@ -13,10 +13,7 @@ public sealed class MySqlMigrationTests [Fact] public void Production_migration_is_discoverable_and_generates_mysql_sql() { - var options = new DbContextOptionsBuilder() - .UseMySQL( - "Server=localhost;Database=jiaowu;User=__test__;Password=__not_used__;") - .Options; + var options = CreateMySqlOptions(); using var db = new AppDbContext(options); Assert.Contains(LatestMigration, db.Database.GetMigrations()); @@ -33,4 +30,31 @@ public sealed class MySqlMigrationTests Assert.DoesNotContain("0001-01-01", script); Assert.DoesNotContain("0000-00-00", script); } + + [Fact] + public void MySql_date_properties_use_datetime_provider_conversion() + { + using var db = new AppDbContext(CreateMySqlOptions()); + + var dateProperties = db.Model.GetEntityTypes() + .SelectMany(x => x.GetProperties()) + .Where(x => x.ClrType == typeof(DateOnly) || + x.ClrType == typeof(DateOnly?)) + .ToList(); + + Assert.NotEmpty(dateProperties); + Assert.All(dateProperties, property => + { + Assert.Equal("date", property.GetColumnType()); + Assert.Equal( + typeof(DateTime), + property.GetTypeMapping().Converter?.ProviderClrType); + }); + } + + private static DbContextOptions CreateMySqlOptions() => + new DbContextOptionsBuilder() + .UseMySQL( + "Server=localhost;Database=jiaowu;User=__test__;Password=__not_used__;") + .Options; }