Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,8 @@ const config: UserConfig<DefaultTheme.Config> = {
{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'},
Expand Down
135 changes: 135 additions & 0 deletions docs/guide/durability/efcore/query-plans.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Query Plans <Badge type="tip" text="5.32" />

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<TDbContext, TEntity>` for a single result, or
`QueryListPlan<TDbContext, TEntity>` for a list:

```csharp
using Wolverine.EntityFrameworkCore;

public class ActiveOrderForCustomer(Guid customerId) : QueryPlan<OrderDbContext, Order>
{
public override IQueryable<Order> Query(OrderDbContext db)
=> db.Orders
.Where(x => x.CustomerId == customerId && !x.IsArchived)
.OrderByDescending(x => x.CreatedAt);
}

public class OrdersForCustomer(Guid customerId) : QueryListPlan<OrderDbContext, Order>
{
public override IQueryable<Order> 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<OrderDbContext>()
.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<T>` (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<T>` builder (so any
LINQ operator EF Core supports is available) rather than a curated DSL, and
integrate directly with Wolverine's handler pipeline.
101 changes: 101 additions & 0 deletions src/Persistence/EfCoreTests/QueryPlans/QueryPlan_end_to_end.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
[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<ItemsDbContext>(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<ItemsDbContext>();
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<ItemsDbContext>();

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<ItemsDbContext, Item>
{
public override IQueryable<Item> Query(ItemsDbContext db)
=> db.Items.Where(x => x.Name.StartsWith(prefix));
}
Loading
Loading