Skip to content

Commit bd02802

Browse files
committed
Prevent dependents from being deleted when principal is detached
Fixes #12590 Fixes #18982 Regression test for #16546
1 parent 2aefa91 commit bd02802

File tree

5 files changed

+405
-36
lines changed

5 files changed

+405
-36
lines changed

src/EFCore/ChangeTracking/Internal/StateManager.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -959,6 +959,7 @@ public virtual void CascadeChanges(bool force)
959959
public virtual void CascadeDelete(InternalEntityEntry entry, bool force, IEnumerable<IForeignKey> foreignKeys = null)
960960
{
961961
var doCascadeDelete = force || CascadeDeleteTiming != CascadeTiming.Never;
962+
var principalIsDetached = entry.EntityState == EntityState.Detached;
962963

963964
foreignKeys ??= entry.EntityType.GetReferencingForeignKeys();
964965
foreach (var fk in foreignKeys)
@@ -980,9 +981,10 @@ public virtual void CascadeDelete(InternalEntityEntry entry, bool force, IEnumer
980981
|| fk.DeleteBehavior == DeleteBehavior.ClientCascade)
981982
&& doCascadeDelete)
982983
{
983-
var cascadeState = dependent.EntityState == EntityState.Added
984-
? EntityState.Detached
985-
: EntityState.Deleted;
984+
var cascadeState = principalIsDetached
985+
|| dependent.EntityState == EntityState.Added
986+
? EntityState.Detached
987+
: EntityState.Deleted;
986988

987989
if (SensitiveLoggingEnabled)
988990
{

test/EFCore.Specification.Tests/PropertyValuesTestBase.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1257,7 +1257,8 @@ public async Task Reload_when_entity_deleted_in_store_can_happen_for_any_state(E
12571257
else
12581258
{
12591259
Assert.Equal(EntityState.Detached, entry.State);
1260-
Assert.Null(mailRoom.Building);
1260+
Assert.Same(mailRoom, building.PrincipalMailRoom);
1261+
Assert.Contains(office, building.Offices);
12611262

12621263
Assert.Equal(EntityState.Detached, context.Entry(office.Building).State);
12631264
Assert.Same(building, office.Building);

test/EFCore.Specification.Tests/ProxyGraphUpdatesTestBase.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -681,7 +681,7 @@ public virtual void Save_required_one_to_one_changed_by_reference(ChangeMechanis
681681
Assert.Same(new1, new2.Back);
682682

683683
Assert.NotNull(old1.Root);
684-
Assert.Null(old2.Back);
684+
Assert.Same(old1, old2.Back);
685685
Assert.Equal(old1.Id, old2.Id);
686686
});
687687
}

test/EFCore.Tests/ChangeTracking/ChangeTrackerTest.cs

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2085,6 +2085,323 @@ public void Can_add_owned_dependent_with_reference_to_parent(bool useAdd, bool s
20852085
Assert.Equal(1, dependentEntry2b.Property(dependentEntry2b.Metadata.FindPrimaryKey().Properties[0].Name).CurrentValue);
20862086
}
20872087

2088+
[ConditionalTheory] // Issue #17828
2089+
[InlineData(false)]
2090+
[InlineData(true)]
2091+
public void DetectChanges_reparents_even_when_immediate_cascade_enabled(bool delayCascade)
2092+
{
2093+
using var context = new EarlyLearningCenter();
2094+
2095+
// Construct initial state
2096+
var parent1 = new Category { Id = 1 };
2097+
var parent2 = new Category { Id = 2 };
2098+
var child = new Product { Id = 3, Category = parent1 };
2099+
2100+
context.AddRange(parent1, parent2, child);
2101+
context.ChangeTracker.AcceptAllChanges();
2102+
2103+
Assert.Equal(3, context.ChangeTracker.Entries().Count());
2104+
Assert.Equal(EntityState.Unchanged, context.Entry(parent1).State);
2105+
Assert.Equal(EntityState.Unchanged, context.Entry(parent2).State);
2106+
Assert.Equal(EntityState.Unchanged, context.Entry(child).State);
2107+
2108+
if (delayCascade)
2109+
{
2110+
context.ChangeTracker.CascadeDeleteTiming = CascadeTiming.OnSaveChanges;
2111+
}
2112+
2113+
child.Category = parent2;
2114+
2115+
context.ChangeTracker.DetectChanges();
2116+
2117+
context.Remove(parent1);
2118+
2119+
Assert.Equal(3, context.ChangeTracker.Entries().Count());
2120+
Assert.Equal(EntityState.Deleted, context.Entry(parent1).State);
2121+
Assert.Equal(EntityState.Unchanged, context.Entry(parent2).State);
2122+
Assert.Equal(EntityState.Modified, context.Entry(child).State);
2123+
}
2124+
2125+
[ConditionalTheory] // Issue #12590
2126+
[InlineData(false, false)]
2127+
[InlineData(false, true)]
2128+
[InlineData(true, false)]
2129+
[InlineData(true, true)]
2130+
public void Dependents_are_detached_not_deleted_when_principal_is_detached(bool delayCascade, bool trackNewDependents)
2131+
{
2132+
using var context = new EarlyLearningCenter();
2133+
2134+
var category = new Category
2135+
{
2136+
Id = 1,
2137+
Products = new List<Product>
2138+
{
2139+
new Product { Id = 1 },
2140+
new Product { Id = 2 },
2141+
new Product { Id = 3 }
2142+
}
2143+
};
2144+
2145+
context.Attach(category);
2146+
2147+
var categoryEntry = context.Entry(category);
2148+
var product0Entry = context.Entry(category.Products[0]);
2149+
var product1Entry = context.Entry(category.Products[1]);
2150+
var product2Entry = context.Entry(category.Products[2]);
2151+
2152+
Assert.Equal(EntityState.Unchanged, categoryEntry.State);
2153+
Assert.Equal(EntityState.Unchanged, product0Entry.State);
2154+
Assert.Equal(EntityState.Unchanged, product1Entry.State);
2155+
Assert.Equal(EntityState.Unchanged, product2Entry.State);
2156+
2157+
if (delayCascade)
2158+
{
2159+
context.ChangeTracker.CascadeDeleteTiming = CascadeTiming.OnSaveChanges;
2160+
}
2161+
2162+
context.Entry(category).State = EntityState.Detached;
2163+
2164+
Assert.Equal(EntityState.Detached, categoryEntry.State);
2165+
2166+
if (delayCascade)
2167+
{
2168+
Assert.Equal(EntityState.Unchanged, product0Entry.State);
2169+
Assert.Equal(EntityState.Unchanged, product1Entry.State);
2170+
Assert.Equal(EntityState.Unchanged, product2Entry.State);
2171+
}
2172+
else
2173+
{
2174+
Assert.Equal(EntityState.Detached, product0Entry.State);
2175+
Assert.Equal(EntityState.Detached, product1Entry.State);
2176+
Assert.Equal(EntityState.Detached, product2Entry.State);
2177+
}
2178+
2179+
var newCategory = new Category { Id = 1, };
2180+
2181+
if (trackNewDependents)
2182+
{
2183+
newCategory.Products = new List<Product>
2184+
{
2185+
new Product { Id = 1 },
2186+
new Product { Id = 2 },
2187+
new Product { Id = 3 }
2188+
};
2189+
}
2190+
2191+
var traversal = new List<string>();
2192+
2193+
if (delayCascade && trackNewDependents)
2194+
{
2195+
Assert.Equal(
2196+
CoreStrings.IdentityConflict(nameof(Product), "{'Id'}"),
2197+
Assert.Throws<InvalidOperationException>(TrackGraph).Message);
2198+
}
2199+
else
2200+
{
2201+
TrackGraph();
2202+
2203+
Assert.Equal(
2204+
trackNewDependents
2205+
? new List<string>
2206+
{
2207+
"<None> -----> Category:1",
2208+
"Category:1 ---Products--> Product:1",
2209+
"Category:1 ---Products--> Product:2",
2210+
"Category:1 ---Products--> Product:3"
2211+
}
2212+
: new List<string>
2213+
{
2214+
"<None> -----> Category:1"
2215+
},
2216+
traversal);
2217+
2218+
if (trackNewDependents || delayCascade)
2219+
{
2220+
Assert.Equal(4, context.ChangeTracker.Entries().Count());
2221+
2222+
categoryEntry = context.Entry(newCategory);
2223+
product0Entry = context.Entry(newCategory.Products[0]);
2224+
product1Entry = context.Entry(newCategory.Products[1]);
2225+
product2Entry = context.Entry(newCategory.Products[2]);
2226+
2227+
Assert.Equal(EntityState.Modified, categoryEntry.State);
2228+
2229+
if (trackNewDependents)
2230+
{
2231+
Assert.Equal(EntityState.Modified, product0Entry.State);
2232+
Assert.Equal(EntityState.Modified, product1Entry.State);
2233+
Assert.Equal(EntityState.Modified, product2Entry.State);
2234+
2235+
Assert.NotSame(newCategory.Products[0], category.Products[0]);
2236+
Assert.NotSame(newCategory.Products[1], category.Products[1]);
2237+
Assert.NotSame(newCategory.Products[2], category.Products[2]);
2238+
}
2239+
else
2240+
{
2241+
Assert.Equal(EntityState.Unchanged, product0Entry.State);
2242+
Assert.Equal(EntityState.Unchanged, product1Entry.State);
2243+
Assert.Equal(EntityState.Unchanged, product2Entry.State);
2244+
2245+
Assert.Same(newCategory.Products[0], category.Products[0]);
2246+
Assert.Same(newCategory.Products[1], category.Products[1]);
2247+
Assert.Same(newCategory.Products[2], category.Products[2]);
2248+
}
2249+
2250+
Assert.Same(newCategory, newCategory.Products[0].Category);
2251+
Assert.Same(newCategory, newCategory.Products[1].Category);
2252+
Assert.Same(newCategory, newCategory.Products[2].Category);
2253+
2254+
Assert.Equal(newCategory.Id, product0Entry.Property("CategoryId").CurrentValue);
2255+
Assert.Equal(newCategory.Id, product1Entry.Property("CategoryId").CurrentValue);
2256+
Assert.Equal(newCategory.Id, product2Entry.Property("CategoryId").CurrentValue);
2257+
}
2258+
else
2259+
{
2260+
Assert.Single(context.ChangeTracker.Entries());
2261+
2262+
categoryEntry = context.Entry(newCategory);
2263+
2264+
Assert.Equal(EntityState.Modified, categoryEntry.State);
2265+
Assert.Null(newCategory.Products);
2266+
}
2267+
}
2268+
2269+
void TrackGraph()
2270+
{
2271+
context.ChangeTracker.TrackGraph(
2272+
newCategory, n =>
2273+
{
2274+
n.Entry.State = EntityState.Modified;
2275+
traversal.Add(NodeString(n));
2276+
});
2277+
}
2278+
}
2279+
2280+
[ConditionalTheory] // Issue #16546
2281+
[InlineData(false)]
2282+
[InlineData(true)]
2283+
public void Optional_relationship_with_cascade_still_cascades(bool delayCascade)
2284+
{
2285+
Kontainer detachedContainer;
2286+
var databaseName = "K" + delayCascade;
2287+
using (var context = new KontainerContext(databaseName))
2288+
{
2289+
context.Add(
2290+
new Kontainer
2291+
{
2292+
Name = "C1",
2293+
Rooms = { new KontainerRoom { Number = 1, Troduct = new Troduct { Description = "Heavy Engine XT3" } } }
2294+
}
2295+
);
2296+
2297+
context.SaveChanges();
2298+
2299+
detachedContainer = context.Set<Kontainer>()
2300+
.Include(container => container.Rooms)
2301+
.ThenInclude(room => room.Troduct)
2302+
.AsNoTracking()
2303+
.Single();
2304+
}
2305+
2306+
using (var context = new KontainerContext(databaseName))
2307+
{
2308+
var attachedContainer = context.Set<Kontainer>()
2309+
.Include(container => container.Rooms)
2310+
.ThenInclude(room => room.Troduct)
2311+
.Single();
2312+
2313+
Assert.Equal(3, context.ChangeTracker.Entries().Count());
2314+
Assert.Equal(EntityState.Unchanged, context.Entry(attachedContainer).State);
2315+
Assert.Equal(EntityState.Unchanged, context.Entry(attachedContainer.Rooms.Single()).State);
2316+
Assert.Equal(EntityState.Unchanged, context.Entry(attachedContainer.Rooms.Single().Troduct).State);
2317+
2318+
var detachedRoom = detachedContainer.Rooms.Single();
2319+
detachedRoom.Troduct = null;
2320+
detachedRoom.TroductId = null;
2321+
2322+
var attachedRoom = attachedContainer.Rooms.Single();
2323+
2324+
if (delayCascade)
2325+
{
2326+
context.ChangeTracker.DeleteOrphansTiming = CascadeTiming.OnSaveChanges;
2327+
}
2328+
2329+
context.Entry(attachedRoom).CurrentValues.SetValues(detachedRoom);
2330+
2331+
Assert.Equal(3, context.ChangeTracker.Entries().Count());
2332+
Assert.Equal(EntityState.Unchanged, context.Entry(attachedContainer).State);
2333+
Assert.Equal(EntityState.Unchanged, context.Entry(attachedContainer.Rooms.Single().Troduct).State);
2334+
2335+
if (delayCascade)
2336+
{
2337+
Assert.Equal(EntityState.Modified, context.Entry(attachedContainer.Rooms.Single()).State);
2338+
}
2339+
else
2340+
{
2341+
// Deleted because FK with cascade has been set to null
2342+
Assert.Equal(EntityState.Deleted, context.Entry(attachedContainer.Rooms.Single()).State);
2343+
}
2344+
2345+
context.ChangeTracker.CascadeChanges();
2346+
2347+
Assert.Equal(3, context.ChangeTracker.Entries().Count());
2348+
Assert.Equal(EntityState.Unchanged, context.Entry(attachedContainer).State);
2349+
Assert.Equal(EntityState.Unchanged, context.Entry(attachedContainer.Rooms.Single().Troduct).State);
2350+
Assert.Equal(EntityState.Deleted, context.Entry(attachedContainer.Rooms.Single()).State);
2351+
2352+
context.SaveChanges();
2353+
}
2354+
}
2355+
2356+
private class Kontainer
2357+
{
2358+
public int Id { get; set; }
2359+
public string Name { get; set; }
2360+
public List<KontainerRoom> Rooms { get; set; } = new List<KontainerRoom>();
2361+
}
2362+
2363+
private class KontainerRoom
2364+
{
2365+
public int Id { get; set; }
2366+
public int Number { get; set; }
2367+
public int KontainerId { get; set; }
2368+
public Kontainer Kontainer { get; set; }
2369+
public int? TroductId { get; set; }
2370+
public Troduct Troduct { get; set; }
2371+
}
2372+
2373+
private class Troduct
2374+
{
2375+
public int Id { get; set; }
2376+
public string Description { get; set; }
2377+
public List<KontainerRoom> Rooms { get; set; } = new List<KontainerRoom>();
2378+
}
2379+
2380+
private class KontainerContext : DbContext
2381+
{
2382+
private readonly string _databaseName;
2383+
2384+
public KontainerContext(string databaseName)
2385+
{
2386+
_databaseName = databaseName;
2387+
}
2388+
2389+
protected internal override void OnModelCreating(ModelBuilder modelBuilder)
2390+
{
2391+
modelBuilder.Entity<KontainerRoom>()
2392+
.HasOne(room => room.Troduct)
2393+
.WithMany(product => product.Rooms)
2394+
.HasForeignKey(room => room.TroductId)
2395+
.IsRequired(false)
2396+
.OnDelete(DeleteBehavior.Cascade);
2397+
}
2398+
2399+
protected internal override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
2400+
=> optionsBuilder
2401+
.UseInternalServiceProvider(InMemoryFixture.DefaultServiceProvider)
2402+
.UseInMemoryDatabase(_databaseName);
2403+
}
2404+
20882405
[ConditionalTheory]
20892406
[InlineData(false)]
20902407
[InlineData(true)]

0 commit comments

Comments
 (0)