diff --git a/Directory.Build.props b/Directory.Build.props index d4353874..77fb5d29 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 8.10.2 + 8.10.3 13.0 enable enable diff --git a/src/Weasel.EntityFrameworkCore.Tests/Postgresql/TphBaseClassFkDbContext.cs b/src/Weasel.EntityFrameworkCore.Tests/Postgresql/TphBaseClassFkDbContext.cs new file mode 100644 index 00000000..71a61c8b --- /dev/null +++ b/src/Weasel.EntityFrameworkCore.Tests/Postgresql/TphBaseClassFkDbContext.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; + +namespace Weasel.EntityFrameworkCore.Tests.Postgresql; + +public class TphBaseClassFkDbContext : DbContext +{ + public TphBaseClassFkDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet Vehicles => Set(); + public DbSet Trucks => Set(); + public DbSet Sedans => Set(); + public DbSet VehicleOwners => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("vehicles"); + entity.HasKey(e => e.Id); + entity.HasDiscriminator("Discriminator") + .HasValue("Truck") + .HasValue("Sedan"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("vehicle_owners"); + entity.HasKey(e => e.Id); + }); + } +} diff --git a/src/Weasel.EntityFrameworkCore.Tests/Postgresql/tph_base_class_fk_end_to_end.cs b/src/Weasel.EntityFrameworkCore.Tests/Postgresql/tph_base_class_fk_end_to_end.cs new file mode 100644 index 00000000..7ffaad1e --- /dev/null +++ b/src/Weasel.EntityFrameworkCore.Tests/Postgresql/tph_base_class_fk_end_to_end.cs @@ -0,0 +1,83 @@ +using JasperFx; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Weasel.Core; +using Weasel.Postgresql; +using Xunit; + +namespace Weasel.EntityFrameworkCore.Tests.Postgresql; + +/// +/// Reproduces GitHub issue #228: TPH topological sort fails when the base class +/// has a FK dependency, because derived types inherit the same FK causing duplicate +/// edges that inflate in-degree counts in Kahn's algorithm. +/// +public class tph_base_class_fk_end_to_end : IAsyncLifetime +{ + private IHost _host = null!; + + public async Task InitializeAsync() + { + _host = Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + services.AddDbContext(options => + options.UseNpgsql(PostgresqlDbContext.ConnectionString)); + + services.AddSingleton(); + }) + .Build(); + + await _host.StartAsync(); + } + + public async Task DisposeAsync() + { + await _host.StopAsync(); + _host.Dispose(); + } + + [Fact] + public void entity_types_sorted_correctly_when_base_class_has_fk() + { + using var scope = _host.Services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var entityTypes = DbContextExtensions.GetEntityTypesForMigration(context); + var names = entityTypes.Select(e => e.GetTableName()).ToList(); + + // vehicle_owners must come before vehicles because vehicles has a FK to vehicle_owners + var ownerIndex = names.IndexOf("vehicle_owners"); + var vehicleIndex = names.IndexOf("vehicles"); + + ownerIndex.ShouldBeGreaterThanOrEqualTo(0, "vehicle_owners should be in the list"); + vehicleIndex.ShouldBeGreaterThanOrEqualTo(0, "vehicles should be in the list"); + ownerIndex.ShouldBeLessThan(vehicleIndex, + "vehicle_owners should come before vehicles due to FK dependency"); + } + + [Fact] + public async Task can_apply_migration_when_base_class_has_fk() + { + using var scope = _host.Services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + // Clean up any previous test state + await context.Database.ExecuteSqlRawAsync("DROP TABLE IF EXISTS vehicles"); + await context.Database.ExecuteSqlRawAsync("DROP TABLE IF EXISTS vehicle_owners"); + + var migration = await _host.Services.CreateMigrationAsync(context, CancellationToken.None); + migration.ShouldNotBeNull(); + migration.Migration.Difference.ShouldNotBe(SchemaPatchDifference.None); + + // This would fail before the fix because the topological sort returned + // unsorted order, potentially creating vehicles before vehicle_owners + await migration.ExecuteAsync(AutoCreate.CreateOrUpdate, CancellationToken.None); + + // Clean up + await context.Database.ExecuteSqlRawAsync("DROP TABLE IF EXISTS vehicles"); + await context.Database.ExecuteSqlRawAsync("DROP TABLE IF EXISTS vehicle_owners"); + } +} diff --git a/src/Weasel.EntityFrameworkCore.Tests/TphBaseClassFkEntities.cs b/src/Weasel.EntityFrameworkCore.Tests/TphBaseClassFkEntities.cs new file mode 100644 index 00000000..27291acf --- /dev/null +++ b/src/Weasel.EntityFrameworkCore.Tests/TphBaseClassFkEntities.cs @@ -0,0 +1,31 @@ +namespace Weasel.EntityFrameworkCore.Tests; + +// TPH hierarchy where the base class has a FK to another entity. +// This reproduces GitHub issue #228: the FK on Animal is inherited by +// Cat and Dog, causing duplicate dependency edges in the topological sort +// which breaks Kahn's algorithm (in-degree inflated but only decremented once). + +public abstract class Vehicle +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Discriminator { get; set; } = string.Empty; + public VehicleOwner Owner { get; set; } = default!; +} + +public class Truck : Vehicle +{ + public double PayloadCapacity { get; set; } +} + +public class Sedan : Vehicle +{ + public int PassengerCount { get; set; } +} + +public class VehicleOwner +{ + public Guid Id { get; set; } + public string OwnerName { get; set; } = string.Empty; + public List Vehicles { get; set; } = []; +} diff --git a/src/Weasel.EntityFrameworkCore/DbContextExtensions.cs b/src/Weasel.EntityFrameworkCore/DbContextExtensions.cs index 9d33fb3d..31f5ac42 100644 --- a/src/Weasel.EntityFrameworkCore/DbContextExtensions.cs +++ b/src/Weasel.EntityFrameworkCore/DbContextExtensions.cs @@ -235,10 +235,11 @@ internal static IReadOnlyList TopologicalSortByForeignKeys(List>(); + // Use HashSet to avoid duplicate edges from TPH derived types inheriting the same FK + var dependencies = new Dictionary>(); foreach (var et in entityTypes) { - var deps = new List(); + var deps = new HashSet(); foreach (var fk in et.GetForeignKeys()) { var principalKey = (fk.PrincipalEntityType.GetTableName(), fk.PrincipalEntityType.GetSchema());