From 7da47e4a4af6320b18f36fccb000e69664e845b5 Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Fri, 14 Aug 2026 15:26:59 +0000 Subject: [PATCH 1/3] Add lightweight EF Core relationship loading sample --- .../EfCoreRelationshipsMinimal.slnx | 8 + ef-core/modern-data-access-dotnet/README.md | 211 +++++++++ .../Data/RelationshipDatabase.cs | 216 +++++++++ .../Data/RelationshipDbContext.cs | 105 ++++ .../Diagnostics/SelectCountingInterceptor.cs | 77 +++ .../EfCoreRelationshipsMinimal.csproj | 17 + .../Models/RelationshipModels.cs | 73 +++ .../src/EfCoreRelationshipsMinimal/Program.cs | 32 ++ .../Services/LoadingScenarios.cs | 448 ++++++++++++++++++ .../EfCoreRelationshipsMinimal.Tests.csproj | 46 ++ .../EfCoreRelationshipsTests.cs | 316 ++++++++++++ 11 files changed, 1549 insertions(+) create mode 100644 ef-core/modern-data-access-dotnet/EfCoreRelationshipsMinimal.slnx create mode 100644 ef-core/modern-data-access-dotnet/README.md create mode 100644 ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/Data/RelationshipDatabase.cs create mode 100644 ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/Data/RelationshipDbContext.cs create mode 100644 ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/Diagnostics/SelectCountingInterceptor.cs create mode 100644 ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/EfCoreRelationshipsMinimal.csproj create mode 100644 ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/Models/RelationshipModels.cs create mode 100644 ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/Program.cs create mode 100644 ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/Services/LoadingScenarios.cs create mode 100644 ef-core/modern-data-access-dotnet/tests/EfCoreRelationshipsMinimal.Tests/EfCoreRelationshipsMinimal.Tests.csproj create mode 100644 ef-core/modern-data-access-dotnet/tests/EfCoreRelationshipsMinimal.Tests/EfCoreRelationshipsTests.cs diff --git a/ef-core/modern-data-access-dotnet/EfCoreRelationshipsMinimal.slnx b/ef-core/modern-data-access-dotnet/EfCoreRelationshipsMinimal.slnx new file mode 100644 index 0000000..04e7ca9 --- /dev/null +++ b/ef-core/modern-data-access-dotnet/EfCoreRelationshipsMinimal.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/ef-core/modern-data-access-dotnet/README.md b/ef-core/modern-data-access-dotnet/README.md new file mode 100644 index 0000000..b18d4da --- /dev/null +++ b/ef-core/modern-data-access-dotnet/README.md @@ -0,0 +1,211 @@ +# EF Core Relationship Loading & Query Shapes + +A focused EF Core companion showing how relationship-loading choices change +database command shape. + +## Full tutorial + +[EF Core 8 Fundamentals: Modern Data Access with .NET 8](https://www.dotnet-guide.com/tutorials/ef-core/modern-data-access-dotnet/) + +## Version note + +The full tutorial was written around .NET 8 / EF Core 8. + +This companion targets: + +```text +.NET 10 +EF Core 10 +Microsoft.EntityFrameworkCore.Sqlite 10.0.11 +``` + +because that is the current DOTNET GUIDE repository baseline. + +## Focus + +```text +Category -> TodoItem +TodoItem <-> Tag + ↓ +intentional N+1 baseline + ↓ +Include + ↓ +filtered Include + ↓ +AsSplitQuery + ↓ +explicit loading +``` + +## Why SQLite? + +The sample uses one open SQLite in-memory connection so it can demonstrate real +relational behavior without credentials, Docker, or a database server. + +SQLite is still not equivalent to SQL Server or PostgreSQL. + +Provider-specific production behavior should be tested against the actual +production provider. + +## N+1 baseline + +The sample intentionally includes an inefficient pattern: + +```text +1 query for todos ++ 1 category query per todo +``` + +With three seeded todos, that is: + +```text +4 SELECT commands +``` + +This is an anti-pattern included for comparison, not a recommendation. + +## Eager loading + +When the operation knows it needs the category relationship, the companion uses: + +```csharp +Include(todo => todo.Category) +``` + +For this exact SQLite/EF Core 10.0.11 query shape, that produces one SELECT. + +Do not generalize this count to arbitrary relationship graphs. + +## Filtered Include + +Todo 1 has two tags: + +```text +urgent +planning +``` + +The filtered Include deliberately loads only: + +```text +urgent +``` + +The scenario uses `AsNoTracking` so prior tracked entities cannot change the +filtered navigation through relationship fix-up. + +## Split query + +The nested graph: + +```text +Category -> Todos -> Tags +``` + +is loaded with: + +```csharp +AsSplitQuery() +``` + +For this pinned sample it issues three SELECT commands. + +Split queries aren't universally faster. They trade JOIN result size for +multiple commands/round trips. + +## Explicit loading + +The explicit-load scenario first loads a TodoItem without its Category. + +It then calls: + +```csharp +context.Entry(todo) + .Reference(item => item.Category) + .LoadAsync(...) +``` + +The test proves the reference changes from not loaded to loaded. + +Use explicit loading when the relationship is conditional rather than always +required. + +## No lazy-loading proxies + +This companion intentionally does not install: + +```text +Microsoft.EntityFrameworkCore.Proxies +``` + +Lazy loading can hide extra database round trips, making N+1 behavior harder to +see in a learning sample. + +## Deterministic output + +```text +EF Core Relationship Loading Lab +Seeded graph: categories=2, todos=3, tags=3 +N+1 baseline: todos=3, SELECTs=4 +Eager Include: todos=3, SELECTs=1 +Split graph: categories=2, todos=3, tags=3, SELECTs=3 +Explicit reference load: todo=Prepare release, category=Work, SELECTs=2 +``` + +## Restore, build, and test + +```powershell +dotnet restore ` + .\EfCoreRelationshipsMinimal.slnx + +dotnet build ` + .\EfCoreRelationshipsMinimal.slnx ` + --configuration Release ` + --no-restore + +dotnet test ` + .\EfCoreRelationshipsMinimal.slnx ` + --configuration Release ` + --no-build +``` + +## Run + +```powershell +dotnet run ` + --project .\src\EfCoreRelationshipsMinimal\EfCoreRelationshipsMinimal.csproj ` + --configuration Release ` + --no-build +``` + +## Deliberately omitted + +- web API CRUD; +- migrations; +- owned/complex types; +- JSON; +- compiled queries; +- ExecuteUpdate/Delete; +- concurrency; +- transactions; +- retries; +- SQL Server; +- PostgreSQL; +- lazy-loading proxies; +- raw SQL; +- Testcontainers; +- benchmarks. + +The complete tutorial covers the broader EF Core fundamentals. + +## Verification + +- Target framework: .NET 10 +- EF Core provider: SQLite 10.0.11 +- Direct application packages: 1 +- Tests: 8 +- External services: none +- Generated database files: none +- Benchmark claims: none +- Last reviewed: 2026-08-14 \ No newline at end of file diff --git a/ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/Data/RelationshipDatabase.cs b/ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/Data/RelationshipDatabase.cs new file mode 100644 index 0000000..f500d7e --- /dev/null +++ b/ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/Data/RelationshipDatabase.cs @@ -0,0 +1,216 @@ +using EfCoreRelationshipsMinimal.Diagnostics; +using EfCoreRelationshipsMinimal.Models; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace EfCoreRelationshipsMinimal.Data; + +public sealed class RelationshipDatabase : + IAsyncDisposable +{ + private readonly + SqliteConnection + _connection; + + private readonly + DbContextOptions< + RelationshipDbContext> + _options; + + private RelationshipDatabase( + SqliteConnection connection, + SelectCountingInterceptor + selectCounter, + DbContextOptions< + RelationshipDbContext> + options) + { + _connection = + connection; + + SelectCounter = + selectCounter; + + _options = + options; + } + + public SelectCountingInterceptor + SelectCounter + { + get; + } + + public static async Task< + RelationshipDatabase> + CreateAsync( + CancellationToken + cancellationToken = + default) + { + var connection = + new SqliteConnection( + "Data Source=:memory:"); + + await connection + .OpenAsync( + cancellationToken); + + try + { + var selectCounter = + new SelectCountingInterceptor(); + + DbContextOptions< + RelationshipDbContext> + options = + new DbContextOptionsBuilder< + RelationshipDbContext>() + .UseSqlite( + connection) + .AddInterceptors( + selectCounter) + .EnableDetailedErrors() + .Options; + + var database = + new RelationshipDatabase( + connection, + selectCounter, + options); + + await using + RelationshipDbContext context = + database + .CreateContext(); + + await context.Database + .EnsureCreatedAsync( + cancellationToken); + + await SeedAsync( + context, + cancellationToken); + + selectCounter.Reset(); + + return database; + } + catch + { + await connection + .DisposeAsync(); + + throw; + } + } + + public RelationshipDbContext + CreateContext() => + new( + _options); + + public ValueTask + DisposeAsync() => + _connection + .DisposeAsync(); + + private static async Task + SeedAsync( + RelationshipDbContext + context, + CancellationToken + cancellationToken) + { + var work = + new Category + { + Id = 1, + Name = "Work" + }; + + var personal = + new Category + { + Id = 2, + Name = "Personal" + }; + + var urgent = + new Tag + { + Id = 1, + Name = "urgent" + }; + + var planning = + new Tag + { + Id = 2, + Name = "planning" + }; + + var home = + new Tag + { + Id = 3, + Name = "home" + }; + + var release = + new TodoItem + { + Id = 1, + Title = + "Prepare release", + Category = + work + }; + + release.Tags.Add( + urgent); + + release.Tags.Add( + planning); + + var metrics = + new TodoItem + { + Id = 2, + Title = + "Review metrics", + Category = + work + }; + + metrics.Tags.Add( + planning); + + var dentist = + new TodoItem + { + Id = 3, + Title = + "Book dentist", + Category = + personal + }; + + dentist.Tags.Add( + home); + + context.AddRange( + work, + personal, + urgent, + planning, + home, + release, + metrics, + dentist); + + await context + .SaveChangesAsync( + cancellationToken); + } +} \ No newline at end of file diff --git a/ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/Data/RelationshipDbContext.cs b/ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/Data/RelationshipDbContext.cs new file mode 100644 index 0000000..fdf99ee --- /dev/null +++ b/ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/Data/RelationshipDbContext.cs @@ -0,0 +1,105 @@ +using EfCoreRelationshipsMinimal.Models; +using Microsoft.EntityFrameworkCore; + +namespace EfCoreRelationshipsMinimal.Data; + +public sealed class RelationshipDbContext( + DbContextOptions< + RelationshipDbContext> + options) : + DbContext( + options) +{ + public DbSet Categories => + Set(); + + public DbSet Todos => + Set(); + + public DbSet Tags => + Set(); + + protected override void + OnModelCreating( + ModelBuilder modelBuilder) + { + modelBuilder + .Entity( + category => + { + category.HasKey( + entity => + entity.Id); + + category.Property( + entity => + entity.Name) + .HasMaxLength( + 80) + .IsRequired(); + }); + + modelBuilder + .Entity( + todo => + { + todo.HasKey( + entity => + entity.Id); + + todo.Property( + entity => + entity.Title) + .HasMaxLength( + 120) + .IsRequired(); + + todo.HasIndex( + entity => + entity.CategoryId); + + todo.HasOne( + entity => + entity.Category) + .WithMany( + category => + category.Todos) + .HasForeignKey( + entity => + entity.CategoryId) + .OnDelete( + DeleteBehavior + .Restrict); + + todo.HasMany( + entity => + entity.Tags) + .WithMany( + tag => + tag.Todos) + .UsingEntity( + "TodoTag"); + }); + + modelBuilder + .Entity( + tag => + { + tag.HasKey( + entity => + entity.Id); + + tag.Property( + entity => + entity.Name) + .HasMaxLength( + 40) + .IsRequired(); + + tag.HasIndex( + entity => + entity.Name) + .IsUnique(); + }); + } +} \ No newline at end of file diff --git a/ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/Diagnostics/SelectCountingInterceptor.cs b/ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/Diagnostics/SelectCountingInterceptor.cs new file mode 100644 index 0000000..883e557 --- /dev/null +++ b/ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/Diagnostics/SelectCountingInterceptor.cs @@ -0,0 +1,77 @@ +using System.Data.Common; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace EfCoreRelationshipsMinimal.Diagnostics; + +public sealed class + SelectCountingInterceptor : + DbCommandInterceptor +{ + private int _selectCount; + + public int SelectCount => + Volatile.Read( + ref _selectCount); + + public void Reset() => + Interlocked.Exchange( + ref _selectCount, + 0); + + public override + InterceptionResult< + DbDataReader> + ReaderExecuting( + DbCommand command, + CommandEventData eventData, + InterceptionResult< + DbDataReader> result) + { + CountSelect( + command); + + return base.ReaderExecuting( + command, + eventData, + result); + } + + public override + ValueTask< + InterceptionResult< + DbDataReader>> + ReaderExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult< + DbDataReader> result, + CancellationToken + cancellationToken = + default) + { + CountSelect( + command); + + return base + .ReaderExecutingAsync( + command, + eventData, + result, + cancellationToken); + } + + private void CountSelect( + DbCommand command) + { + if (command.CommandText + .TrimStart() + .StartsWith( + "SELECT", + StringComparison + .OrdinalIgnoreCase)) + { + Interlocked.Increment( + ref _selectCount); + } + } +} \ No newline at end of file diff --git a/ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/EfCoreRelationshipsMinimal.csproj b/ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/EfCoreRelationshipsMinimal.csproj new file mode 100644 index 0000000..5342fe8 --- /dev/null +++ b/ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/EfCoreRelationshipsMinimal.csproj @@ -0,0 +1,17 @@ + + + + Exe + net10.0 + enable + enable + true + + + + + + + \ No newline at end of file diff --git a/ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/Models/RelationshipModels.cs b/ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/Models/RelationshipModels.cs new file mode 100644 index 0000000..2a03ee9 --- /dev/null +++ b/ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/Models/RelationshipModels.cs @@ -0,0 +1,73 @@ +namespace EfCoreRelationshipsMinimal.Models; + +public sealed class Category +{ + public int Id + { + get; + set; + } + + public string Name + { + get; + set; + } = ""; + + public List Todos + { + get; + } = []; +} + +public sealed class TodoItem +{ + public int Id + { + get; + set; + } + + public string Title + { + get; + set; + } = ""; + + public int CategoryId + { + get; + set; + } + + public Category Category + { + get; + set; + } = null!; + + public List Tags + { + get; + } = []; +} + +public sealed class Tag +{ + public int Id + { + get; + set; + } + + public string Name + { + get; + set; + } = ""; + + public List Todos + { + get; + } = []; +} \ No newline at end of file diff --git a/ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/Program.cs b/ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/Program.cs new file mode 100644 index 0000000..1f909b9 --- /dev/null +++ b/ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/Program.cs @@ -0,0 +1,32 @@ +using EfCoreRelationshipsMinimal.Services; + +LoadingWorkflowResult result = + await LoadingScenarios + .RunAsync(); + +Console.WriteLine( + "EF Core Relationship Loading Lab"); + +Console.WriteLine( + $"Seeded graph: categories={result.SeededGraph.Categories}, " + + $"todos={result.SeededGraph.Todos}, " + + $"tags={result.SeededGraph.Tags}"); + +Console.WriteLine( + $"N+1 baseline: todos={result.NPlusOne.TodoCount}, " + + $"SELECTs={result.NPlusOne.SelectCount}"); + +Console.WriteLine( + $"Eager Include: todos={result.EagerInclude.TodoCount}, " + + $"SELECTs={result.EagerInclude.SelectCount}"); + +Console.WriteLine( + $"Split graph: categories={result.SplitGraph.CategoryCount}, " + + $"todos={result.SplitGraph.TodoCount}, " + + $"tags={result.SplitGraph.TagCount}, " + + $"SELECTs={result.SplitGraph.SelectCount}"); + +Console.WriteLine( + $"Explicit reference load: todo={result.ExplicitLoad.TodoTitle}, " + + $"category={result.ExplicitLoad.CategoryName}, " + + $"SELECTs={result.ExplicitLoad.SelectCount}"); \ No newline at end of file diff --git a/ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/Services/LoadingScenarios.cs b/ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/Services/LoadingScenarios.cs new file mode 100644 index 0000000..29f19ee --- /dev/null +++ b/ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/Services/LoadingScenarios.cs @@ -0,0 +1,448 @@ +using EfCoreRelationshipsMinimal.Data; +using EfCoreRelationshipsMinimal.Models; +using Microsoft.EntityFrameworkCore; + +namespace EfCoreRelationshipsMinimal.Services; + +public sealed record + GraphCounts( + int Categories, + int Todos, + int Tags); + +public sealed record + NPlusOneResult( + int TodoCount, + int SelectCount); + +public sealed record + IncludeResult( + int TodoCount, + IReadOnlyList< + string> CategoryNames, + int SelectCount, + int TrackedEntries); + +public sealed record + FilteredIncludeResult( + string TodoTitle, + IReadOnlyList< + string> TagNames, + int SelectCount); + +public sealed record + SplitGraphResult( + int CategoryCount, + int TodoCount, + int TagCount, + int SelectCount); + +public sealed record + ExplicitLoadResult( + string TodoTitle, + string CategoryName, + bool WasLoadedBefore, + bool IsLoadedAfter, + int SelectCount); + +public sealed record + LoadingWorkflowResult( + GraphCounts SeededGraph, + NPlusOneResult + NPlusOne, + IncludeResult + EagerInclude, + SplitGraphResult + SplitGraph, + ExplicitLoadResult + ExplicitLoad); + +public static class LoadingScenarios +{ + public static async Task< + GraphCounts> + CountSeededGraphAsync( + RelationshipDatabase + database, + CancellationToken + cancellationToken = + default) + { + await using + RelationshipDbContext context = + database + .CreateContext(); + + int categories = + await context.Categories + .CountAsync( + cancellationToken); + + int todos = + await context.Todos + .CountAsync( + cancellationToken); + + int tags = + await context.Tags + .CountAsync( + cancellationToken); + + return new GraphCounts( + categories, + todos, + tags); + } + + public static async Task< + NPlusOneResult> + RunNPlusOneAsync( + RelationshipDatabase + database, + CancellationToken + cancellationToken = + default) + { + database + .SelectCounter + .Reset(); + + await using + RelationshipDbContext context = + database + .CreateContext(); + + List todos = + await context.Todos + .AsNoTracking() + .OrderBy( + todo => + todo.Id) + .ToListAsync( + cancellationToken); + + foreach ( + TodoItem todo + in todos) + { + _ = + await context.Categories + .AsNoTracking() + .Where( + category => + category.Id + == todo.CategoryId) + .Select( + category => + category.Name) + .SingleAsync( + cancellationToken); + } + + return new NPlusOneResult( + TodoCount: + todos.Count, + + SelectCount: + database + .SelectCounter + .SelectCount); + } + + public static async Task< + IncludeResult> + RunEagerIncludeAsync( + RelationshipDatabase + database, + CancellationToken + cancellationToken = + default) + { + database + .SelectCounter + .Reset(); + + await using + RelationshipDbContext context = + database + .CreateContext(); + + List todos = + await context.Todos + .AsNoTracking() + .Include( + todo => + todo.Category) + .OrderBy( + todo => + todo.Id) + .ToListAsync( + cancellationToken); + + return new IncludeResult( + TodoCount: + todos.Count, + + CategoryNames: + todos + .Select( + todo => + todo.Category.Name) + .ToArray(), + + SelectCount: + database + .SelectCounter + .SelectCount, + + TrackedEntries: + context + .ChangeTracker + .Entries() + .Count()); + } + + public static async Task< + FilteredIncludeResult> + RunFilteredIncludeAsync( + RelationshipDatabase + database, + CancellationToken + cancellationToken = + default) + { + database + .SelectCounter + .Reset(); + + await using + RelationshipDbContext context = + database + .CreateContext(); + + TodoItem todo = + await context.Todos + .AsNoTracking() + .Include( + item => + item.Tags + .Where( + tag => + tag.Name + == "urgent")) + .SingleAsync( + item => + item.Id + == 1, + cancellationToken); + + return new FilteredIncludeResult( + TodoTitle: + todo.Title, + + TagNames: + todo.Tags + .Select( + tag => + tag.Name) + .OrderBy( + name => + name, + StringComparer + .Ordinal) + .ToArray(), + + SelectCount: + database + .SelectCounter + .SelectCount); + } + + public static async Task< + SplitGraphResult> + RunSplitGraphAsync( + RelationshipDatabase + database, + CancellationToken + cancellationToken = + default) + { + database + .SelectCounter + .Reset(); + + await using + RelationshipDbContext context = + database + .CreateContext(); + + List categories = + await context.Categories + .AsNoTracking() + .Include( + category => + category.Todos) + .ThenInclude( + todo => + todo.Tags) + .AsSplitQuery() + .OrderBy( + category => + category.Id) + .ToListAsync( + cancellationToken); + + TodoItem[] todos = + categories + .SelectMany( + category => + category.Todos) + .DistinctBy( + todo => + todo.Id) + .ToArray(); + + int tagCount = + todos + .SelectMany( + todo => + todo.Tags) + .DistinctBy( + tag => + tag.Id) + .Count(); + + return new SplitGraphResult( + CategoryCount: + categories.Count, + + TodoCount: + todos.Length, + + TagCount: + tagCount, + + SelectCount: + database + .SelectCounter + .SelectCount); + } + + public static async Task< + ExplicitLoadResult> + RunExplicitReferenceLoadAsync( + RelationshipDatabase + database, + CancellationToken + cancellationToken = + default) + { + database + .SelectCounter + .Reset(); + + await using + RelationshipDbContext context = + database + .CreateContext(); + + TodoItem todo = + await context.Todos + .SingleAsync( + item => + item.Id + == 1, + cancellationToken); + + var reference = + context + .Entry( + todo) + .Reference( + item => + item.Category); + + bool before = + reference.IsLoaded; + + await reference + .LoadAsync( + cancellationToken); + + bool after = + reference.IsLoaded; + + return new ExplicitLoadResult( + TodoTitle: + todo.Title, + + CategoryName: + todo.Category.Name, + + WasLoadedBefore: + before, + + IsLoadedAfter: + after, + + SelectCount: + database + .SelectCounter + .SelectCount); + } + + public static async Task< + LoadingWorkflowResult> + RunAsync( + CancellationToken + cancellationToken = + default) + { + await using + RelationshipDatabase database = + await RelationshipDatabase + .CreateAsync( + cancellationToken); + + GraphCounts seeded = + await CountSeededGraphAsync( + database, + cancellationToken); + + NPlusOneResult nPlusOne = + await RunNPlusOneAsync( + database, + cancellationToken); + + IncludeResult eager = + await RunEagerIncludeAsync( + database, + cancellationToken); + + SplitGraphResult split = + await RunSplitGraphAsync( + database, + cancellationToken); + + ExplicitLoadResult explicitLoad = + await RunExplicitReferenceLoadAsync( + database, + cancellationToken); + + return new LoadingWorkflowResult( + SeededGraph: + seeded, + + NPlusOne: + nPlusOne, + + EagerInclude: + eager, + + SplitGraph: + split, + + ExplicitLoad: + explicitLoad); + } +} \ No newline at end of file diff --git a/ef-core/modern-data-access-dotnet/tests/EfCoreRelationshipsMinimal.Tests/EfCoreRelationshipsMinimal.Tests.csproj b/ef-core/modern-data-access-dotnet/tests/EfCoreRelationshipsMinimal.Tests/EfCoreRelationshipsMinimal.Tests.csproj new file mode 100644 index 0000000..0ae0fa8 --- /dev/null +++ b/ef-core/modern-data-access-dotnet/tests/EfCoreRelationshipsMinimal.Tests/EfCoreRelationshipsMinimal.Tests.csproj @@ -0,0 +1,46 @@ + + + + net10.0 + enable + enable + true + false + true + Exe + + + + + + + + + all + + runtime; + build; + native; + contentfiles; + analyzers; + buildtransitive + + + + + + + + + + + + + \ No newline at end of file diff --git a/ef-core/modern-data-access-dotnet/tests/EfCoreRelationshipsMinimal.Tests/EfCoreRelationshipsTests.cs b/ef-core/modern-data-access-dotnet/tests/EfCoreRelationshipsMinimal.Tests/EfCoreRelationshipsTests.cs new file mode 100644 index 0000000..8e05345 --- /dev/null +++ b/ef-core/modern-data-access-dotnet/tests/EfCoreRelationshipsMinimal.Tests/EfCoreRelationshipsTests.cs @@ -0,0 +1,316 @@ +using EfCoreRelationshipsMinimal.Data; +using EfCoreRelationshipsMinimal.Models; +using EfCoreRelationshipsMinimal.Services; +using Microsoft.EntityFrameworkCore; + +namespace EfCoreRelationshipsMinimal.Tests; + +public sealed class + EfCoreRelationshipsTests +{ + [Fact] + public async Task + Seeded_graph_has_expected_relationship_counts() + { + CancellationToken token = + TestContext.Current + .CancellationToken; + + await using + RelationshipDatabase database = + await RelationshipDatabase + .CreateAsync( + token); + + await using + RelationshipDbContext context = + database + .CreateContext(); + + List categories = + await context.Categories + .AsNoTracking() + .Include( + category => + category.Todos) + .ThenInclude( + todo => + todo.Tags) + .AsSplitQuery() + .OrderBy( + category => + category.Id) + .ToListAsync( + token); + + TodoItem[] todos = + categories + .SelectMany( + category => + category.Todos) + .DistinctBy( + todo => + todo.Id) + .ToArray(); + + Tag[] tags = + todos + .SelectMany( + todo => + todo.Tags) + .DistinctBy( + tag => + tag.Id) + .ToArray(); + + Assert.Equal( + 2, + categories.Count); + + Assert.Equal( + 3, + todos.Length); + + Assert.Equal( + 3, + tags.Length); + } + + [Fact] + public async Task + Manual_category_lookup_demonstrates_n_plus_one() + { + CancellationToken token = + TestContext.Current + .CancellationToken; + + await using + RelationshipDatabase database = + await RelationshipDatabase + .CreateAsync( + token); + + NPlusOneResult result = + await LoadingScenarios + .RunNPlusOneAsync( + database, + token); + + Assert.Equal( + 3, + result.TodoCount); + + Assert.Equal( + 4, + result.SelectCount); + } + + [Fact] + public async Task + Eager_include_loads_categories_in_one_select() + { + CancellationToken token = + TestContext.Current + .CancellationToken; + + await using + RelationshipDatabase database = + await RelationshipDatabase + .CreateAsync( + token); + + IncludeResult result = + await LoadingScenarios + .RunEagerIncludeAsync( + database, + token); + + Assert.Equal( + 3, + result.TodoCount); + + Assert.Equal( + [ + "Work", + "Work", + "Personal" + ], + result.CategoryNames); + + Assert.Equal( + 1, + result.SelectCount); + } + + [Fact] + public async Task + Filtered_include_loads_only_requested_tag() + { + CancellationToken token = + TestContext.Current + .CancellationToken; + + await using + RelationshipDatabase database = + await RelationshipDatabase + .CreateAsync( + token); + + FilteredIncludeResult result = + await LoadingScenarios + .RunFilteredIncludeAsync( + database, + token); + + Assert.Equal( + "Prepare release", + result.TodoTitle); + + Assert.Equal( + ["urgent"], + result.TagNames); + + Assert.Equal( + 1, + result.SelectCount); + } + + [Fact] + public async Task + Split_query_loads_complete_graph_in_three_selects() + { + CancellationToken token = + TestContext.Current + .CancellationToken; + + await using + RelationshipDatabase database = + await RelationshipDatabase + .CreateAsync( + token); + + SplitGraphResult result = + await LoadingScenarios + .RunSplitGraphAsync( + database, + token); + + Assert.Equal( + 2, + result.CategoryCount); + + Assert.Equal( + 3, + result.TodoCount); + + Assert.Equal( + 3, + result.TagCount); + + Assert.Equal( + 3, + result.SelectCount); + } + + [Fact] + public async Task + Explicit_reference_load_is_conditional_and_two_selects() + { + CancellationToken token = + TestContext.Current + .CancellationToken; + + await using + RelationshipDatabase database = + await RelationshipDatabase + .CreateAsync( + token); + + ExplicitLoadResult result = + await LoadingScenarios + .RunExplicitReferenceLoadAsync( + database, + token); + + Assert.Equal( + "Prepare release", + result.TodoTitle); + + Assert.Equal( + "Work", + result.CategoryName); + + Assert.False( + result.WasLoadedBefore); + + Assert.True( + result.IsLoadedAfter); + + Assert.Equal( + 2, + result.SelectCount); + } + + [Fact] + public async Task + No_tracking_include_leaves_change_tracker_empty() + { + CancellationToken token = + TestContext.Current + .CancellationToken; + + await using + RelationshipDatabase database = + await RelationshipDatabase + .CreateAsync( + token); + + IncludeResult result = + await LoadingScenarios + .RunEagerIncludeAsync( + database, + token); + + Assert.Equal( + 0, + result.TrackedEntries); + } + + [Fact] + public async Task + Workflow_returns_deterministic_summary() + { + LoadingWorkflowResult result = + await LoadingScenarios + .RunAsync( + TestContext.Current + .CancellationToken); + + Assert.Equal( + new GraphCounts( + 2, + 3, + 3), + result.SeededGraph); + + Assert.Equal( + 4, + result.NPlusOne + .SelectCount); + + Assert.Equal( + 1, + result.EagerInclude + .SelectCount); + + Assert.Equal( + 3, + result.SplitGraph + .SelectCount); + + Assert.Equal( + 2, + result.ExplicitLoad + .SelectCount); + } +} \ No newline at end of file From b6e7e0db54ac88a05e3e05f60c8eb53982638f50 Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Fri, 14 Aug 2026 15:27:13 +0000 Subject: [PATCH 2/3] Add EF Core relationship sample to repository README --- README.md | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1cb95ea..007be76 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Each sample folder contains a focused implementation of one tutorial topic. The | [`dotnet-8-essentials/core-features-get-started`](dotnet-8-essentials/core-features-get-started/) | Focused .NET 10 Native AOT Minimal API demonstrating CreateSlimBuilder, source-generated JSON, typed DI, AOT-safe endpoints, analyzer-aware publishing, and direct native-binary smoke testing | [.NET 8 Essentials: Core Features & Getting Started](https://www.dotnet-guide.com/tutorials/dotnet-8-essentials/core-features-get-started/) | | [`dotnet-8-essentials/observability-opentelemetry`](dotnet-8-essentials/observability-opentelemetry/) | Focused .NET 10 OpenTelemetry companion demonstrating ASP.NET Core request instrumentation, custom ActivitySource spans, low-cardinality Meter metrics, structured ILogger events, automatic log-to-trace correlation, console export, and deterministic tests without external observability infrastructure | [.NET 8 Observability with OpenTelemetry: Tracing, Metrics & Structured Logging](https://www.dotnet-guide.com/tutorials/dotnet-8-essentials/observability-opentelemetry/) | | [`ef-core/advanced-modeling-performance`](ef-core/advanced-modeling-performance/) | Focused .NET 10 / EF Core 10 relational-data companion demonstrating DTO projection, no-tracking reads, a compiled hot-path query, named soft-delete filtering, ExecuteUpdate, ExecuteDelete, and explicit change-tracker caveats with deterministic SQLite tests | [EF Core Advanced Modeling & Performance: Owned Types, Converters, JSON/Temporal Tables, Compiled Queries](https://www.dotnet-guide.com/tutorials/ef-core/advanced-modeling-performance/) | +| [`ef-core/modern-data-access-dotnet`](ef-core/modern-data-access-dotnet/) | Focused .NET 10 / EF Core 10 relationship-loading companion demonstrating one-to-many and many-to-many modeling, an intentional N+1 baseline, eager and filtered Include, split-query loading, explicit loading, SELECT-command counting, and deterministic SQLite verification | [EF Core 8 Fundamentals: Modern Data Access with .NET 8](https://www.dotnet-guide.com/tutorials/ef-core/modern-data-access-dotnet/) | ## Companion articles - [Common Microsoft.Extensions.AI mistakes](https://www.dotnet-guide.com/articles/dotnet-ai/microsoft-extensions-ai-common-mistakes/) @@ -471,8 +472,8 @@ tutorials/ | |-- MinimalApiPipeline.Tests.csproj | `-- MinimalApiPipelineTests.cs |-- ef-core/ -| `-- advanced-modeling-performance/ -| |-- EfCoreHotPathMinimal.slnx +| |-- advanced-modeling-performance/ +| | |-- EfCoreHotPathMinimal.slnx | |-- README.md | |-- src/ | | `-- EfCoreHotPathMinimal/ @@ -492,6 +493,26 @@ tutorials/ | `-- EfCoreHotPathMinimal.Tests/ | |-- EfCoreHotPathMinimal.Tests.csproj | `-- EfCoreHotPathTests.cs +| `-- modern-data-access-dotnet/ +| |-- EfCoreRelationshipsMinimal.slnx +| |-- README.md +| |-- src/ +| | `-- EfCoreRelationshipsMinimal/ +| | |-- EfCoreRelationshipsMinimal.csproj +| | |-- Program.cs +| | |-- Data/ +| | | |-- RelationshipDatabase.cs +| | | `-- RelationshipDbContext.cs +| | |-- Diagnostics/ +| | | `-- SelectCountingInterceptor.cs +| | |-- Models/ +| | | `-- RelationshipModels.cs +| | `-- Services/ +| | `-- LoadingScenarios.cs +| `-- tests/ +| `-- EfCoreRelationshipsMinimal.Tests/ +| |-- EfCoreRelationshipsMinimal.Tests.csproj +| `-- EfCoreRelationshipsTests.cs |-- .github/ | `-- workflows/ | `-- build-samples.yml From c6e6832534d3423c74c5ebdfe80ee6e94cb068fd Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Fri, 14 Aug 2026 15:27:13 +0000 Subject: [PATCH 3/3] Test EF Core relationship loading in GitHub Actions --- .github/workflows/build-samples.yml | 106 ++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/.github/workflows/build-samples.yml b/.github/workflows/build-samples.yml index 382d3c2..abb55f5 100644 --- a/.github/workflows/build-samples.yml +++ b/.github/workflows/build-samples.yml @@ -28,6 +28,7 @@ on: - "dotnet-8-essentials/core-features-get-started/**" - "dotnet-8-essentials/observability-opentelemetry/**" - "ef-core/advanced-modeling-performance/**" + - "ef-core/modern-data-access-dotnet/**" - ".github/workflows/build-samples.yml" pull_request: @@ -56,6 +57,7 @@ on: - "dotnet-8-essentials/core-features-get-started/**" - "dotnet-8-essentials/observability-opentelemetry/**" - "ef-core/advanced-modeling-performance/**" + - "ef-core/modern-data-access-dotnet/**" - ".github/workflows/build-samples.yml" workflow_dispatch: @@ -1667,3 +1669,107 @@ jobs: echo "Generated SQLite database file found in sample tree." exit 1 fi + + test-ef-core-relationships: + name: Test EF Core relationship loading sample + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Install .NET 10 SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Restore + run: > + dotnet restore + ef-core/modern-data-access-dotnet/EfCoreRelationshipsMinimal.slnx + + - name: Build + run: > + dotnet build + ef-core/modern-data-access-dotnet/EfCoreRelationshipsMinimal.slnx + --configuration Release + --no-restore + + - name: Test + run: > + dotnet test + ef-core/modern-data-access-dotnet/EfCoreRelationshipsMinimal.slnx + --configuration Release + --no-build + + - name: Verify EF Core package baseline + shell: bash + run: | + project="ef-core/modern-data-access-dotnet/src/EfCoreRelationshipsMinimal/EfCoreRelationshipsMinimal.csproj" + + test "$( + grep \ + --count \ + '