From 916fc1d1ac0e60659b24fc4e48bae0965501558e Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Mon, 20 Apr 2026 13:18:18 -0500 Subject: [PATCH] EF Core dev-time improvements (GH-2539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the EF Core dev-loop faster and more discoverable: - Auto-register Weasel's DatabaseCleaner<> (open generic) inside UseEntityFrameworkCoreTransactions(). Callers no longer need a per-context services.AddDatabaseCleaner() — resolving IDatabaseCleaner / DatabaseCleaner just works. - New IHost.ResetAllDataAsync() extension for finer-grained per-DbContext test teardown. Creates its own scope, resolves the DbContext first, then the cleaner, then runs delete+reseed. - New documentation: * docs/guide/durability/efcore/index.md — Development-time usage section covering Weasel migrations vs EF Core migrations, IInitialData mention, and reset guidance. * docs/guide/durability/efcore/initial-data.md — IInitialData patterns: class-based vs lambda, layered seeders, idempotency, when not to use. * docs/guide/durability/efcore/batch-queries.md — four handler patterns for the Wolverine batching story, with locally measured perf numbers (2.78x speedup, 4-query handler) and links out to Weasel's fluent BatchedQuery reference. - Weasel link sweep: replaced github.com links with https://weasel.jasperfx.net/... where applicable. - ItemService sample: SeedSampleItems IInitialData registered via AddInitialData(), with a comment showing the Weasel 8.14 lambda overload. - Bumps Weasel.* package references to 8.14.0 to pick up the new LambdaInitialData + AddInitialData lambda overload (JasperFx/weasel#250). Tests: 7/7 pass for the cleaner-related suite (auto_database_cleaner_registration_tests + database_cleaner_usage_tests). Co-Authored-By: Claude Opus 4.7 (1M context) --- Directory.Packages.props | 14 +- docs/.vitepress/config.mts | 4 +- docs/guide/durability/efcore/batch-queries.md | 160 ++++++++++++++++++ docs/guide/durability/efcore/index.md | 75 ++++++++ docs/guide/durability/efcore/initial-data.md | 96 +++++++++++ docs/guide/durability/efcore/migrations.md | 2 +- docs/guide/durability/efcore/query-plans.md | 2 +- docs/guide/durability/managing.md | 2 +- .../auto_database_cleaner_tests.cs | 115 +++++++++++++ .../HostResetExtensions.cs | 38 +++++ .../WolverineEntityCoreExtensions.cs | 9 + .../EFCoreSample/ItemService/Program.cs | 16 ++ .../ItemService/SeedSampleItems.cs | 25 +++ 13 files changed, 547 insertions(+), 11 deletions(-) create mode 100644 docs/guide/durability/efcore/batch-queries.md create mode 100644 docs/guide/durability/efcore/initial-data.md create mode 100644 src/Persistence/EfCoreTests/auto_database_cleaner_tests.cs create mode 100644 src/Persistence/Wolverine.EntityFrameworkCore/HostResetExtensions.cs create mode 100644 src/Samples/EFCoreSample/ItemService/SeedSampleItems.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 70e8c08de..b234a984b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -100,13 +100,13 @@ - - - - - - - + + + + + + + diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index a3d96ee6c..c1b040093 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -339,7 +339,9 @@ const config: UserConfig = { {text: 'Multi-Tenancy', link: '/guide/durability/efcore/multi-tenancy'}, {text: 'Domain Events', link: '/guide/durability/efcore/domain-events'}, {text: 'Database Migrations', link: '/guide/durability/efcore/migrations'}, - {text: 'Query Plans', link: '/guide/durability/efcore/query-plans'} + {text: 'Query Plans', link: '/guide/durability/efcore/query-plans'}, + {text: 'Batch Queries', link: '/guide/durability/efcore/batch-queries'}, + {text: 'Initial Data', link: '/guide/durability/efcore/initial-data'} ]}, {text: 'Managing Message Storage', link: '/guide/durability/managing'}, diff --git a/docs/guide/durability/efcore/batch-queries.md b/docs/guide/durability/efcore/batch-queries.md new file mode 100644 index 000000000..ffe164147 --- /dev/null +++ b/docs/guide/durability/efcore/batch-queries.md @@ -0,0 +1,160 @@ +# Batch Queries / Futures + +Wolverine's EF Core integration can collapse multiple related `SELECT`s inside one handler into a single database round-trip, using [Weasel's `BatchedQuery`](https://weasel.jasperfx.net/efcore/batch-queries.html) (the EF Core counterpart to Marten's [batched query](https://martendb.io/documents/querying/batched-queries.html)) as the underlying mechanism. + +This page is Wolverine-focused: handler patterns, auto-batching, what the code generator does for you. For the underlying `BatchedQuery` fluent API, see [Weasel's batch-queries guide](https://weasel.jasperfx.net/efcore/batch-queries.html). + +## Why batch? + +Every extra `SELECT` in a handler is another round-trip to the database. Four queries against a local SQL Server, same handler, four small rows: + +| Strategy | Total (50 iterations) | Per handler | Relative | +|---|---|---|---| +| Sequential `await`s | 345.8 ms | **6.92 ms** | 1.0× | +| Batched via `BatchedQuery` | 124.3 ms | **2.49 ms** | **2.78×** | + +Measured locally against `mcr.microsoft.com/mssql/server`, 4 keyed lookups per iteration, after warm-up. The speedup scales with (a) number of queries in the handler and (b) network latency — across a region-to-region hop, a four-query handler can drop from ~40 ms to ~12 ms. + +## Pattern 1 — Two `IQueryPlan`s on one handler + +The simplest win. If your handler [uses query plans](./query-plans) that implement *both* `IQueryPlan` **and** `IBatchQueryPlan` (which `QueryPlan` and `QueryListPlan` do automatically), Wolverine's code generator detects multiple batch-capable loads on the same handler and rewrites them into a shared `BatchedQuery`: + +```csharp +public class ShipmentOverview +{ + public Customer Customer { get; set; } = null!; + public IReadOnlyList RecentOrders { get; set; } = []; +} + +public record GetShipmentOverview(Guid CustomerId); + +public static class ShipmentOverviewHandler +{ + public static async Task Handle( + GetShipmentOverview query, + CustomerById customerSpec, // IQueryPlan + IBatchQueryPlan via QueryPlan<> + RecentOrdersFor ordersSpec) // " " QueryListPlan<> + { + return new ShipmentOverview + { + Customer = await customerSpec.FetchAsync(...), + RecentOrders = await ordersSpec.FetchAsync(...) + }; + } +} +``` + +Wolverine's `EFCoreBatchingPolicy` inspects the handler chain, detects the two batch-capable plans, and generates code equivalent to: + +```csharp +// Generated (simplified) +var batch = db.CreateBatchQuery(); +var customerTask = customerSpec.FetchAsync(batch, db); +var ordersTask = ordersSpec.FetchAsync(batch, db); +await batch.ExecuteAsync(cancellation); +var customer = await customerTask; +var orders = await ordersTask; +``` + +One round-trip, no manual batch wiring, no change to the plan classes. + +## Pattern 2 — Manual `IBatchQueryPlan` + +When a query doesn't fit the `QueryPlan<>` / `QueryListPlan<>` shape — for example, a projection into a DTO or a pre-aggregated count — implement `IBatchQueryPlan` directly: + +```csharp +public class OrderCountFor(Guid customerId) : IBatchQueryPlan +{ + public Task FetchAsync(BatchedQuery batch, OrderDbContext db) + => batch.QueryCount(db.Orders.Where(x => x.CustomerId == customerId)); +} +``` + +Any handler parameter implementing `IBatchQueryPlan` gets the same auto-batching treatment as `IQueryPlan`-derived plans. Use `IBatchQueryPlan` when you need full control over the batched shape; use `QueryPlan<>` / `QueryListPlan<>` when you want the plan to work both standalone and batched. + +## Pattern 3 — `[Entity]` primary-key lookup + a spec in the same handler + +`[Entity]` lookups and query plans batch together. A common shape — load the root aggregate by id, then fetch a related collection with a spec: + +```csharp +public record ArchiveOrderLines(Guid OrderId); + +public static class ArchiveOrderLinesHandler +{ + public static async Task Handle( + ArchiveOrderLines cmd, + [Entity] Order order, // PK lookup — batch-capable + ActiveLinesFor linesSpec, // QueryListPlan — batch-capable + OrderDbContext db) + { + order.Archive(); + foreach (var line in await linesSpec.FetchAsync(...)) + { + line.Archive(); + } + await db.SaveChangesAsync(); + } +} +``` + +Both the `[Entity]` load and the spec fetch are enlisted into a single `BatchedQuery`, one round-trip for two reads. + +## Pattern 4 — Count + page in one round-trip + +Paginated list responses almost always want `(totalCount, currentPage)`. Two queries, same filter, one logical operation. Keep it one round-trip: + +```csharp +public record OrderSearch(string? Customer, int Page, int PageSize); + +public class OrderSearchCount(string? customer) : IBatchQueryPlan +{ + public Task FetchAsync(BatchedQuery batch, OrderDbContext db) + => batch.QueryCount(OrdersMatching(db, customer)); +} + +public class OrderSearchPage(string? customer, int page, int pageSize) + : IBatchQueryPlan> +{ + public async Task> FetchAsync(BatchedQuery batch, OrderDbContext db) + { + var list = await batch.Query( + OrdersMatching(db, customer) + .OrderByDescending(x => x.PlacedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize)); + return list.ToList(); + } +} + +static IQueryable OrdersMatching(OrderDbContext db, string? customer) + => customer is null + ? db.Orders + : db.Orders.Where(x => x.Customer.Name.Contains(customer)); + +public static class OrderSearchHandler +{ + public static async Task<(int Total, IReadOnlyList Page)> Handle( + OrderSearch query, + OrderSearchCount countPlan, + OrderSearchPage pagePlan) + { + return (await countPlan.FetchAsync(...), await pagePlan.FetchAsync(...)); + } +} +``` + +One `SELECT COUNT(*)` + one paginated `SELECT`, merged into a single `DbBatch` on the wire. + +## Opting out + +If a specific handler genuinely needs sequential round-trips (e.g. because a later query depends on an earlier query's result), just don't use batch-capable plans — the batching policy only fires when two or more `IBatchQueryPlan` parameters appear on the same handler. + +## Testing batched handlers + +The batching is transparent to tests. The same handler that uses `IQueryPlan` parameters can be unit-tested against an in-memory `DbContext` the normal way — query plans execute standalone through `FetchAsync(DbContext)` when invoked outside a `BatchedQuery`. See [Query Plans → Testing a plan in isolation](./query-plans#testing-a-plan-in-isolation). + +## Further reading + +- [Weasel — `BatchedQuery` fluent API reference](https://weasel.jasperfx.net/efcore/batch-queries.html) +- [Query Plans](./query-plans) — the Specification model that backs the auto-batching +- [Marten — Batched Queries](https://martendb.io/documents/querying/batched-queries.html) — the Critter Stack sibling this mirrors diff --git a/docs/guide/durability/efcore/index.md b/docs/guide/durability/efcore/index.md index 9ee4b5ef2..bc8cdd529 100644 --- a/docs/guide/durability/efcore/index.md +++ b/docs/guide/durability/efcore/index.md @@ -88,3 +88,78 @@ builder.UseWolverine(opts => Right now, we've tested Wolverine with EF Core using both [SQL Server](/guide/durability/sqlserver) and [PostgreSQL](/guide/durability/postgresql) persistence. + +## Development-time usage + +Wolverine + EF Core is designed to keep the dev loop short: fast schema iteration, cheap per-test database resets, declarative seed data. The three pillars below all work together — and all come for free the moment you call `UseEntityFrameworkCoreTransactions()`. + +### Weasel-managed schema migrations + +`UseEntityFrameworkCoreWolverineManagedMigrations()` hands schema management to [Weasel](https://weasel.jasperfx.net/efcore/migrations.html) rather than EF Core's migration-chain tooling. The shape of the story: + +| | EF Core migrations | Weasel migrations | +|---|---|---| +| Model | Ordered chain of up/down scripts checked in alongside code | Diff the live database against the current `DbContext` model at startup | +| Authoring | Generate + edit migration classes | Nothing — just change your model | +| Iteration cost | Slow (script regeneration, merge conflicts on parallel branches) | None — restart the app | +| Best for | Production deployments with a change audit | Local dev, integration tests, short-lived branches | + +Register it on `WolverineOptions`: + +```csharp +builder.UseWolverine(opts => +{ + opts.Services.AddDbContextWithWolverineIntegration( + x => x.UseSqlServer(connectionString)); + + // Diff the DbContext against the live DB at startup and apply missing DDL. + opts.UseEntityFrameworkCoreWolverineManagedMigrations(); +}); +``` + +The [Weasel docs](https://weasel.jasperfx.net/efcore/migrations.html) go deeper on the diff engine, opt-outs, and how it handles schemas. + +### IInitialData — declarative seed data + +Implement `Weasel.EntityFrameworkCore.IInitialData` (or register a lambda with `services.AddInitialData(...)`) to declare data that should be present every time the database is reset: + +```csharp +public class SeedItems : IInitialData +{ + public async Task Populate(ItemsDbContext context, CancellationToken cancellation) + { + context.Items.Add(new Item { Name = "Seed" }); + await context.SaveChangesAsync(cancellation); + } +} + +builder.Services.AddInitialData(); +``` + +Multiple seeders run in registration order. See the [dedicated page on initial data](./initial-data) for patterns around layered seeders, lambda-based registration, and multi-tenant seeding. + +### Resetting data between tests + +Two knobs, finest-grained first: + +**Per-DbContext — `host.ResetAllDataAsync()`** + +Wipes one `DbContext`'s tables in FK-safe order and reruns that context's `IInitialData` seeders. This is the right default for most integration tests: + +```csharp +[Fact] +public async Task ordering_flow() +{ + await _host.ResetAllDataAsync(); + + // arrange ... act ... assert +} +``` + +The underlying `DatabaseCleaner` is registered automatically by `UseEntityFrameworkCoreTransactions()` — no `services.AddDatabaseCleaner()` needed. + +**Global — `host.ResetResourceState()`** + +Resets every `IStatefulResource` registered with the host — Wolverine's message store, every broker, every `DbContext` cleaner. Bigger hammer; right when a test writes to multiple stores or you've seen cross-test contamination you can't isolate. + +**Recommendation:** use the finest-grained mechanism the test actually needs. Resetting the world on every test multiplies your suite runtime for no benefit. diff --git a/docs/guide/durability/efcore/initial-data.md b/docs/guide/durability/efcore/initial-data.md new file mode 100644 index 000000000..75f7ee242 --- /dev/null +++ b/docs/guide/durability/efcore/initial-data.md @@ -0,0 +1,96 @@ +# Initial Data + +`Weasel.EntityFrameworkCore.IInitialData` is the declarative seed-data hook that runs every time a `DbContext` is reset via `DatabaseCleaner.ResetAllDataAsync()` or Wolverine's [`host.ResetAllDataAsync()`](./index#resetting-data-between-tests). It's the recommended way to keep integration tests and local dev loops seeded with a known baseline without hand-rolling setup code per test. + +See the [Weasel docs on `IInitialData`](https://weasel.jasperfx.net/efcore/database-cleaner.html#iinitialdata) for the authoritative reference — this page is a Wolverine-focused overview of how to use it well. + +## Class-based seeders + +The traditional form. Implement `Populate`, register with `services.AddInitialData()`: + +```csharp +public class SeedCoreItems : IInitialData +{ + public static readonly Item[] Items = + [ + new Item { Name = "Alpha" }, + new Item { Name = "Beta" } + ]; + + public async Task Populate(ItemsDbContext context, CancellationToken cancellation) + { + context.Items.AddRange(Items); + await context.SaveChangesAsync(cancellation); + } +} + +// Registration +builder.Services.AddInitialData(); +``` + +## Lambda seeders + +For small amounts of seed data, authoring a class is overkill. Weasel 8.14+ exposes a lambda overload: + +```csharp +builder.Services.AddInitialData(async (ctx, ct) => +{ + ctx.Items.Add(new Item { Name = "Gamma" }); + await ctx.SaveChangesAsync(ct); +}); +``` + +Class-based and lambda seeders can be freely mixed in the same application; they're all resolved as `IEnumerable>` at reset time and run in registration order. + +## Layered seeders + +Because every registered `IInitialData` runs on every reset, you can compose seed data by registering multiple seeders rather than stuffing everything into one class: + +```csharp +// Baseline data needed by every test suite. +builder.Services.AddInitialData(); + +// Only in Development, add some demo rows. +if (builder.Environment.IsDevelopment()) +{ + builder.Services.AddInitialData(async (ctx, ct) => + { + ctx.Items.Add(new Item { Name = "Dev Demo" }); + await ctx.SaveChangesAsync(ct); + }); +} +``` + +This is easier to maintain than a single "giant seeder" and plays well with conditional registration (environment, feature flags, tenant). + +## Interaction with `ResetAllDataAsync` + +`host.ResetAllDataAsync()` does two things, in order: + +1. Delete every row from the tables mapped by `ItemsDbContext` in foreign-key-safe order. +2. Invoke every registered `IInitialData`. + +So the contract of a seeder is: **after me, the database contains whatever I just wrote, plus whatever later seeders write.** If you need the row to exist after `ResetAllDataAsync()`, put it in an `IInitialData`. + +## Idempotency + +Seeders are expected to be idempotent across resets — the cleaner always deletes first, so you can use fixed primary keys without collision concerns: + +```csharp +public async Task Populate(ItemsDbContext context, CancellationToken cancellation) +{ + context.Items.Add(new Item + { + Id = Guid.Parse("10000000-0000-0000-0000-000000000001"), + Name = "Deterministic seed" + }); + await context.SaveChangesAsync(cancellation); +} +``` + +This makes assertions trivial: look up the known Id, no need to keep a reference returned from setup. + +## When not to use `IInitialData` + +- **Production bootstrap data** — `IInitialData` runs on *reset*, not on every app start. For data that should exist on first deploy, use EF Core's [`UseSeeding`](https://learn.microsoft.com/ef/core/modeling/data-seeding) or an explicit setup command. +- **Per-test unique fixtures** — if each test needs fundamentally different baseline data, keep that inside the test (Arrange) rather than baking it into shared seeders. `IInitialData` is for the *common floor* across your suite. diff --git a/docs/guide/durability/efcore/migrations.md b/docs/guide/durability/efcore/migrations.md index db7aee88f..8fe80f84a 100644 --- a/docs/guide/durability/efcore/migrations.md +++ b/docs/guide/durability/efcore/migrations.md @@ -1,6 +1,6 @@ # Database Migrations -Wolverine uses [Weasel](https://github.com/JasperFx/weasel) for schema management of EF Core `DbContext` types rather than EF Core's own migration system. This approach provides a consistent schema management experience across the entire "critter stack" (Wolverine + Marten) and avoids issues with EF Core's `Database.EnsureCreatedAsync()` bypassing migration history. +Wolverine uses [Weasel](https://weasel.jasperfx.net/) for schema management of EF Core `DbContext` types rather than EF Core's own migration system. This approach provides a consistent schema management experience across the entire "critter stack" (Wolverine + Marten) and avoids issues with EF Core's `Database.EnsureCreatedAsync()` bypassing migration history. See the [Weasel EF Core migration docs](https://weasel.jasperfx.net/efcore/migrations.html) for the underlying diff engine, provider-specific behavior, and opt-outs. ## How It Works diff --git a/docs/guide/durability/efcore/query-plans.md b/docs/guide/durability/efcore/query-plans.md index 34631efbe..facc0c742 100644 --- a/docs/guide/durability/efcore/query-plans.md +++ b/docs/guide/durability/efcore/query-plans.md @@ -124,7 +124,7 @@ You can also return a plan instance directly from a handler's `Load` / plan type in the return and auto-executes it, passing the materialized result to `Handle` / `Validate` / `After` parameters. When multiple batch-capable plans target the same `DbContext` on one handler, they share -a single [Weasel `BatchedQuery`](https://github.com/JasperFx/weasel) — +a single [Weasel `BatchedQuery`](https://weasel.jasperfx.net/efcore/batch-queries.html) — **one database round-trip for all plans**. ```csharp diff --git a/docs/guide/durability/managing.md b/docs/guide/durability/managing.md index 7aa07a398..76d275cbe 100644 --- a/docs/guide/durability/managing.md +++ b/docs/guide/durability/managing.md @@ -115,7 +115,7 @@ The available commands are: storage Administer the envelope storage ``` -There's admittedly some duplication here with different options coming from [Oakton](https://jasperfx.github.io/oakton) itself, the [Weasel.CommandLine](https://github.com/JasperFx/weasel) library, +There's admittedly some duplication here with different options coming from [Oakton](https://jasperfx.github.io/oakton) itself, the [Weasel.CommandLine](https://weasel.jasperfx.net/) library, and the `storage` command from Wolverine itself. To build out the schema objects for [message persistence](/guide/durability/), you can use this command to apply any outstanding database changes necessary to bring the database schema to the Wolverine configuration: diff --git a/src/Persistence/EfCoreTests/auto_database_cleaner_tests.cs b/src/Persistence/EfCoreTests/auto_database_cleaner_tests.cs new file mode 100644 index 000000000..b5832884a --- /dev/null +++ b/src/Persistence/EfCoreTests/auto_database_cleaner_tests.cs @@ -0,0 +1,115 @@ +using IntegrationTests; +using JasperFx.Resources; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using SharedPersistenceModels.Items; +using Shouldly; +using Weasel.EntityFrameworkCore; +using Wolverine; +using Wolverine.EntityFrameworkCore; +using Wolverine.SqlServer; + +namespace EfCoreTests; + +/// +/// Verifies the GH-2539 dev-time ergonomics wins on the Wolverine side: +/// 1. +/// auto-registers Weasel's open-generic / +/// so callers no longer need a per-context +/// services.AddDatabaseCleaner<T>(). +/// 2. The new host.ResetAllDataAsync<T>() extension reaches through a +/// scope, resolves the cleaner, and wipes+reseeds the one DbContext. +/// +public class AutoDatabaseCleanerContext : IAsyncLifetime +{ + public IHost Host { get; private set; } = null!; + + public async Task InitializeAsync() + { + Host = await Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder() + .ConfigureServices((_, services) => + { + services.AddDbContext(x => x.UseSqlServer(Servers.SqlServerConnectionString)); + + // NOTE: no explicit AddDatabaseCleaner() here — that + // is exactly the registration we're eliminating. Seed data still + // needs to be registered; only the cleaner itself is now automatic. + services.AddInitialData(); + }) + .UseWolverine(opts => + { + opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString); + opts.UseEntityFrameworkCoreTransactions(); + opts.Services.AddResourceSetupOnStartup(StartupAction.ResetState); + opts.UseEntityFrameworkCoreWolverineManagedMigrations(); + opts.Policies.AutoApplyTransactions(); + }) + .StartAsync(); + } + + public async Task DisposeAsync() + { + await Host.StopAsync(); + Host.Dispose(); + } +} + +[Collection("sqlserver")] +public class auto_database_cleaner_registration_tests : IClassFixture +{ + private readonly AutoDatabaseCleanerContext _ctx; + + public auto_database_cleaner_registration_tests(AutoDatabaseCleanerContext ctx) + { + _ctx = ctx; + } + + [Fact] + public void IDatabaseCleaner_of_T_resolves_without_explicit_registration() + { + // UseEntityFrameworkCoreTransactions() alone should be enough. + var cleaner = _ctx.Host.Services.GetRequiredService>(); + cleaner.ShouldNotBeNull(); + cleaner.ShouldBeOfType>(); + } + + [Fact] + public void concrete_DatabaseCleaner_of_T_also_resolves() + { + // Consumers that want the concrete type (e.g. internal helpers) should + // also get it from DI without registering it themselves. + var cleaner = _ctx.Host.Services.GetRequiredService>(); + cleaner.ShouldNotBeNull(); + } + + [Fact] + public void auto_registration_is_singleton() + { + var a = _ctx.Host.Services.GetRequiredService>(); + var b = _ctx.Host.Services.GetRequiredService>(); + a.ShouldBeSameAs(b); + } + + [Fact] + public async Task host_ResetAllDataAsync_deletes_then_reseeds() + { + // Seed noise that should not survive the reset. + using (var scope = _ctx.Host.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Items.Add(new Item { Id = Guid.NewGuid(), Name = "Noise" }); + await db.SaveChangesAsync(); + } + + // Act: the new one-liner for test teardown. + await _ctx.Host.ResetAllDataAsync(); + + using var check = _ctx.Host.Services.CreateScope(); + var checkDb = check.ServiceProvider.GetRequiredService(); + var items = await checkDb.Items.OrderBy(x => x.Name).ToListAsync(); + + items.Select(x => x.Name).ShouldBe( + SeedItemsForTests.Items.Select(x => x.Name).OrderBy(n => n)); + } +} diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/HostResetExtensions.cs b/src/Persistence/Wolverine.EntityFrameworkCore/HostResetExtensions.cs new file mode 100644 index 000000000..3ff690341 --- /dev/null +++ b/src/Persistence/Wolverine.EntityFrameworkCore/HostResetExtensions.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Weasel.EntityFrameworkCore; + +namespace Wolverine.EntityFrameworkCore; + +public static class HostResetExtensions +{ + /// + /// Reset the data for a single — delete every row in + /// foreign-key-safe order and then re-run every registered + /// seeder. A finer-grained alternative to + /// host.ResetResourceState() for integration tests that only need one + /// context cleaned between runs. + /// + /// + /// Requires WolverineOptions.UseEntityFrameworkCoreTransactions() to have + /// registered the open-generic , which is + /// the default as of Wolverine 5.x (GH-2539). Creates its own scope so it can be + /// called from test fixtures that aren't already inside one. + /// + /// The DbContext type to reset. + /// The running Wolverine/ASP.NET host. + /// Optional cancellation token. + public static async Task ResetAllDataAsync(this IHost host, CancellationToken ct = default) + where T : DbContext + { + using var scope = host.Services.CreateScope(); + + // Resolving the DbContext first exercises any scoped factory/registration (e.g. + // Wolverine's multi-tenant DbContext providers) before we ask the cleaner to act. + _ = scope.ServiceProvider.GetRequiredService(); + + var cleaner = scope.ServiceProvider.GetRequiredService>(); + await cleaner.ResetAllDataAsync(ct); + } +} diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/WolverineEntityCoreExtensions.cs b/src/Persistence/Wolverine.EntityFrameworkCore/WolverineEntityCoreExtensions.cs index c1f3025b8..210b7afe9 100644 --- a/src/Persistence/Wolverine.EntityFrameworkCore/WolverineEntityCoreExtensions.cs +++ b/src/Persistence/Wolverine.EntityFrameworkCore/WolverineEntityCoreExtensions.cs @@ -8,6 +8,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Weasel.EntityFrameworkCore; using Wolverine.EntityFrameworkCore.Codegen; using Wolverine.EntityFrameworkCore.Internals; using Wolverine.EntityFrameworkCore.Internals.Migrations; @@ -200,6 +201,14 @@ public static void UseEntityFrameworkCoreTransactions(this WolverineOptions opti options.Services.AddScoped(typeof(IDbContextOutbox<>), typeof(DbContextOutbox<>)); options.Services.AddScoped(); options.Services.AddScoped(); + + // Open-generic registration of Weasel's DbContext cleaner so every + // DbContext used with Wolverine gets a ready-to-use IDatabaseCleaner + // without requiring callers to register each one individually. Backs + // the new host.ResetAllDataAsync() helper and any per-test cleanup + // in IInitialData-driven dev loops. See GH-2539. + options.Services.TryAdd(ServiceDescriptor.Singleton(typeof(IDatabaseCleaner<>), typeof(DatabaseCleaner<>))); + options.Services.TryAdd(ServiceDescriptor.Singleton(typeof(DatabaseCleaner<>), typeof(DatabaseCleaner<>))); } catch (InvalidOperationException e) { diff --git a/src/Samples/EFCoreSample/ItemService/Program.cs b/src/Samples/EFCoreSample/ItemService/Program.cs index 459ae557d..5785cad0d 100644 --- a/src/Samples/EFCoreSample/ItemService/Program.cs +++ b/src/Samples/EFCoreSample/ItemService/Program.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using JasperFx; using JasperFx.Resources; +using Weasel.EntityFrameworkCore; using Wolverine; using Wolverine.EntityFrameworkCore; using Wolverine.Http; @@ -24,6 +25,21 @@ #endregion +#region sample_register_initial_data +// Seed data that will be applied every time +// host.ResetAllDataAsync() is invoked (typical test flow). +builder.Services.AddInitialData(); + +// For small inline seed data, the Weasel 8.14+ lambda overload avoids +// having to author a dedicated IInitialData class: +// +// builder.Services.AddInitialData(async (ctx, ct) => +// { +// ctx.Items.Add(new Item { Name = "Demo" }); +// await ctx.SaveChangesAsync(ct); +// }); +#endregion + #region registration_of_db_context_not_integrated_with_outbox // Add DbContext that is not integrated with outbox builder.Services.AddDbContext( diff --git a/src/Samples/EFCoreSample/ItemService/SeedSampleItems.cs b/src/Samples/EFCoreSample/ItemService/SeedSampleItems.cs new file mode 100644 index 000000000..69454bcd1 --- /dev/null +++ b/src/Samples/EFCoreSample/ItemService/SeedSampleItems.cs @@ -0,0 +1,25 @@ +using Weasel.EntityFrameworkCore; + +namespace ItemService; + +#region sample_initial_data_seeder +/// +/// Seed data applied every time host.ResetAllDataAsync<ItemsDbContext>() +/// or DatabaseCleaner<ItemsDbContext>.ResetAllDataAsync() runs. +/// Multiple registrations execute in order. +/// +public class SeedSampleItems : IInitialData +{ + public static readonly Item[] Items = + [ + new Item { Id = Guid.Parse("10000000-0000-0000-0000-000000000001"), Name = "Alpha" }, + new Item { Id = Guid.Parse("10000000-0000-0000-0000-000000000002"), Name = "Beta" } + ]; + + public async Task Populate(ItemsDbContext context, CancellationToken cancellation) + { + context.Items.AddRange(Items); + await context.SaveChangesAsync(cancellation); + } +} +#endregion