using System.Reflection; using Cleanuparr.Domain.Exceptions; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; namespace Cleanuparr.Infrastructure.Features.DatabaseMigration; public sealed class ModelDataCopier { private const int BatchSize = 5000; public async Task CopyAsync(DbContext source, DbContext target, IProgress? progress, CancellationToken cancellationToken) { IReadOnlyList ordered = OrderByDependencies(target.Model.GetEntityTypes()); bool autoDetectChanges = target.ChangeTracker.AutoDetectChangesEnabled; target.ChangeTracker.AutoDetectChangesEnabled = false; try { foreach (IEntityType entityType in ordered) { MethodInfo copyMethod = typeof(ModelDataCopier) .GetMethod(nameof(CopyEntitySetAsync), BindingFlags.Instance | BindingFlags.NonPublic)! .MakeGenericMethod(entityType.ClrType); int copied = await (Task)copyMethod.Invoke(this, new object?[] { source, target, progress, cancellationToken })!; progress?.Report($"{entityType.GetTableName()}: {copied} rows"); } } finally { target.ChangeTracker.AutoDetectChangesEnabled = autoDetectChanges; } } public static IReadOnlyList OrderByDependencies(IEnumerable entityTypes) { List types = entityTypes.Where(entityType => !entityType.IsOwned()).ToList(); HashSet visited = new(); HashSet onStack = new(); List ordered = new(); void Visit(IEntityType type) { if (!visited.Add(type)) { return; } onStack.Add(type); foreach (IForeignKey foreignKey in type.GetForeignKeys()) { IEntityType principal = foreignKey.PrincipalEntityType; if (ReferenceEquals(principal, type) || !types.Contains(principal)) { continue; } if (onStack.Contains(principal)) { throw new InvalidOperationException( $"Circular FK dependency between '{type.GetTableName()}' and '{principal.GetTableName()}'; copier cannot order inserts. Break the cycle or add deferred-constraint handling."); } Visit(principal); } onStack.Remove(type); ordered.Add(type); } foreach (IEntityType type in types) { Visit(type); } return ordered; } private async Task CopyEntitySetAsync(DbContext source, DbContext target, IProgress? progress, CancellationToken cancellationToken) where TEntity : class { string? table = target.Model.FindEntityType(typeof(TEntity))?.GetTableName(); int count = 0; IAsyncEnumerable rows = source.Set().AsNoTracking().AsAsyncEnumerable(); try { await foreach (TEntity entity in rows.WithCancellation(cancellationToken)) { target.Set().Add(entity); count++; if (count % BatchSize == 0) { await target.SaveChangesAsync(cancellationToken); target.ChangeTracker.Clear(); progress?.Report($"{table}: {count} rows copied..."); } } if (target.ChangeTracker.Entries().Any()) { await target.SaveChangesAsync(cancellationToken); target.ChangeTracker.Clear(); } } catch (Exception exception) when (exception.GetBaseException() is UnknownEnumValueException) { throw new UnknownEnumValueException( $"Cannot copy {table}: it holds values a newer version of Cleanuparr wrote. " + "Upgrade Cleanuparr to that version (or newer) and re-run the migration.", exception); } return count; } }