diff --git a/.github/workflows/build-samples.yml b/.github/workflows/build-samples.yml index 9a4836c..382d3c2 100644 --- a/.github/workflows/build-samples.yml +++ b/.github/workflows/build-samples.yml @@ -27,6 +27,7 @@ on: - "dotnet-8-essentials/configuration-secrets-environments/**" - "dotnet-8-essentials/core-features-get-started/**" - "dotnet-8-essentials/observability-opentelemetry/**" + - "ef-core/advanced-modeling-performance/**" - ".github/workflows/build-samples.yml" pull_request: @@ -54,6 +55,7 @@ on: - "dotnet-8-essentials/configuration-secrets-environments/**" - "dotnet-8-essentials/core-features-get-started/**" - "dotnet-8-essentials/observability-opentelemetry/**" + - "ef-core/advanced-modeling-performance/**" - ".github/workflows/build-samples.yml" workflow_dispatch: @@ -1561,3 +1563,107 @@ jobs: grep --fixed-strings --quiet 'Checkout.Process' "${app_log}" grep --fixed-strings --quiet 'checkout.requests' "${app_log}" grep --fixed-strings --quiet 'Checkout accepted' "${app_log}" + + test-ef-core-hot-path: + name: Test EF Core hot-path 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/advanced-modeling-performance/EfCoreHotPathMinimal.slnx + + - name: Build + run: > + dotnet build + ef-core/advanced-modeling-performance/EfCoreHotPathMinimal.slnx + --configuration Release + --no-restore + + - name: Test + run: > + dotnet test + ef-core/advanced-modeling-performance/EfCoreHotPathMinimal.slnx + --configuration Release + --no-build + + - name: Verify EF Core package baseline + shell: bash + run: | + project="ef-core/advanced-modeling-performance/src/EfCoreHotPathMinimal/EfCoreHotPathMinimal.csproj" + + test "$( + grep \ + --count \ + ' + + + + + + + diff --git a/ef-core/advanced-modeling-performance/README.md b/ef-core/advanced-modeling-performance/README.md new file mode 100644 index 0000000..deaf681 --- /dev/null +++ b/ef-core/advanced-modeling-performance/README.md @@ -0,0 +1,211 @@ +# EF Core Hot-Path Queries & Set-Based Operations + +A focused EF Core companion demonstrating a lean read path, a stable compiled +query, a named soft-delete query filter, and server-side set-based updates and +deletes without external database infrastructure. + +## Full tutorial + +[EF Core Advanced Modeling & Performance: Owned Types, Converters, JSON/Temporal Tables, Compiled Queries](https://www.dotnet-guide.com/tutorials/ef-core/advanced-modeling-performance/) + +## 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 +SQLite in-memory + -> named soft-delete filter + -> DTO projection + -> AsNoTracking + -> EF.CompileAsyncQuery + -> ExecuteUpdateAsync + -> ExecuteDeleteAsync + -> fresh-context verification +``` + +This sample is intentionally not a benchmark. + +## Why SQLite? + +SQLite gives the companion a real relational database without: + +- credentials; +- a server; +- Docker; +- Testcontainers; +- generated database files. + +The low-level in-memory connection remains open for the lifetime of each +scenario so multiple DbContext instances see the same database. + +SQLite is still a different provider from SQL Server and PostgreSQL. + +This sample does not validate provider-specific JSON, temporal-table, +concurrency-token, migration, query-plan, or batching behavior. + +## Soft-delete filter + +The companion targets EF Core 10 and uses a named query filter: + +```csharp +entity.HasQueryFilter( + "SoftDeleteFilter", + product => !product.IsDeleted); +``` + +The purge path selectively disables it: + +```csharp +IgnoreQueryFilters( + ["SoftDeleteFilter"]) +``` + +The EF Core 8 tutorial must continue to explain that older versions use one +combined filter expression when several filters apply to the same entity. + +## Projection and tracking + +The read path projects only: + +```text +Sku +Name +PriceCents +StockQuantity +``` + +into `ProductSummary`. + +It also uses `AsNoTracking` to make the read-only intent explicit. + +The tests verify that no entity entries remain in the DbContext change tracker. + +## Compiled query + +The sample uses one: + +```text +EF.CompileAsyncQuery +``` + +for a stable SKU lookup. + +This does not mean every query should be manually compiled. + +EF Core already caches ordinary queries by expression-tree shape. Explicit +compiled queries bypass the normal cache lookup and are intended for measured +hot paths with stable query shapes and simple scalar parameters. + +The sample makes no fixed latency-savings claim. + +## Set-based update + +```text +ExecuteUpdateAsync +``` + +increments stock for all visible products below the threshold in one +set-based command. + +The application does not load those products before updating them. + +## Change-tracker caveat + +`ExecuteUpdateAsync` does not synchronize entity instances that were already +tracked by the DbContext. + +The test suite deliberately proves this behavior. + +Use a clear context boundary, reload, or clear tracking when mixing set-based +operations with tracked entities. + +## Set-based delete + +The soft-deleted row is hidden by default. + +The purge operation explicitly disables the named soft-delete filter and calls: + +```text +ExecuteDeleteAsync +``` + +The affected-row count is asserted. + +## Deterministic output + +```text +EF Core Hot-Path Query Lab +Visible products: 4 +Compiled lookup: SKU-003 | Dock | 12900 cents +Restocked products: 3 +Purged soft-deleted products: 1 +Final rows: 4 +``` + +## Restore, build, and test + +```powershell +dotnet restore ` + .\EfCoreHotPathMinimal.slnx + +dotnet build ` + .\EfCoreHotPathMinimal.slnx ` + --configuration Release ` + --no-restore + +dotnet test ` + .\EfCoreHotPathMinimal.slnx ` + --configuration Release ` + --no-build +``` + +## Run + +```powershell +dotnet run ` + --project .\src\EfCoreHotPathMinimal\EfCoreHotPathMinimal.csproj ` + --configuration Release ` + --no-build +``` + +## Deliberately omitted + +- SQL Server; +- PostgreSQL; +- temporal tables; +- JSON columns; +- owned/complex types; +- value converters; +- encryption; +- concurrency tokens; +- migrations; +- Testcontainers; +- split queries; +- logging/interceptors; +- benchmarking. + +The complete tutorial covers the broader modeling and provider-specific +concepts. + +## 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 +- Benchmarks: none +- Last reviewed: 2026-08-14 \ No newline at end of file diff --git a/ef-core/advanced-modeling-performance/src/EfCoreHotPathMinimal/Data/CatalogDatabase.cs b/ef-core/advanced-modeling-performance/src/EfCoreHotPathMinimal/Data/CatalogDatabase.cs new file mode 100644 index 0000000..f7f0de2 --- /dev/null +++ b/ef-core/advanced-modeling-performance/src/EfCoreHotPathMinimal/Data/CatalogDatabase.cs @@ -0,0 +1,139 @@ +using EfCoreHotPathMinimal.Models; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace EfCoreHotPathMinimal.Data; + +public sealed class CatalogDatabase : + IAsyncDisposable +{ + private readonly SqliteConnection + _connection; + + private readonly + DbContextOptions< + CatalogDbContext> + _options; + + private CatalogDatabase( + SqliteConnection connection, + DbContextOptions< + CatalogDbContext> + options) + { + _connection = + connection; + + _options = + options; + } + + public static async Task< + CatalogDatabase> + CreateAsync( + CancellationToken + cancellationToken = + default) + { + var connection = + new SqliteConnection( + "Data Source=:memory:"); + + await connection + .OpenAsync( + cancellationToken); + + try + { + DbContextOptions< + CatalogDbContext> + options = + new DbContextOptionsBuilder< + CatalogDbContext>() + .UseSqlite( + connection) + .EnableDetailedErrors() + .Options; + + var database = + new CatalogDatabase( + connection, + options); + + await using + CatalogDbContext context = + database + .CreateContext(); + + await context.Database + .EnsureCreatedAsync( + cancellationToken); + + context.Products.AddRange( + new Product + { + Id = 1, + Sku = "SKU-001", + Name = "Keyboard", + PriceCents = 7_500, + StockQuantity = 2 + }, + new Product + { + Id = 2, + Sku = "SKU-002", + Name = "Mouse", + PriceCents = 3_500, + StockQuantity = 8 + }, + new Product + { + Id = 3, + Sku = "SKU-003", + Name = "Dock", + PriceCents = 12_900, + StockQuantity = 1 + }, + new Product + { + Id = 4, + Sku = "SKU-004", + Name = "Webcam", + PriceCents = 6_500, + StockQuantity = 0, + IsDeleted = true + }, + new Product + { + Id = 5, + Sku = "SKU-005", + Name = "Stand", + PriceCents = 4_500, + StockQuantity = 4 + }); + + await context + .SaveChangesAsync( + cancellationToken); + + return database; + } + catch + { + await connection + .DisposeAsync(); + + throw; + } + } + + public CatalogDbContext + CreateContext() => + new( + _options); + + public ValueTask + DisposeAsync() => + _connection + .DisposeAsync(); +} \ No newline at end of file diff --git a/ef-core/advanced-modeling-performance/src/EfCoreHotPathMinimal/Data/CatalogDbContext.cs b/ef-core/advanced-modeling-performance/src/EfCoreHotPathMinimal/Data/CatalogDbContext.cs new file mode 100644 index 0000000..9184723 --- /dev/null +++ b/ef-core/advanced-modeling-performance/src/EfCoreHotPathMinimal/Data/CatalogDbContext.cs @@ -0,0 +1,56 @@ +using EfCoreHotPathMinimal.Models; +using Microsoft.EntityFrameworkCore; + +namespace EfCoreHotPathMinimal.Data; + +public sealed class CatalogDbContext( + DbContextOptions + options) : + DbContext( + options) +{ + public const string + SoftDeleteFilterName = + "SoftDeleteFilter"; + + public DbSet Products => + Set(); + + protected override void + OnModelCreating( + ModelBuilder modelBuilder) + { + modelBuilder + .Entity( + entity => + { + entity.HasKey( + product => + product.Id); + + entity.Property( + product => + product.Sku) + .HasMaxLength( + 32) + .IsRequired(); + + entity.Property( + product => + product.Name) + .HasMaxLength( + 120) + .IsRequired(); + + entity.HasIndex( + product => + product.Sku) + .IsUnique(); + + entity.HasQueryFilter( + SoftDeleteFilterName, + product => + !product.IsDeleted); + }); + } +} \ No newline at end of file diff --git a/ef-core/advanced-modeling-performance/src/EfCoreHotPathMinimal/EfCoreHotPathMinimal.csproj b/ef-core/advanced-modeling-performance/src/EfCoreHotPathMinimal/EfCoreHotPathMinimal.csproj new file mode 100644 index 0000000..5342fe8 --- /dev/null +++ b/ef-core/advanced-modeling-performance/src/EfCoreHotPathMinimal/EfCoreHotPathMinimal.csproj @@ -0,0 +1,17 @@ + + + + Exe + net10.0 + enable + enable + true + + + + + + + \ No newline at end of file diff --git a/ef-core/advanced-modeling-performance/src/EfCoreHotPathMinimal/Models/Product.cs b/ef-core/advanced-modeling-performance/src/EfCoreHotPathMinimal/Models/Product.cs new file mode 100644 index 0000000..fd7d211 --- /dev/null +++ b/ef-core/advanced-modeling-performance/src/EfCoreHotPathMinimal/Models/Product.cs @@ -0,0 +1,40 @@ +namespace EfCoreHotPathMinimal.Models; + +public sealed class Product +{ + public int Id + { + get; + set; + } + + public string Sku + { + get; + set; + } = ""; + + public string Name + { + get; + set; + } = ""; + + public int PriceCents + { + get; + set; + } + + public int StockQuantity + { + get; + set; + } + + public bool IsDeleted + { + get; + set; + } +} \ No newline at end of file diff --git a/ef-core/advanced-modeling-performance/src/EfCoreHotPathMinimal/Models/ProductSummary.cs b/ef-core/advanced-modeling-performance/src/EfCoreHotPathMinimal/Models/ProductSummary.cs new file mode 100644 index 0000000..af08c37 --- /dev/null +++ b/ef-core/advanced-modeling-performance/src/EfCoreHotPathMinimal/Models/ProductSummary.cs @@ -0,0 +1,7 @@ +namespace EfCoreHotPathMinimal.Models; + +public sealed record ProductSummary( + string Sku, + string Name, + int PriceCents, + int StockQuantity); \ No newline at end of file diff --git a/ef-core/advanced-modeling-performance/src/EfCoreHotPathMinimal/Program.cs b/ef-core/advanced-modeling-performance/src/EfCoreHotPathMinimal/Program.cs new file mode 100644 index 0000000..b657241 --- /dev/null +++ b/ef-core/advanced-modeling-performance/src/EfCoreHotPathMinimal/Program.cs @@ -0,0 +1,25 @@ +using EfCoreHotPathMinimal.Services; + +CatalogWorkflowResult result = + await CatalogWorkflow + .RunAsync(); + +Console.WriteLine( + "EF Core Hot-Path Query Lab"); + +Console.WriteLine( + $"Visible products: {result.VisibleProducts}"); + +Console.WriteLine( + $"Compiled lookup: {result.CompiledLookup.Sku} | " + + $"{result.CompiledLookup.Name} | " + + $"{result.CompiledLookup.PriceCents} cents"); + +Console.WriteLine( + $"Restocked products: {result.RestockedProducts}"); + +Console.WriteLine( + $"Purged soft-deleted products: {result.PurgedProducts}"); + +Console.WriteLine( + $"Final rows: {result.FinalRows}"); \ No newline at end of file diff --git a/ef-core/advanced-modeling-performance/src/EfCoreHotPathMinimal/Queries/CatalogQueries.cs b/ef-core/advanced-modeling-performance/src/EfCoreHotPathMinimal/Queries/CatalogQueries.cs new file mode 100644 index 0000000..207ac53 --- /dev/null +++ b/ef-core/advanced-modeling-performance/src/EfCoreHotPathMinimal/Queries/CatalogQueries.cs @@ -0,0 +1,86 @@ +using EfCoreHotPathMinimal.Data; +using EfCoreHotPathMinimal.Models; +using Microsoft.EntityFrameworkCore; + +namespace EfCoreHotPathMinimal.Queries; + +public static class CatalogQueries +{ + private static readonly + Func< + CatalogDbContext, + string, + IAsyncEnumerable< + ProductSummary>> + FindBySkuCompiled = + EF.CompileAsyncQuery( + ( + CatalogDbContext + context, + string sku) => + context.Products + .AsNoTracking() + .Where( + product => + product.Sku + == sku) + .OrderBy( + product => + product.Id) + .Select( + product => + new ProductSummary( + product.Sku, + product.Name, + product.PriceCents, + product.StockQuantity)) + .Take( + 1)); + + public static Task< + List> + ListVisibleAsync( + CatalogDbContext + context, + CancellationToken + cancellationToken = + default) => + context.Products + .AsNoTracking() + .OrderBy( + product => + product.Id) + .Select( + product => + new ProductSummary( + product.Sku, + product.Name, + product.PriceCents, + product.StockQuantity)) + .ToListAsync( + cancellationToken); + + public static async Task< + ProductSummary?> + FindBySkuAsync( + CatalogDbContext + context, + string sku, + CancellationToken + cancellationToken = + default) + { + await foreach ( + ProductSummary product + in FindBySkuCompiled( + context, + sku) + .WithCancellation( + cancellationToken)) + { + return product; + } + + return null; + } +} \ No newline at end of file diff --git a/ef-core/advanced-modeling-performance/src/EfCoreHotPathMinimal/Services/CatalogWorkflow.cs b/ef-core/advanced-modeling-performance/src/EfCoreHotPathMinimal/Services/CatalogWorkflow.cs new file mode 100644 index 0000000..2365a2b --- /dev/null +++ b/ef-core/advanced-modeling-performance/src/EfCoreHotPathMinimal/Services/CatalogWorkflow.cs @@ -0,0 +1,108 @@ +using EfCoreHotPathMinimal.Data; +using EfCoreHotPathMinimal.Models; +using EfCoreHotPathMinimal.Queries; +using Microsoft.EntityFrameworkCore; + +namespace EfCoreHotPathMinimal.Services; + +public sealed record + CatalogWorkflowResult( + int VisibleProducts, + ProductSummary + CompiledLookup, + int RestockedProducts, + int PurgedProducts, + int FinalRows); + +public static class CatalogWorkflow +{ + public static async Task< + CatalogWorkflowResult> + RunAsync( + CancellationToken + cancellationToken = + default) + { + await using + CatalogDatabase database = + await CatalogDatabase + .CreateAsync( + cancellationToken); + + await using + CatalogDbContext context = + database + .CreateContext(); + + List + visible = + await CatalogQueries + .ListVisibleAsync( + context, + cancellationToken); + + ProductSummary lookup = + await CatalogQueries + .FindBySkuAsync( + context, + "SKU-003", + cancellationToken) + ?? throw new InvalidOperationException( + "Seeded SKU-003 was not found."); + + int restocked = + await context.Products + .Where( + product => + product.StockQuantity + < 5) + .ExecuteUpdateAsync( + setters => + setters.SetProperty( + product => + product.StockQuantity, + product => + product.StockQuantity + + 5), + cancellationToken); + + int purged = + await context.Products + .IgnoreQueryFilters( + [ + CatalogDbContext + .SoftDeleteFilterName + ]) + .Where( + product => + product.IsDeleted) + .ExecuteDeleteAsync( + cancellationToken); + + int finalRows = + await context.Products + .IgnoreQueryFilters( + [ + CatalogDbContext + .SoftDeleteFilterName + ]) + .CountAsync( + cancellationToken); + + return new CatalogWorkflowResult( + VisibleProducts: + visible.Count, + + CompiledLookup: + lookup, + + RestockedProducts: + restocked, + + PurgedProducts: + purged, + + FinalRows: + finalRows); + } +} \ No newline at end of file diff --git a/ef-core/advanced-modeling-performance/tests/EfCoreHotPathMinimal.Tests/EfCoreHotPathMinimal.Tests.csproj b/ef-core/advanced-modeling-performance/tests/EfCoreHotPathMinimal.Tests/EfCoreHotPathMinimal.Tests.csproj new file mode 100644 index 0000000..2748df6 --- /dev/null +++ b/ef-core/advanced-modeling-performance/tests/EfCoreHotPathMinimal.Tests/EfCoreHotPathMinimal.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/advanced-modeling-performance/tests/EfCoreHotPathMinimal.Tests/EfCoreHotPathTests.cs b/ef-core/advanced-modeling-performance/tests/EfCoreHotPathMinimal.Tests/EfCoreHotPathTests.cs new file mode 100644 index 0000000..0aaff9a --- /dev/null +++ b/ef-core/advanced-modeling-performance/tests/EfCoreHotPathMinimal.Tests/EfCoreHotPathTests.cs @@ -0,0 +1,426 @@ +using EfCoreHotPathMinimal.Data; +using EfCoreHotPathMinimal.Models; +using EfCoreHotPathMinimal.Queries; +using EfCoreHotPathMinimal.Services; +using Microsoft.EntityFrameworkCore; + +namespace EfCoreHotPathMinimal.Tests; + +public sealed class EfCoreHotPathTests +{ + [Fact] + public async Task + Global_filter_excludes_soft_deleted_rows() + { + CancellationToken token = + TestContext.Current + .CancellationToken; + + await using + CatalogDatabase database = + await CatalogDatabase + .CreateAsync( + token); + + await using + CatalogDbContext context = + database + .CreateContext(); + + int visible = + await context.Products + .CountAsync( + token); + + int all = + await context.Products + .IgnoreQueryFilters( + [ + CatalogDbContext + .SoftDeleteFilterName + ]) + .CountAsync( + token); + + Assert.Equal( + 4, + visible); + + Assert.Equal( + 5, + all); + } + + [Fact] + public async Task + Read_projection_does_not_track_entities() + { + CancellationToken token = + TestContext.Current + .CancellationToken; + + await using + CatalogDatabase database = + await CatalogDatabase + .CreateAsync( + token); + + await using + CatalogDbContext context = + database + .CreateContext(); + + List + products = + await CatalogQueries + .ListVisibleAsync( + context, + token); + + Assert.Equal( + 4, + products.Count); + + Assert.Empty( + context.ChangeTracker + .Entries()); + } + + [Fact] + public async Task + Compiled_lookup_returns_projected_product() + { + CancellationToken token = + TestContext.Current + .CancellationToken; + + await using + CatalogDatabase database = + await CatalogDatabase + .CreateAsync( + token); + + await using + CatalogDbContext context = + database + .CreateContext(); + + ProductSummary? product = + await CatalogQueries + .FindBySkuAsync( + context, + "SKU-003", + token); + + Assert.NotNull( + product); + + Assert.Equal( + "Dock", + product.Name); + + Assert.Equal( + 12_900, + product.PriceCents); + + Assert.Equal( + 1, + product.StockQuantity); + + Assert.Empty( + context.ChangeTracker + .Entries()); + } + + [Fact] + public async Task + Compiled_lookup_respects_soft_delete_filter() + { + CancellationToken token = + TestContext.Current + .CancellationToken; + + await using + CatalogDatabase database = + await CatalogDatabase + .CreateAsync( + token); + + await using + CatalogDbContext context = + database + .CreateContext(); + + ProductSummary? product = + await CatalogQueries + .FindBySkuAsync( + context, + "SKU-004", + token); + + Assert.Null( + product); + + bool rowExists = + await context.Products + .IgnoreQueryFilters( + [ + CatalogDbContext + .SoftDeleteFilterName + ]) + .AnyAsync( + candidate => + candidate.Sku + == "SKU-004", + token); + + Assert.True( + rowExists); + } + + [Fact] + public async Task + ExecuteUpdate_updates_matching_rows_and_returns_count() + { + CancellationToken token = + TestContext.Current + .CancellationToken; + + await using + CatalogDatabase database = + await CatalogDatabase + .CreateAsync( + token); + + await using + CatalogDbContext context = + database + .CreateContext(); + + int updated = + await context.Products + .Where( + product => + product.StockQuantity + < 5) + .ExecuteUpdateAsync( + setters => + setters.SetProperty( + product => + product.StockQuantity, + product => + product.StockQuantity + + 5), + token); + + Assert.Equal( + 3, + updated); + + await using + CatalogDbContext verification = + database + .CreateContext(); + + Dictionary< + string, + int> + stock = + await verification + .Products + .AsNoTracking() + .ToDictionaryAsync( + product => + product.Sku, + product => + product.StockQuantity, + token); + + Assert.Equal( + 7, + stock["SKU-001"]); + + Assert.Equal( + 8, + stock["SKU-002"]); + + Assert.Equal( + 6, + stock["SKU-003"]); + + Assert.Equal( + 9, + stock["SKU-005"]); + } + + [Fact] + public async Task + ExecuteUpdate_does_not_synchronize_pretracked_entity() + { + CancellationToken token = + TestContext.Current + .CancellationToken; + + await using + CatalogDatabase database = + await CatalogDatabase + .CreateAsync( + token); + + await using + CatalogDbContext context = + database + .CreateContext(); + + Product tracked = + await context.Products + .SingleAsync( + product => + product.Sku + == "SKU-001", + token); + + Assert.Equal( + 2, + tracked.StockQuantity); + + int updated = + await context.Products + .Where( + product => + product.Sku + == "SKU-001") + .ExecuteUpdateAsync( + setters => + setters.SetProperty( + product => + product.StockQuantity, + product => + product.StockQuantity + + 10), + token); + + Assert.Equal( + 1, + updated); + + Assert.Equal( + 2, + tracked.StockQuantity); + + await using + CatalogDbContext verification = + database + .CreateContext(); + + int stored = + await verification + .Products + .AsNoTracking() + .Where( + product => + product.Sku + == "SKU-001") + .Select( + product => + product.StockQuantity) + .SingleAsync( + token); + + Assert.Equal( + 12, + stored); + } + + [Fact] + public async Task + ExecuteDelete_can_purge_soft_deleted_rows_explicitly() + { + CancellationToken token = + TestContext.Current + .CancellationToken; + + await using + CatalogDatabase database = + await CatalogDatabase + .CreateAsync( + token); + + await using + CatalogDbContext context = + database + .CreateContext(); + + int deleted = + await context.Products + .IgnoreQueryFilters( + [ + CatalogDbContext + .SoftDeleteFilterName + ]) + .Where( + product => + product.IsDeleted) + .ExecuteDeleteAsync( + token); + + Assert.Equal( + 1, + deleted); + + await using + CatalogDbContext verification = + database + .CreateContext(); + + int all = + await verification + .Products + .IgnoreQueryFilters( + [ + CatalogDbContext + .SoftDeleteFilterName + ]) + .CountAsync( + token); + + Assert.Equal( + 4, + all); + } + + [Fact] + public async Task + Workflow_returns_deterministic_summary() + { + CatalogWorkflowResult result = + await CatalogWorkflow + .RunAsync( + TestContext.Current + .CancellationToken); + + Assert.Equal( + 4, + result.VisibleProducts); + + Assert.Equal( + "SKU-003", + result.CompiledLookup.Sku); + + Assert.Equal( + "Dock", + result.CompiledLookup.Name); + + Assert.Equal( + 3, + result.RestockedProducts); + + Assert.Equal( + 1, + result.PurgedProducts); + + Assert.Equal( + 4, + result.FinalRows); + } +} \ No newline at end of file