From 3cf03378eee6252ae3f3171ca005c09ad2ac0be2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A7=D1=83=D0=BF=D1=80=D0=B8=D0=BD=20=D0=9B=D0=B5=D0=B2?= Date: Tue, 25 Nov 2025 17:06:08 +0300 Subject: [PATCH 1/2] changed db schema --- .../IncludeGraph/GraphDbContext.cs | 4 ++-- .../IncludeGraph/IncludeGraphTests.cs | 19 ++++++++++----- .../IncludeGraph/Model/WorkOrder.cs | 23 ++++++++++--------- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/EFCore.BulkExtensions.Tests/IncludeGraph/GraphDbContext.cs b/EFCore.BulkExtensions.Tests/IncludeGraph/GraphDbContext.cs index 89953f2b..e728db85 100644 --- a/EFCore.BulkExtensions.Tests/IncludeGraph/GraphDbContext.cs +++ b/EFCore.BulkExtensions.Tests/IncludeGraph/GraphDbContext.cs @@ -25,7 +25,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) cfg.HasKey(y => y.Id); cfg.HasOne(y => y.ParentAsset).WithMany(y => y.ChildAssets); - cfg.HasMany(y => y.WorkOrders).WithOne(y => y.Asset).IsRequired(); + cfg.HasMany(y => y.WorkOrders).WithOne(y => y.Asset); }); modelBuilder.Entity(cfg => @@ -33,7 +33,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) cfg.HasKey(y => y.Id); cfg.HasMany(y => y.WorkOrderSpares).WithOne(y => y.WorkOrder); - cfg.HasOne(y => y.Asset).WithMany(y => y.WorkOrders).IsRequired(); + cfg.HasOne(y => y.Asset).WithMany(y => y.WorkOrders); }); modelBuilder.Entity(cfg => diff --git a/EFCore.BulkExtensions.Tests/IncludeGraph/IncludeGraphTests.cs b/EFCore.BulkExtensions.Tests/IncludeGraph/IncludeGraphTests.cs index d4cefb2b..d1e93bc1 100644 --- a/EFCore.BulkExtensions.Tests/IncludeGraph/IncludeGraphTests.cs +++ b/EFCore.BulkExtensions.Tests/IncludeGraph/IncludeGraphTests.cs @@ -1,18 +1,21 @@ using EFCore.BulkExtensions.SqlAdapters; using EFCore.BulkExtensions.Tests.IncludeGraph.Model; using EFCore.BulkExtensions.Tests.ShadowProperties; + using Microsoft.EntityFrameworkCore; + using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; + using Xunit; namespace EFCore.BulkExtensions.Tests.IncludeGraph; public class IncludeGraphTests : IDisposable { - private readonly static WorkOrder WorkOrder1 = new () + private readonly static WorkOrder WorkOrder1 = new() { Description = "Fix belt", Asset = new Asset @@ -45,7 +48,7 @@ public class IncludeGraphTests : IDisposable } }; - private static readonly WorkOrder WorkOrder2 = new () + private static readonly WorkOrder WorkOrder2 = new() { Description = "Fix toilets", Asset = new Asset @@ -78,6 +81,7 @@ public class IncludeGraphTests : IDisposable } }; + [Theory] [InlineData(SqlType.SqlServer)] //[InlineData(DbServer.Sqlite)] @@ -100,11 +104,14 @@ public async Task BulkInsertOrUpdate_EntityWithNestedObjectGraph_SavesGraphToDat wos.WorkOrder = WorkOrder2; } - WorkOrder1.Asset.WorkOrders.Add(WorkOrder1); - WorkOrder2.Asset.WorkOrders.Add(WorkOrder2); + if (WorkOrder1.Asset != null && WorkOrder2.Asset != null) + { + WorkOrder1.Asset.WorkOrders.Add(WorkOrder1); + WorkOrder2.Asset.WorkOrders.Add(WorkOrder2); - WorkOrder1.Asset.ParentAsset = WorkOrder2.Asset; - WorkOrder2.Asset.ChildAssets.Add(WorkOrder1.Asset); + WorkOrder1.Asset.ParentAsset = WorkOrder2.Asset; + WorkOrder2.Asset.ChildAssets.Add(WorkOrder1.Asset); + } var testData = GetTestData().ToList(); var bulkConfig = new BulkConfig diff --git a/EFCore.BulkExtensions.Tests/IncludeGraph/Model/WorkOrder.cs b/EFCore.BulkExtensions.Tests/IncludeGraph/Model/WorkOrder.cs index faa8bb37..6c4927b2 100644 --- a/EFCore.BulkExtensions.Tests/IncludeGraph/Model/WorkOrder.cs +++ b/EFCore.BulkExtensions.Tests/IncludeGraph/Model/WorkOrder.cs @@ -1,12 +1,13 @@ -using System.Collections.Generic; - -namespace EFCore.BulkExtensions.Tests.IncludeGraph.Model; +using System.Collections.Generic; -public class WorkOrder -{ - public int Id { get; set; } - public string Description { get; set; } = null!; - - public Asset Asset { get; set; } = null!; - public ICollection WorkOrderSpares { get; set; } = new HashSet(); -} +namespace EFCore.BulkExtensions.Tests.IncludeGraph.Model; + +public class WorkOrder +{ + public int Id { get; set; } + public string Description { get; set; } = null!; + + public int? AssetId { get; set; } + public Asset? Asset { get; set; } + public ICollection WorkOrderSpares { get; set; } = new HashSet(); +} From 54001318169e7ea8256709412f41d09bd015fb83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A7=D1=83=D0=BF=D1=80=D0=B8=D0=BD=20=D0=9B=D0=B5=D0=B2?= Date: Tue, 25 Nov 2025 17:06:57 +0300 Subject: [PATCH 2/2] additional graph sorting by depth and tests --- .../DbContextBulkTransactionGraphUtil.cs | 2 +- EFCore.BulkExtensions.Core/Util/GraphUtil.cs | 86 +++++++++++++- .../IncludeGraph/IncludeGraphTests.cs | 111 ++++++++++++++++++ 3 files changed, 197 insertions(+), 2 deletions(-) diff --git a/EFCore.BulkExtensions.Core/DbContextBulkTransactionGraphUtil.cs b/EFCore.BulkExtensions.Core/DbContextBulkTransactionGraphUtil.cs index 7bace99f..7e2a3b07 100644 --- a/EFCore.BulkExtensions.Core/DbContextBulkTransactionGraphUtil.cs +++ b/EFCore.BulkExtensions.Core/DbContextBulkTransactionGraphUtil.cs @@ -40,7 +40,7 @@ private static async Task ExecuteWithGraphAsync(DbContext context, IEnumerable? GetTopologicallySortedGraph(DbContext dbContext, IEnumerable entities) + public static IEnumerable? GetSortedGraph(DbContext dbContext, IEnumerable entities) { if (!entities.Any()) { @@ -26,6 +27,16 @@ internal class GraphUtil // Sort these entities so the first entity is the least dependendant var topologicalSorted = TopologicalSort(dependencies.Keys, y => dependencies[y].DependsOn.Select(y => y.entity)); + + var withAdditionalSorting = true; + + if (withAdditionalSorting) return GetNodesWithSortingByDepth(dependencies, topologicalSorted); + + return GetNodes(dependencies, topologicalSorted); + } + + private static IEnumerable GetNodes(Dictionary dependencies, IEnumerable topologicalSorted) + { var result = new List(); foreach (var s in topologicalSorted) @@ -40,6 +51,79 @@ internal class GraphUtil return result; } + private static IEnumerable GetNodesWithSortingByDepth(Dictionary dependencies, IEnumerable topologicalSorted) + { + var entitiesDepth = CalculateEntitiesDepth(dependencies, topologicalSorted.ToList()); + + var typesDepth = CalculateTypesDepth(entitiesDepth); + + var result = new List(); + + foreach (var type in typesDepth.OrderByDescending(x => x.Value)) + { + foreach (var s in topologicalSorted) + { + if (s.GetType() != type.Key) continue; + + result.Add(new GraphNode + { + Entity = s, + Dependencies = dependencies[s] + }); + } + } + + return result; + } + + /// + /// Calculates the depth of each entity from the entity to the top of the dependency graph. Returns a dictionary mapping entities to their depth. + /// + private static Dictionary CalculateEntitiesDepth( + Dictionary graph, + IReadOnlyList topologicalSorted) + { + var depthDict = graph.Keys.ToDictionary(k => k, v => 0); + + for (int i = graph.Count - 1; i >= 0; i--) + { + var node = topologicalSorted[i]; + + var nodeDeps = graph + .Where(x => x.Value.DependsOn.Any(d => d.entity == node)) + .Select(x => x.Key) + .ToList(); + + int max = 0; + foreach (var childNode in nodeDeps) + max = Math.Max(max, depthDict[childNode] + 1); + + depthDict[node] = max; + } + + return depthDict; + } + + /// + /// Calculates the maximum depth from entity of a given type to the top. Returns a dictionary mapping types to their maximum depth. + /// + private static Dictionary CalculateTypesDepth(Dictionary entitiesDepth) + { + var typesDepth = new Dictionary(); + + foreach (var entityDepth in entitiesDepth) + { + var type = entityDepth.Key.GetType(); + + if (typesDepth.TryGetValue(type, out int value)) + typesDepth[type] = Math.Max(value, entityDepth.Value); + else + typesDepth.Add(type, entityDepth.Value); + } + + return typesDepth; + } + private static GraphDependency? GetFlatGraph(DbContext dbContext, object graphEntity, IDictionary result) { var entityType = dbContext.Model.FindEntityType(graphEntity.GetType()); diff --git a/EFCore.BulkExtensions.Tests/IncludeGraph/IncludeGraphTests.cs b/EFCore.BulkExtensions.Tests/IncludeGraph/IncludeGraphTests.cs index d1e93bc1..719ec1fa 100644 --- a/EFCore.BulkExtensions.Tests/IncludeGraph/IncludeGraphTests.cs +++ b/EFCore.BulkExtensions.Tests/IncludeGraph/IncludeGraphTests.cs @@ -143,6 +143,117 @@ private static IEnumerable GetTestData() yield return WorkOrder2; } + [Theory] + [MemberData(nameof(GetTestData2))] + public async Task BulkInsertOrUpdate_EntityWithNestedNullableObjectGraph_SavesGraphToDatabase1(IEnumerable orders) + { + ContextUtil.DatabaseType = SqlType.SqlServer; + + using (var db = new GraphDbContext(ContextUtil.GetOptions(databaseName: $"{nameof(EFCoreBulkTest)}_Graph"))) + { + db.Database.EnsureDeleted(); + await db.Database.EnsureCreatedAsync(); + + var bulkConfig = new BulkConfig + { + IncludeGraph = true, + CalculateStats = true, + }; + await db.BulkInsertOrUpdateAsync(orders, bulkConfig); + + Assert.NotNull(bulkConfig.StatsInfo); + Assert.Equal( + orders.Count() + orders.Where(x => x.Asset != null).Count(), + bulkConfig.StatsInfo.StatsNumberInserted); + } + + using (var db = new GraphDbContext(ContextUtil.GetOptions(databaseName: $"{nameof(EFCoreBulkTest)}_Graph"))) + { + var ordersFromDb = db.WorkOrders + .Include(y => y.Asset) + .OrderBy(x => x.Id) + .ToList(); + + foreach (var orderFromDb in ordersFromDb) + { + var order = orders.First(x => x.Description == orderFromDb.Description); + + if (order.Asset == null) + Assert.Null(orderFromDb.Asset); + else + Assert.NotNull(orderFromDb.Asset); + } + } + } + + public static IEnumerable GetTestData2() + => + [ + [GetTestDataWithNullInEnd()], + [GetTestDataWithNullInMiddle()], + [GetTestDataWithNullInFirst()], + ]; + + private static IEnumerable GetTestDataWithNullInEnd() + { + var baseData = GetBaseTestData(); + + baseData.Last().Asset = null; + + return baseData; + } + + private static IEnumerable GetTestDataWithNullInMiddle() + { + var baseData = GetBaseTestData(); + + baseData[1].Asset = null; + + return baseData; + } + + private static IEnumerable GetTestDataWithNullInFirst() + { + var baseData = GetBaseTestData(); + + baseData.First().Asset = null; + + return baseData; + } + + private static List GetBaseTestData() + { + return + [ + new WorkOrder() + { + Description = "Fix belt", + Asset = new Asset + { + Description = "MANU-1", + Location = "WAREHOUSE-1" + }, + }, + new WorkOrder() + { + Description = "Fix toilets", + Asset = new Asset + { + Description = "FLUSHMASTER-1", + Location = "GYM-BLOCK-3" + }, + }, + new WorkOrder() + { + Description = "Fix door", + Asset = new Asset + { + Location = "OFFICE-12" + }, + } + ]; + } + public void Dispose() { using var db = new GraphDbContext(ContextUtil.GetOptions(databaseName: $"{nameof(EFCoreBulkTest)}_Graph"));