Skip to content

Commit 29c3299

Browse files
committed
EF Core pattern coverage sweep: 44 new tests across 10 common LINQ-to-SQL patterns — value converters, many-to-many, global query filters, eager / split queries (Include / ThenInclude / AsSplitQuery), owned entities (OwnsOne / OwnsMany), shadow properties + alternate keys, concurrency tokens ([Timestamp] rowversion + DbUpdateConcurrencyException), ExecuteUpdate / ExecuteDelete bulk operations, stored procedure CRUD mappings (InsertUsingStoredProcedure / UpdateUsingStoredProcedure / DeleteUsingStoredProcedure), and temporal tables (IsTemporal + TemporalAsOf / TemporalAll). 38 tests pass against the existing simulator; 6 disabled with [Ignore("Needs: <category>")] tags marking next-arc work: ExecuteUpdate column-self-reference (2 tests covering SetProperty(col, c => c.col + 1) form) and temporal-table DDL + history tracking + FOR SYSTEM_TIME queries (4 tests covering GENERATED ALWAYS AS ROW START/END / PERIOD FOR SYSTEM_TIME / WITH (SYSTEM_VERSIONING = ON …) / TemporalAsOf / TemporalAll). Test set is the regression oracle for the next arc — unblock tests in category order, starting with whichever pays off fastest in application compatibility.
1 parent cd7862c commit 29c3299

12 files changed

Lines changed: 1827 additions & 0 deletions
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
using Microsoft.EntityFrameworkCore;
2+
using System.ComponentModel.DataAnnotations;
3+
using System.ComponentModel.DataAnnotations.Schema;
4+
5+
namespace SqlServerSimulator;
6+
7+
/// <summary>
8+
/// End-to-end tests for EF Core's optimistic-concurrency support via
9+
/// <c>[Timestamp]</c> rowversion columns. On every UPDATE / DELETE EF
10+
/// includes the rowversion in the WHERE clause; if the row's stored
11+
/// rowversion has advanced since the entity was read, the affected-rows
12+
/// count is 0 and EF throws <see cref="DbUpdateConcurrencyException"/>.
13+
/// EF also OUTPUTs the new rowversion so the in-memory entity tracks
14+
/// the latest version after a successful save. Exercises the simulator's
15+
/// rowversion auto-advance + OUTPUT-INSERTED round-trip + WHERE-on-
16+
/// rowversion filtering.
17+
/// </summary>
18+
[TestClass]
19+
public class EFCoreConcurrencyTokens
20+
{
21+
public TestContext TestContext { get; set; } = null!;
22+
23+
private sealed class Item
24+
{
25+
public int Id { get; set; }
26+
27+
[Column(TypeName = "nvarchar(30)")]
28+
public string Name { get; set; } = "";
29+
30+
public int Quantity { get; set; }
31+
32+
[Timestamp]
33+
public byte[] RowVersion { get; set; } = [];
34+
}
35+
36+
private sealed class InventoryContext(Simulation simulation) : DbContext
37+
{
38+
public Simulation Simulation { get; } = simulation;
39+
40+
public DbSet<Item> Items => Set<Item>();
41+
42+
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
43+
{
44+
_ = optionsBuilder.UseSqlServer(this.Simulation.CreateDbConnection());
45+
}
46+
47+
protected override void OnModelCreating(ModelBuilder modelBuilder)
48+
{
49+
_ = modelBuilder.Entity<Item>().Property(i => i.Id).ValueGeneratedNever();
50+
}
51+
}
52+
53+
private static Simulation CreateSimulation()
54+
{
55+
var simulation = new Simulation();
56+
_ = simulation
57+
.CreateOpenConnection()
58+
.CreateCommand("""
59+
create table Items (
60+
Id int not null primary key,
61+
Name nvarchar(30) not null,
62+
Quantity int not null,
63+
RowVersion rowversion not null
64+
)
65+
""")
66+
.ExecuteNonQuery();
67+
return simulation;
68+
}
69+
70+
private static InventoryContext SeededContext()
71+
{
72+
var context = new InventoryContext(CreateSimulation());
73+
context.Items.AddRange(
74+
new Item { Id = 1, Name = "widget", Quantity = 10 },
75+
new Item { Id = 2, Name = "gadget", Quantity = 5 });
76+
_ = context.SaveChanges();
77+
return context;
78+
}
79+
80+
[TestMethod]
81+
public void Insert_PopulatesRowVersion()
82+
{
83+
using var context = SeededContext();
84+
var widget = context.Items.Single(i => i.Id == 1);
85+
Assert.IsNotNull(widget.RowVersion);
86+
Assert.AreNotEqual(0, widget.RowVersion.Length);
87+
}
88+
89+
[TestMethod]
90+
public void Update_AdvancesRowVersion()
91+
{
92+
using var context = SeededContext();
93+
var widget = context.Items.Single(i => i.Id == 1);
94+
var before = widget.RowVersion;
95+
widget.Quantity = 20;
96+
_ = context.SaveChanges();
97+
var after = widget.RowVersion;
98+
CollectionAssert.AreNotEqual(before, after);
99+
}
100+
101+
[TestMethod]
102+
public void StaleUpdate_ThrowsConcurrencyException()
103+
{
104+
var simulation = CreateSimulation();
105+
using (var first = new InventoryContext(simulation))
106+
{
107+
_ = first.Items.Add(new Item { Id = 1, Name = "widget", Quantity = 10 });
108+
_ = first.SaveChanges();
109+
}
110+
111+
using var contextA = new InventoryContext(simulation);
112+
using var contextB = new InventoryContext(simulation);
113+
114+
var widgetA = contextA.Items.Single(i => i.Id == 1);
115+
var widgetB = contextB.Items.Single(i => i.Id == 1);
116+
117+
widgetA.Quantity = 20;
118+
_ = contextA.SaveChanges();
119+
120+
widgetB.Quantity = 30;
121+
_ = Assert.Throws<DbUpdateConcurrencyException>(() => contextB.SaveChanges());
122+
}
123+
124+
[TestMethod]
125+
public void StaleDelete_ThrowsConcurrencyException()
126+
{
127+
var simulation = CreateSimulation();
128+
using (var first = new InventoryContext(simulation))
129+
{
130+
_ = first.Items.Add(new Item { Id = 1, Name = "widget", Quantity = 10 });
131+
_ = first.SaveChanges();
132+
}
133+
134+
using var contextA = new InventoryContext(simulation);
135+
using var contextB = new InventoryContext(simulation);
136+
137+
var widgetA = contextA.Items.Single(i => i.Id == 1);
138+
var widgetB = contextB.Items.Single(i => i.Id == 1);
139+
140+
widgetA.Quantity = 99;
141+
_ = contextA.SaveChanges();
142+
143+
_ = contextB.Items.Remove(widgetB);
144+
_ = Assert.Throws<DbUpdateConcurrencyException>(() => contextB.SaveChanges());
145+
}
146+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
using Microsoft.EntityFrameworkCore;
2+
using System.ComponentModel.DataAnnotations.Schema;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// End-to-end tests for EF Core's eager-loading surface:
8+
/// <c>Include</c> / <c>ThenInclude</c> walk navigation collections
9+
/// via LEFT JOIN in a single query (the default). <c>AsSplitQuery()</c>
10+
/// switches to one query per Include level, avoiding the cartesian-
11+
/// explosion that JOIN-based eager loading causes with multiple
12+
/// collection navigations. Both shapes are common in real apps.
13+
/// </summary>
14+
[TestClass]
15+
public class EFCoreEagerSplitQueries
16+
{
17+
public TestContext TestContext { get; set; } = null!;
18+
19+
private sealed class Blog
20+
{
21+
public int Id { get; set; }
22+
23+
[Column(TypeName = "nvarchar(50)")]
24+
public string Name { get; set; } = "";
25+
26+
public List<Post> Posts { get; set; } = [];
27+
}
28+
29+
private sealed class Post
30+
{
31+
public int Id { get; set; }
32+
33+
[Column(TypeName = "nvarchar(50)")]
34+
public string Title { get; set; } = "";
35+
36+
public int BlogId { get; set; }
37+
public Blog? Blog { get; set; }
38+
39+
public List<Comment> Comments { get; set; } = [];
40+
}
41+
42+
private sealed class Comment
43+
{
44+
public int Id { get; set; }
45+
46+
[Column(TypeName = "nvarchar(100)")]
47+
public string Text { get; set; } = "";
48+
49+
public int PostId { get; set; }
50+
public Post? Post { get; set; }
51+
}
52+
53+
private sealed class BlogContext(Simulation simulation) : DbContext
54+
{
55+
public Simulation Simulation { get; } = simulation;
56+
57+
public DbSet<Blog> Blogs => Set<Blog>();
58+
public DbSet<Post> Posts => Set<Post>();
59+
public DbSet<Comment> Comments => Set<Comment>();
60+
61+
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
62+
{
63+
_ = optionsBuilder.UseSqlServer(this.Simulation.CreateDbConnection());
64+
}
65+
66+
protected override void OnModelCreating(ModelBuilder modelBuilder)
67+
{
68+
_ = modelBuilder.Entity<Blog>().Property(b => b.Id).ValueGeneratedNever();
69+
_ = modelBuilder.Entity<Post>().Property(p => p.Id).ValueGeneratedNever();
70+
_ = modelBuilder.Entity<Comment>().Property(c => c.Id).ValueGeneratedNever();
71+
}
72+
}
73+
74+
private static Simulation CreateSimulation()
75+
{
76+
var simulation = new Simulation();
77+
_ = simulation
78+
.CreateOpenConnection()
79+
.CreateCommand("""
80+
create table Blogs (
81+
Id int not null primary key,
82+
Name nvarchar(50) not null
83+
);
84+
create table Posts (
85+
Id int not null primary key,
86+
Title nvarchar(50) not null,
87+
BlogId int not null
88+
);
89+
create table Comments (
90+
Id int not null primary key,
91+
Text nvarchar(100) not null,
92+
PostId int not null
93+
)
94+
""")
95+
.ExecuteNonQuery();
96+
return simulation;
97+
}
98+
99+
private static BlogContext SeededContext()
100+
{
101+
var context = new BlogContext(CreateSimulation());
102+
_ = context.Blogs.Add(new Blog
103+
{
104+
Id = 1,
105+
Name = "Tech",
106+
Posts =
107+
{
108+
new Post
109+
{
110+
Id = 10,
111+
Title = "Hello",
112+
BlogId = 1,
113+
Comments = { new Comment { Id = 100, Text = "first", PostId = 10 } },
114+
},
115+
new Post
116+
{
117+
Id = 11,
118+
Title = "World",
119+
BlogId = 1,
120+
Comments =
121+
{
122+
new Comment { Id = 110, Text = "ok", PostId = 11 },
123+
new Comment { Id = 111, Text = "thanks", PostId = 11 },
124+
},
125+
},
126+
},
127+
});
128+
_ = context.SaveChanges();
129+
return context;
130+
}
131+
132+
[TestMethod]
133+
public void Include_LoadsCollectionInSameQuery()
134+
{
135+
using var context = SeededContext();
136+
var blog = context.Blogs.Include(b => b.Posts).Single(b => b.Id == 1);
137+
Assert.HasCount(2, blog.Posts);
138+
CollectionAssert.AreEquivalent(new[] { "Hello", "World" }, blog.Posts.Select(p => p.Title).ToArray());
139+
}
140+
141+
[TestMethod]
142+
public void IncludeThenInclude_LoadsTwoLevels()
143+
{
144+
using var context = SeededContext();
145+
var blog = context.Blogs
146+
.Include(b => b.Posts)
147+
.ThenInclude(p => p.Comments)
148+
.Single(b => b.Id == 1);
149+
var totalComments = blog.Posts.Sum(p => p.Comments.Count);
150+
Assert.AreEqual(3, totalComments);
151+
}
152+
153+
[TestMethod]
154+
public void AsSplitQuery_LoadsCollectionsViaSeparateQueries()
155+
{
156+
using var context = SeededContext();
157+
var blog = context.Blogs
158+
.Include(b => b.Posts)
159+
.ThenInclude(p => p.Comments)
160+
.AsSplitQuery()
161+
.Single(b => b.Id == 1);
162+
var totalComments = blog.Posts.Sum(p => p.Comments.Count);
163+
Assert.AreEqual(3, totalComments);
164+
}
165+
166+
[TestMethod]
167+
public void InverseInclude_WalksNonCollectionNavigation()
168+
{
169+
using var context = SeededContext();
170+
var post = context.Posts.Include(p => p.Blog).Single(p => p.Id == 10);
171+
Assert.IsNotNull(post.Blog);
172+
Assert.AreEqual("Tech", post.Blog!.Name);
173+
}
174+
}

0 commit comments

Comments
 (0)