Multi-Model Queries¶
Verified by tests
ILensQueryMultiGenericTests, EFCorePostgresLensQueryMultiGenericTests, EFCorePostgresLensQueryHighArityTests, MultiModelScopedAccessTests, LensQueryTypeArgumentAnalyzerTests — library CI run #31657041675 (2026-08-13)
Multi-model queries enable LINQ joins across multiple perspective types using a shared DbContext. This is essential for GraphQL resolvers that need to combine data from different perspectives efficiently.
The Problem: Separate DbContexts Cannot Join¶
When using single-generic ILensQuery<T>, each injection gets its own DbContext. This prevents LINQ joins across perspective types:
The Problem: Separate DbContexts Cannot Join
// WRONG: Cannot join - different DbContexts
public class OrderResolver {
public async Task<OrderWithCustomer> GetOrderWithCustomer(
[Service] ILensQuery<Order> orders,
[Service] ILensQuery<Customer> customers,
Guid orderId,
CancellationToken ct) {
// This FAILS - EF Core cannot join queries from different DbContexts!
var result = await (
from o in orders.DefaultScope.Query
join c in customers.DefaultScope.Query on o.Data.CustomerId equals c.Id
where o.Id == orderId
select new OrderWithCustomer(o.Data, c.Data)
).FirstOrDefaultAsync(ct);
return result;
}
}
Solution: Multi-Generic ILensQuery¶
Use ILensQuery<T1, T2> (or up to 10 type parameters) to get a shared DbContext for all perspective types:
Solution: Multi-Generic ILensQuery
// CORRECT: Shared DbContext enables LINQ joins
public class OrderResolver {
public async Task<OrderWithCustomer> GetOrderWithCustomer(
[Service] ILensQuery<Order, Customer> query,
Guid orderId,
CancellationToken ct) {
var scoped = query.DefaultScope;
var result = await (
from o in scoped.Query<Order>()
join c in scoped.Query<Customer>() on o.Data.CustomerId equals c.Id
where o.Id == orderId
select new OrderWithCustomer(o.Data, c.Data)
).FirstOrDefaultAsync(ct);
return result;
}
}
Available Interfaces¶
Multi-generic interfaces support 2-10 type parameters. Like the single-model lens, they use the scope-before-query API — the bare Query<T>() and GetByIdAsync<T>() members are [Obsolete] and delegate to DefaultScope:
Available Interfaces
public interface ILensQuery<T1, T2> : ILensQuery, IAsyncDisposable, IDisposable
where T1 : class
where T2 : class {
// Fluent scope API - select a scope, then query
IScopedMultiLensAccess<T1, T2> Scope(QueryScope scope);
IScopedMultiLensAccess<T1, T2> ScopeOverride(QueryScope scope, ScopeFilterOverride overrideValues);
IScopedMultiLensAccess<T1, T2> DefaultScope { get; }
// Legacy API (obsolete - delegates to DefaultScope)
[Obsolete("Use scope API")] IQueryable<PerspectiveRow<T>> Query<T>() where T : class;
[Obsolete("Use scope API")] Task<T?> GetByIdAsync<T>(Guid id, CancellationToken cancellationToken = default) where T : class;
}
// The scoped access object exposes the typed query methods
public interface IScopedMultiLensAccess<T1, T2>
where T1 : class
where T2 : class {
IQueryable<PerspectiveRow<T>> Query<T>() where T : class;
Task<T?> GetByIdAsync<T>(Guid id, CancellationToken cancellationToken = default) where T : class;
}
// Also available: ILensQuery<T1, T2, T3> through ILensQuery<T1, ..., T10>
DefaultScope uses WhizbangCoreOptions.DefaultQueryScope (default: QueryScope.Tenant), so multi-model queries are tenant-filtered by default. Use Scope(QueryScope.Global) for unfiltered access.
Type Safety¶
The Query<T>() method validates that T is one of the registered type parameters:
- Compile-time: The WHIZ400 analyzer reports errors for invalid types
- Runtime: Throws
ArgumentExceptionif type is not registered
Type Safety
// Using ILensQuery<Order, Customer>
var scoped = query.DefaultScope;
scoped.Query<Order>(); // OK - Order is T1
scoped.Query<Customer>(); // OK - Customer is T2
scoped.Query<Product>(); // ERROR: WHIZ400 - Product is not T1 or T2
Registration¶
Register multi-generic queries using RegisterMultiLensQuery:
Registration
// In your startup or source-generated code
EFCoreInfrastructureRegistration.RegisterMultiLensQuery<MyDbContext, Order, Customer>(
services,
new Dictionary<Type, string> {
{ typeof(Order), "orders_perspective" },
{ typeof(Customer), "customers_perspective" }
});
Note: Requires
IDbContextFactory<MyDbContext>to be registered (viaAddDbContextFactoryor Whizbang's internalScopedDbContextFactory), plusIScopeContextAccessorandIOptions<WhizbangCoreOptions>(both registered byAddWhizbang()).RegisterMultiLensQueryhelper overloads exist for 2 and 3 model types; higher arities are constructed viaEFCorePostgresLensQuery<T1, ..., T10>directly.
Transient Lifecycle¶
Multi-generic ILensQuery is registered as Transient:
- Each injection gets its own instance with its own
DbContext - Prevents concurrency errors in parallel GraphQL resolvers
- The
DbContextis disposed when theILensQueryis disposed
Transient Lifecycle
// Each resolver invocation gets a fresh instance
public class OrderResolver {
public async Task<Order?> GetOrder(
[Service] ILensQuery<Order, Customer> query, // Fresh instance
Guid id,
CancellationToken ct) {
return await query.DefaultScope.GetByIdAsync<Order>(id, ct);
}
public async Task<Customer?> GetCustomer(
[Service] ILensQuery<Order, Customer> query, // Different instance
Guid id,
CancellationToken ct) {
return await query.DefaultScope.GetByIdAsync<Customer>(id, ct);
}
}
Join Patterns¶
Simple Join¶
Simple Join
var scoped = query.DefaultScope;
var ordersWithCustomers = await (
from o in scoped.Query<Order>()
join c in scoped.Query<Customer>() on o.Data.CustomerId equals c.Id
select new { Order = o.Data, Customer = c.Data }
).ToListAsync(ct);
Left Join¶
Left Join
var scoped = query.DefaultScope;
var ordersWithOptionalCustomers = await (
from o in scoped.Query<Order>()
join c in scoped.Query<Customer>() on o.Data.CustomerId equals c.Id into customers
from c in customers.DefaultIfEmpty()
select new {
Order = o.Data,
Customer = c != null ? c.Data : null
}
).ToListAsync(ct);
Multiple Joins¶
Multiple Joins
// Using ILensQuery<Order, Customer, Product>
var scoped = query.DefaultScope;
var fullOrderDetails = await (
from o in scoped.Query<Order>()
join c in scoped.Query<Customer>() on o.Data.CustomerId equals c.Id
join p in scoped.Query<Product>() on o.Data.ProductId equals p.Id
select new {
Order = o.Data,
Customer = c.Data,
Product = p.Data
}
).ToListAsync(ct);
AOT Compatibility¶
Multi-generic ILensQuery is fully AOT-compatible:
- No reflection at runtime
typeof(T) == typeof(T1)comparisons are compile-time constants- Source-generated registration code
Disposal¶
Multi-generic ILensQuery implements IAsyncDisposable to properly dispose the shared DbContext:
Disposal
// In DI scenarios, disposal is automatic
// For manual use:
await using var query = serviceProvider.GetRequiredService<ILensQuery<Order, Customer>>();
var result = await query.DefaultScope.Query<Order>().ToListAsync();
// DbContext disposed when query is disposed
See Also¶
- WHIZ400 Diagnostic - Invalid type argument errors
- Lens Query Factory - DbContext sharing for parallel resolvers
- Scoped Queries - Auto-scoping for singleton services