diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 208a05769..85545d3f0 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -328,7 +328,8 @@ const config: UserConfig = { {text: 'Saga Storage', link: '/guide/durability/efcore/sagas'}, {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: 'Database Migrations', link: '/guide/durability/efcore/migrations'}, + {text: 'Query Plans', link: '/guide/durability/efcore/query-plans'} ]}, {text: 'Managing Message Storage', link: '/guide/durability/managing'}, diff --git a/docs/guide/durability/efcore/query-plans.md b/docs/guide/durability/efcore/query-plans.md new file mode 100644 index 000000000..06c4bc33c --- /dev/null +++ b/docs/guide/durability/efcore/query-plans.md @@ -0,0 +1,135 @@ +# Query Plans + +Wolverine.EntityFrameworkCore provides a first-class implementation of the +[Specification pattern](https://specification.ardalis.com/) called a *query plan*, +adapted from Marten's [IQueryPlan](https://martendb.io/documents/querying/compiled-queries.html#query-plans) +and consistent with it across the Critter Stack. + +A query plan is a reusable, testable unit of query logic that encapsulates a +LINQ query over a `DbContext`. Handlers can consume complex reads without +reaching for a repository/adapter layer — a middle ground between +`[Entity]` (primary-key lookup only) and a bespoke repository service. + +## Why query plans? + +In real-world codebases, handlers frequently need queries more complex than a +primary-key lookup, but not complex enough to justify an adapter layer. Query +plans give you: + +- **Reusability** — one query class, many callers +- **Testability** — instantiate and call `FetchAsync` against an in-memory + `DbContext`; no host, no mocking +- **Composability** — parameters flow through the constructor; plans can be + combined inside a handler +- **No magic** — no source generators, no DI registration, no runtime + reflection. A plan is just a class with a `Query()` method. + +## Defining a plan + +Inherit from `QueryPlan` for a single result, or +`QueryListPlan` for a list: + +```csharp +using Wolverine.EntityFrameworkCore; + +public class ActiveOrderForCustomer(Guid customerId) : QueryPlan +{ + public override IQueryable Query(OrderDbContext db) + => db.Orders + .Where(x => x.CustomerId == customerId && !x.IsArchived) + .OrderByDescending(x => x.CreatedAt); +} + +public class OrdersForCustomer(Guid customerId) : QueryListPlan +{ + public override IQueryable Query(OrderDbContext db) + => db.Orders + .Where(x => x.CustomerId == customerId) + .Include(x => x.LineItems) + .OrderBy(x => x.CreatedAt); +} +``` + +Everything LINQ-to-EF supports — `Include`, `OrderBy`, `Select`, `Skip`, +`Take`, projection into DTOs — works inside `Query()`. + +## Using a plan in a handler + +The simplest pattern: inject your `DbContext` into the handler and execute the +plan against it. + +```csharp +public static async Task Handle( + ApproveOrder msg, + OrderDbContext db, + CancellationToken ct) +{ + var order = await db.QueryByPlanAsync( + new ActiveOrderForCustomer(msg.CustomerId), ct); + + if (order is null) throw new InvalidOperationException("No active order"); + + order.Approve(); + // DbContext.SaveChangesAsync() is invoked automatically by Wolverine's + // EF Core transactional middleware. +} +``` + +`QueryByPlanAsync` is a convenience extension on `DbContext`. Equivalent to +calling `plan.FetchAsync(db, ct)` directly. + +## Testing a plan in isolation + +Because plans have no framework dependencies, you can unit-test them with EF +Core's in-memory provider (or a real SQLite in-memory connection) without +starting a Wolverine host: + +```csharp +[Fact] +public async Task active_order_plan_finds_most_recent_unarchived_order() +{ + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"plan-test-{Guid.NewGuid():N}") + .Options; + + await using var db = new OrderDbContext(options); + var customerId = Guid.NewGuid(); + db.Orders.AddRange( + new Order { CustomerId = customerId, IsArchived = true, CreatedAt = DateTime.UtcNow.AddDays(-2) }, + new Order { CustomerId = customerId, IsArchived = false, CreatedAt = DateTime.UtcNow.AddHours(-1) }); + await db.SaveChangesAsync(); + + var plan = new ActiveOrderForCustomer(customerId); + var result = await plan.FetchAsync(db, default); + + result.ShouldNotBeNull(); + result.IsArchived.ShouldBeFalse(); +} +``` + +## When to reach for a query plan + +| Situation | Recommendation | +| ----------------------------------------------------- | ----------------------------- | +| Load one entity by primary key | `[Entity]` attribute | +| Load by a simple predicate, used in one handler | Inline LINQ is fine | +| Reusable query used across multiple handlers | **Query plan** | +| Complex query with projection/paging/caching rules | **Query plan** | +| Cross-aggregate read model with data shaping | **Query plan** or a dedicated query service | + +## Relationship to Marten + +This is the same shape as Marten's `IQueryPlan` (see the +[Marten docs](https://martendb.io/documents/querying/compiled-queries.html#query-plans)) +with the signature tweaked for EF Core's `DbContext`. If you are using both +Marten and EF Core in a Critter Stack application, plans on both sides read +identically. + +## Relationship to Ardalis.Specification + +The programming model — a class that encapsulates query logic, parameters via +constructor, composition of `Where`/`Include`/`OrderBy` — matches +[Ardalis.Specification](https://specification.ardalis.com/). The key +difference is that query plans expose the raw `IQueryable` builder (so any +LINQ operator EF Core supports is available) rather than a curated DSL, and +integrate directly with Wolverine's handler pipeline. diff --git a/src/Persistence/EfCoreTests/QueryPlans/QueryPlan_end_to_end.cs b/src/Persistence/EfCoreTests/QueryPlans/QueryPlan_end_to_end.cs new file mode 100644 index 000000000..309edc19a --- /dev/null +++ b/src/Persistence/EfCoreTests/QueryPlans/QueryPlan_end_to_end.cs @@ -0,0 +1,101 @@ +using IntegrationTests; +using JasperFx.Resources; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using SharedPersistenceModels.Items; +using Shouldly; +using Wolverine; +using Wolverine.EntityFrameworkCore; +using Wolverine.SqlServer; +using Wolverine.Tracking; +using Xunit; + +namespace EfCoreTests.QueryPlans; + +/// +/// End-to-end smoke test proving a handler can consume an IQueryPlan against +/// the real EF Core + Wolverine transactional pipeline — not just the +/// in-memory provider. GH-2505. +/// +[Collection("sqlserver")] +public class QueryPlan_end_to_end : IAsyncLifetime +{ + private IHost _host = null!; + + public async Task InitializeAsync() + { + _host = await Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + services.AddDbContext(x => x.UseSqlServer(Servers.SqlServerConnectionString)); + }) + .UseWolverine(opts => + { + opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "qpe2e"); + opts.Services.AddResourceSetupOnStartup(StartupAction.ResetState); + opts.UseEntityFrameworkCoreTransactions(); + opts.Policies.AutoApplyTransactions(); + opts.UseEntityFrameworkCoreWolverineManagedMigrations(); + }) + .StartAsync(); + } + + public async Task DisposeAsync() + { + await _host.StopAsync(); + _host.Dispose(); + } + + [Fact] + public async Task handler_uses_query_plan_to_approve_matching_items() + { + var prefix = $"qp_{Guid.NewGuid():N}"[..16]; + + using (var scope = _host.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Items.Add(new Item { Id = Guid.NewGuid(), Name = $"{prefix}_a", Approved = false }); + db.Items.Add(new Item { Id = Guid.NewGuid(), Name = $"{prefix}_b", Approved = false }); + db.Items.Add(new Item { Id = Guid.NewGuid(), Name = "untouched", Approved = false }); + await db.SaveChangesAsync(); + } + + await _host.InvokeMessageAndWaitAsync(new ApproveItemsByPrefix(prefix)); + + using var verify = _host.Services.CreateScope(); + var verifyDb = verify.ServiceProvider.GetRequiredService(); + + var approved = await verifyDb.Items + .Where(x => x.Name.StartsWith(prefix) && x.Approved) + .CountAsync(); + approved.ShouldBe(2); + + var untouched = await verifyDb.Items.SingleAsync(x => x.Name == "untouched"); + untouched.Approved.ShouldBeFalse(); + } +} + +public record ApproveItemsByPrefix(string Prefix); + +public static class ApproveItemsByPrefixHandler +{ + public static async Task Handle(ApproveItemsByPrefix msg, ItemsDbContext db, CancellationToken ct) + { + // The reusable query lives in its own testable class. The handler + // just consumes it — no repository, no ad-hoc LINQ embedded in the + // handler body. + var items = await db.QueryByPlanAsync(new ItemsWithNamePrefix(msg.Prefix), ct); + + foreach (var item in items) + { + item.Approved = true; + } + } +} + +public class ItemsWithNamePrefix(string prefix) : QueryListPlan +{ + public override IQueryable Query(ItemsDbContext db) + => db.Items.Where(x => x.Name.StartsWith(prefix)); +} diff --git a/src/Persistence/EfCoreTests/QueryPlans/QueryPlan_specs.cs b/src/Persistence/EfCoreTests/QueryPlans/QueryPlan_specs.cs new file mode 100644 index 000000000..7e4625337 --- /dev/null +++ b/src/Persistence/EfCoreTests/QueryPlans/QueryPlan_specs.cs @@ -0,0 +1,171 @@ +using Microsoft.EntityFrameworkCore; +using SharedPersistenceModels.Items; +using Shouldly; +using Wolverine.EntityFrameworkCore; +using Xunit; + +namespace EfCoreTests.QueryPlans; + +/// +/// Unit tests for the Phase 1 query-plan abstractions (GH-2505). These +/// exercise the base classes and extension method against EF Core's +/// in-memory provider — no SQL Server required. +/// +public class QueryPlan_specs : IAsyncLifetime +{ + private QueryPlanDbContext _db = null!; + + public async Task InitializeAsync() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"querypans-{Guid.NewGuid():N}") + .Options; + + _db = new QueryPlanDbContext(options); + + _db.Items.AddRange( + new Item { Id = Guid.NewGuid(), Name = "Red Chair", Approved = true }, + new Item { Id = Guid.NewGuid(), Name = "Red Table", Approved = false }, + new Item { Id = Guid.NewGuid(), Name = "Blue Sofa", Approved = true }, + new Item { Id = Guid.NewGuid(), Name = "Green Lamp", Approved = true }); + + await _db.SaveChangesAsync(); + } + + public async Task DisposeAsync() + { + await _db.DisposeAsync(); + } + + [Fact] + public async Task query_list_plan_returns_matching_rows() + { + var plan = new ItemsByNamePrefix("Red"); + var results = await plan.FetchAsync(_db, CancellationToken.None); + + results.Count.ShouldBe(2); + results.ShouldAllBe(x => x.Name.StartsWith("Red")); + } + + [Fact] + public async Task query_list_plan_returns_empty_when_no_match() + { + var plan = new ItemsByNamePrefix("Zzz"); + var results = await plan.FetchAsync(_db, CancellationToken.None); + + results.ShouldBeEmpty(); + } + + [Fact] + public async Task query_plan_returns_first_match() + { + var plan = new FirstApprovedItem(); + var result = await plan.FetchAsync(_db, CancellationToken.None); + + result.ShouldNotBeNull(); + result.Approved.ShouldBeTrue(); + } + + [Fact] + public async Task query_plan_returns_null_when_no_match() + { + // Delete everything, then run the plan + _db.Items.RemoveRange(_db.Items); + await _db.SaveChangesAsync(); + + var plan = new FirstApprovedItem(); + var result = await plan.FetchAsync(_db, CancellationToken.None); + + result.ShouldBeNull(); + } + + [Fact] + public async Task QueryByPlanAsync_extension_routes_to_the_plan() + { + var result = await _db.QueryByPlanAsync(new FirstApprovedItem()); + + result.ShouldNotBeNull(); + result.Approved.ShouldBeTrue(); + } + + [Fact] + public async Task QueryByPlanAsync_extension_works_with_list_plan() + { + var results = await _db.QueryByPlanAsync(new ItemsByNamePrefix("Red")); + + results.Count.ShouldBe(2); + } + + [Fact] + public async Task QueryByPlanAsync_throws_on_null_plan() + { + await Should.ThrowAsync(async () => + await _db.QueryByPlanAsync(null!)); + } + + [Fact] + public async Task plan_can_compose_ordering_and_projection_inside_query() + { + var plan = new ItemsOrderedByName(); + var results = await plan.FetchAsync(_db, CancellationToken.None); + + // Alphabetical: Blue Sofa, Green Lamp, Red Chair, Red Table + results.Select(x => x.Name).ShouldBe(new[] + { + "Blue Sofa", "Green Lamp", "Red Chair", "Red Table" + }); + } + + [Fact] + public async Task plan_parameters_via_constructor_flow_through_to_query() + { + // Verify that distinct parameter values yield distinct results — the + // core claim of the specification pattern + var red = await _db.QueryByPlanAsync(new ItemsByNamePrefix("Red")); + var blue = await _db.QueryByPlanAsync(new ItemsByNamePrefix("Blue")); + + red.Count.ShouldBe(2); + blue.Count.ShouldBe(1); + blue.Single().Name.ShouldBe("Blue Sofa"); + } +} + +// Test DbContext — distinct from SampleDbContext to avoid cross-test state +public class QueryPlanDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Items => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.Entity(map => + { + map.HasKey(x => x.Id); + map.Property(x => x.Name); + map.Ignore(x => x.Events); + }); + } +} + +// Sample query plans — live in the test assembly to prove that user-defined +// plans in arbitrary assemblies work without any registration + +public class ItemsByNamePrefix(string prefix) : QueryListPlan +{ + public string Prefix { get; } = prefix; + + public override IQueryable Query(QueryPlanDbContext db) + => db.Items.Where(x => x.Name.StartsWith(Prefix)); +} + +public class FirstApprovedItem : QueryPlan +{ + public override IQueryable Query(QueryPlanDbContext db) + => db.Items.Where(x => x.Approved).OrderBy(x => x.Name); +} + +public class ItemsOrderedByName : QueryListPlan +{ + public override IQueryable Query(QueryPlanDbContext db) + => db.Items.OrderBy(x => x.Name); +} diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/QueryPlans/IQueryPlan.cs b/src/Persistence/Wolverine.EntityFrameworkCore/QueryPlans/IQueryPlan.cs new file mode 100644 index 000000000..ecfee5391 --- /dev/null +++ b/src/Persistence/Wolverine.EntityFrameworkCore/QueryPlans/IQueryPlan.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; + +namespace Wolverine.EntityFrameworkCore; + +/// +/// A reusable, testable unit of query logic over an Entity Framework Core +/// — Wolverine's adaptation of Marten's +/// IQueryPlan and a first-class implementation of the +/// Specification pattern +/// for EF Core. +/// +/// A query plan encapsulates query logic in its own class so handlers can +/// consume complex reads without reaching for a repository/adapter layer. +/// Pass parameters through the constructor and execute the plan against any +/// instance Wolverine provides to the handler (including +/// tenanted ones). +/// +/// +/// The the plan queries. +/// The result type the plan returns. +public interface IQueryPlan where TDbContext : DbContext +{ + /// + /// Execute the query plan and return its result. + /// + Task FetchAsync(TDbContext dbContext, CancellationToken cancellation); +} diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/QueryPlans/QueryListPlan.cs b/src/Persistence/Wolverine.EntityFrameworkCore/QueryPlans/QueryListPlan.cs new file mode 100644 index 000000000..c6e740d6e --- /dev/null +++ b/src/Persistence/Wolverine.EntityFrameworkCore/QueryPlans/QueryListPlan.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; + +namespace Wolverine.EntityFrameworkCore; + +/// +/// Convenience base class for query plans that return a list of entities. +/// Subclasses override to build an ; +/// the base class materializes it with +/// . +/// +/// The the plan queries. +/// The entity type returned by the plan. +public abstract class QueryListPlan : IQueryPlan> + where TDbContext : DbContext + where TEntity : class +{ + /// + /// Build the the plan represents. Called once per + /// invocation. All LINQ operators (Where, + /// Include, OrderBy, Select, etc.) are available. + /// + public abstract IQueryable Query(TDbContext dbContext); + + public async Task> FetchAsync(TDbContext dbContext, CancellationToken cancellation) + { + return await Query(dbContext).ToListAsync(cancellation).ConfigureAwait(false); + } +} diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/QueryPlans/QueryPlan.cs b/src/Persistence/Wolverine.EntityFrameworkCore/QueryPlans/QueryPlan.cs new file mode 100644 index 000000000..79a894b6d --- /dev/null +++ b/src/Persistence/Wolverine.EntityFrameworkCore/QueryPlans/QueryPlan.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; + +namespace Wolverine.EntityFrameworkCore; + +/// +/// Convenience base class for query plans that return a single matching entity +/// (or null). Subclasses override to build an +/// ; the base class materializes it with +/// . +/// +/// The the plan queries. +/// The entity type returned by the plan. +public abstract class QueryPlan : IQueryPlan + where TDbContext : DbContext + where TEntity : class +{ + /// + /// Build the the plan represents. Called once per + /// invocation. All LINQ operators (Where, + /// Include, OrderBy, Select, etc.) are available. + /// + public abstract IQueryable Query(TDbContext dbContext); + + public async Task FetchAsync(TDbContext dbContext, CancellationToken cancellation) + { + return await Query(dbContext).FirstOrDefaultAsync(cancellation).ConfigureAwait(false); + } +} diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/QueryPlans/QueryPlanExtensions.cs b/src/Persistence/Wolverine.EntityFrameworkCore/QueryPlans/QueryPlanExtensions.cs new file mode 100644 index 000000000..63174491d --- /dev/null +++ b/src/Persistence/Wolverine.EntityFrameworkCore/QueryPlans/QueryPlanExtensions.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; + +namespace Wolverine.EntityFrameworkCore; + +/// +/// Convenience extension methods for executing +/// instances against a . Parallels Marten's +/// IQuerySession.QueryByPlanAsync. +/// +public static class QueryPlanExtensions +{ + /// + /// Execute a query plan against this . + /// + /// + /// + /// var order = await db.QueryByPlanAsync(new ActiveOrderForCustomer(customerId), ct); + /// + /// + public static Task QueryByPlanAsync( + this TDbContext dbContext, + IQueryPlan plan, + CancellationToken cancellation = default) + where TDbContext : DbContext + { + if (dbContext == null) throw new ArgumentNullException(nameof(dbContext)); + if (plan == null) throw new ArgumentNullException(nameof(plan)); + + return plan.FetchAsync(dbContext, cancellation); + } +}