Projections and Query Views
Source-generated expression projections, computed filters, and aggregation views, all translatable to SQL.
[Projectable]: Expression Projections
Section titled “[Projectable]: Expression Projections”The Problem
Section titled “The Problem”Computed properties on entities (like FullName, IsOverdue, TotalWithTax) work fine in C#, but EF Core cannot translate regular C# properties to SQL. If you use order.IsOverdue in a Where() clause, EF Core throws because it does not know how to convert that property access into a SQL expression.
The workaround is writing Expression<Func<T, bool>> manually: verbose, error-prone, and disconnected from the property definition. The expression lives in a separate file, the property lives on the entity, and there is nothing enforcing they stay in sync.
The Solution
Section titled “The Solution”Mark a property with [Projectable] and the source generator produces a corresponding Expression<Func<T, TResult>> that EF Core can translate to SQL.
[Entity]public partial class Invoice{ public DateTimeOffset DueDate { get; private set; } public DateTimeOffset? PaidAt { get; private set; } public int InvoiceNumber { get; private set; }
[Projectable] public bool IsOverdue => PaidAt == null && DueDate < DateTimeOffset.UtcNow;
[Projectable] public string DisplayName => $"INV-{InvoiceNumber}";}What the Source Generator Produces
Section titled “What the Source Generator Produces”// Generated: {Namespace}.Invoice.Projectable.g.cspublic partial class Invoice{ public static class Expr { public static Expression<Func<Invoice, bool>> IsOverdue { get; } public static Expression<Func<Invoice, string>> DisplayName { get; } }}
Expris nested inside the entity, so you writeInvoice.Expr.IsOverdue. It is not a top-levelInvoiceExprclass.
The generated expression mirrors the property body exactly. One definition, two representations: the C# property for in-memory use, the expression for SQL translation.
// In LINQ queries: translates to SQLvar overdueInvoices = await dbContext.Invoices .Where(Invoice.Expr.IsOverdue) .ToListAsync();
// In OrderBy: server-side sortingvar sorted = await dbContext.Invoices .OrderByDescending(Invoice.Expr.IsOverdue) .ToListAsync();Constraints
Section titled “Constraints”The property body must be a single expression that EF Core can translate to SQL:
| Allowed | Not Allowed |
|---|---|
Property access (e.Name) |
Method calls (e.Name.ToUpper(), unless EF-mapped) |
Arithmetic operators (+, -, *, /) |
Complex control flow (if/else blocks) |
Comparisons (==, !=, <, >) |
LINQ that EF Core cannot translate (e.Items.Select(i => Format(i))) |
Ternary (? :) |
Local variable declarations |
Null coalescing (??) |
new object creation |
String concatenation ($"...", +) |
External method calls |
Aggregates over a navigation (Lines.Where(l => l.Paid).Sum(l => l.Amount)) |
|
Another [Projectable] member: its body is inlined |
|
A specification over a navigation (Lines.Where(OrderLineSpecifications.Paid)); see below |
A specification that takes a value from the row (PRAG0735) |
If the body cannot be translated to SQL, EF Core will throw at runtime. The source generator does not validate translatability: it trusts the developer to write expressions EF Core understands.
A specification in the body
Section titled “A specification in the body”A rule already written as a specification is used in the body as it is used anywhere else, and the query receives the specification’s expression, not its compiled delegate:
public static partial class OrderLineSpecifications // beside the generated ById{ public static Specification<OrderLine> Paid => Spec<OrderLine>.Where(l => l.PaidOn != null); public static Specification<OrderLine> AtLeast(decimal amount) => Spec<OrderLine>.Where(l => l.Amount >= amount);}
[Projectable]public decimal Paid => Lines.Where(OrderLineSpecifications.Paid).Sum(l => l.Amount);
[ComputedFilter]public bool HasALineOfAtLeast(decimal amount) => Lines.Any(OrderLineSpecifications.AtLeast(amount) & !OrderLineSpecifications.Paid);The generator writes Queryable.Where(Queryable.AsQueryable(e.Lines), (OrderLineSpecifications.Paid).ToExpression()).
EF Core evaluates ToExpression() before it translates and inlines the lambda: composed with &, |,
!, and with a value from outside the row (a filter method’s parameter) as a SQL parameter. The getter
is unchanged and filters in memory with the compiled delegate.
⚠️ The specification is built before the query reads any row, so its arguments cannot come from
the row: Lines.Count(OrderLineSpecifications.AtLeast(Threshold)), where Threshold is a column, or a lambda
parameter of the body, is PRAG0735 at build time: EF Core would throw at the first query. Write
that condition as a lambda.
⚠️ The query form names Queryable.AsQueryable, which carries [RequiresUnreferencedCode] and
[RequiresDynamicCode]: a project that analyzes trimming sees IL2026/IL3050 at the generated line.
The expression is only ever handed to EF Core, which is itself outside trim and AOT support: the same
exception, not a new one.
The body may name members the generators add: a navigation or foreign key declared with
[Relation.*], a trait column such as CreatedAt from [Auditable]. They are read from the
expression’s parameter like any member written by hand.
In a projection DTO
Section titled “In a projection DTO”A [GenerateProjection] DTO that maps a [Projectable] member does not read its getter: the
projection carries the member’s body, so the database computes it in the query that reads the row.
[Entity][Relation.OneToMany<LeaveRequest>.WithNavigation("Requests", Inverse = "Allowance")]public partial class Allowance : IEntity{ public decimal Entitled { get; private set; }
[Projectable] public decimal Approved => Requests.Where(LeaveRequestSpecifications.Approved).Sum(r => r.Amount);
[Projectable] public decimal Remaining => Entitled - Approved;}
[MapFrom<Allowance>][GenerateProjection]public partial class AllowanceBalanceDto{ public decimal Entitled { get; init; } public decimal Remaining { get; init; } // Entitled - (SUM over Requests), in the SELECT}⚠️ A getter in a projection is not an error EF Core reports: it loads the row and runs the getter in memory, over navigations nobody loaded, and a sum comes back zero. That is what the projection did before it inlined the body. It still does in the cases the generator cannot see a body for:
- a member reached through a flattened path (
CustomerTotal→Customer.Total) or inside a nested DTO’s inline initializer; - a member of an entity declared in a referenced assembly.
The in-memory mapping (FromEntity) reads the getter, which is right in memory, with the navigation
loaded.
Why This Matters
Section titled “Why This Matters”Without [Projectable], teams end up with one of two bad patterns:
- Client evaluation: Loading entire tables into memory to evaluate computed properties. Works for small datasets, collapses at scale.
- Expression duplication: Maintaining the same logic as both a C# property and a separate
Expression<Func<T, TResult>>. They drift apart silently.
[Projectable] eliminates both by generating the expression from the property body. One source of truth, zero drift.
[ComputedFilter]: Reusable Query Filters
Section titled “[ComputedFilter]: Reusable Query Filters”The Problem
Section titled “The Problem”You have a boolean condition (“is overdue”, “is active”, “has outstanding balance”) that you need in multiple places: repository queries, specifications, API endpoint filters, reporting views. Writing the same Where() expression in each location means duplication and drift. When the business rule changes (“overdue” now means 30 days past due instead of any past due), you have to find and update every copy.
The Solution
Section titled “The Solution”[ComputedFilter] implies [Projectable] and additionally generates a specification and a query extension method:
[Entity]public partial class Invoice{ public DateTimeOffset DueDate { get; private set; } public DateTimeOffset? PaidAt { get; private set; }
[ComputedFilter] public bool IsOverdue => PaidAt == null && DueDate < DateTimeOffset.UtcNow;
[ComputedFilter] public bool IsHighValue => Subtotal > 10_000m;}What the Source Generator Produces
Section titled “What the Source Generator Produces”Three outputs from a single property:
// Generated: {Namespace}.Invoice.ComputedFilter.g.cspublic static class InvoiceComputedFilters{ // A composable specification: a FIELD, not a method public static readonly Specification<Invoice> IsOverdueSpec; public static readonly Specification<Invoice> IsHighValueSpec;
// A fluent extension: the "Is" prefix is dropped from the name public static IQueryable<Invoice> WhereOverdue(this IQueryable<Invoice> query); public static IQueryable<Invoice> WhereHighValue(this IQueryable<Invoice> query);}Three things worth reading off that shape: the class is {Entity}ComputedFilters, the specification is
a field called {Property}Spec, and the extension method drops a leading Is: IsOverdue
becomes WhereOverdue, not WhereIsOverdue.
// As extension method: fluent, discoverablevar overdue = await dbContext.Invoices .WhereOverdue() .ToListAsync();
// As specification: composablevar spec = InvoiceComputedFilters.IsOverdueSpec.And(InvoiceComputedFilters.IsHighValueSpec);var results = await repository.FindAsync(spec);
// As expression: [ComputedFilter] implies [Projectable], so Expr carries it toovar sorted = await dbContext.Invoices .OrderByDescending(Invoice.Expr.IsOverdue) .ToListAsync();A filter that takes a value
Section titled “A filter that takes a value”A rule that needs something from outside the row (a day, a threshold) is a method. The specification and the extension take the same parameters:
[Entity]public partial class Employee{ [ComputedFilter] public bool IsAwayOn(DateOnly day) => LeaveRequests.Any(r => r.Status == LeaveRequestStatus.Approved && r.From <= day && r.To >= day);}
// Generated on EmployeeComputedFilters:public static Specification<Employee> IsAwayOnSpec(DateOnly day);public static IQueryable<Employee> WhereAwayOn(this IQueryable<Employee> query, DateOnly day);It is the form for a rule over “today”. DateTimeOffset.UtcNow in a property’s body (as IsOverdue
above does) is sent to the database and evaluated by its clock; a clock the host or a test injects
never reaches it. A method takes the date, and a query passes it from the application’s clock with
[FromClock] (see the query system).
The body is rewritten over the row as a property’s is, and its parameters stay parameters. A navigation
the relation generates (LeaveRequests here) is read from the row even when a namespace or a type in
scope has the same name. PRAG0733 reports a member the generator cannot write: not bool, static,
generic, a block body, or a ref/out/in/params parameter. A method gets the specification and the
extension only: there is no Expr member for it, since the expression exists only for a value.
Relationship to [Projectable]
Section titled “Relationship to [Projectable]”[ComputedFilter] is a superset of [Projectable]:
| Feature | [Projectable] |
[ComputedFilter] |
|---|---|---|
| Generates expression | Yes | Yes |
| Generates specification | No | Yes |
| Generates extension method | No | Yes |
| Return type restriction | Any | bool only |
If the property returns a non-boolean type, use [Projectable]. If it returns bool and you want reusable filtering, use [ComputedFilter].
Why This Matters
Section titled “Why This Matters”A single [ComputedFilter] annotation produces three reusable artifacts from one property definition. The business rule lives in exactly one place. When “overdue” changes from “past due date” to “30 days past due date”, you change the property body and every consumer (specifications, extension methods, expressions) updates automatically at compile time.
[QueryView<TRoot>]: Aggregation Views
Section titled “[QueryView<TRoot>]: Aggregation Views”The Problem
Section titled “The Problem”Reporting queries need aggregations: “total revenue per customer”, “average order value by month”, “count of overdue invoices per region”. Writing these as raw LINQ with GroupBy, Sum, Average is verbose and hard to maintain. The LINQ query itself becomes the specification: unnamed, untestable, buried inside a service method.
When the report needs to join multiple entities, the complexity compounds. You end up with deeply nested GroupBy + Select + anonymous types that are difficult to read and impossible to reuse.
The Solution
Section titled “The Solution”Declare the shape of the result as a class, annotate it with the data source and aggregation rules, and let the source generator produce the LINQ query.
[QueryView<Order>][GroupBy<Order>(Properties = "Status")][GroupBy<Customer>(Properties = "Region", Via = "Customer")]public partial class OrderSummaryView{ // the grouping key, read back out [From<Order>(Property = "Status")] public OrderStatus Status { get; set; }
[From<Customer>(Property = "Region")] public string Region { get; set; } = "";
// the aggregates, each with its own attribute [Sum<Order>(Expression = "Total")] public decimal TotalRevenue { get; set; }
[Count<Order>] public int OrderCount { get; set; }}Attributes
Section titled “Attributes”[QueryView<TRoot>]
Section titled “[QueryView<TRoot>]”Marks the class as a query view. TRoot is the primary entity: the starting IQueryable<TRoot> that the generated query begins from.
| Property | Type | Description |
|---|---|---|
| n/a | Generic TRoot |
The root entity for the query |
⚠️ [Join<T>] has no effect on a view, and declaring one is PRAG0703. The transform reads
[GroupBy], [From] and the five aggregate attributes, and nothing else. Reach another entity with
[GroupBy<TOther>(Via = "Nav")]; the navigation path is where the traversal is declared here.
[GroupBy<TEntity>]
Section titled “[GroupBy<TEntity>]”Specifies which properties to group by. Multiple [GroupBy] attributes combine into a composite grouping key.
| Property | Type | Description |
|---|---|---|
Properties |
string |
Comma-separated property names to group by |
Via |
string? |
Navigation path for nested grouping |
A key is any member the entity will have: declared, inherited, or written by the generator. The
foreign key and the navigation a [Relation.*] produces count, so an entity that declares neither by
hand can still be grouped by its relation, or through it:
[Entity][Relation.ManyToOne<Team>.WithNavigation("Team", Required = false)] // writes TeamId and Team[Relation.ManyToOne<AbsenceKind>.WithNavigation("AbsenceKind")] // writes AbsenceKindId and AbsenceKindpublic partial class LeaveRequest : IEntity{ public DateOnly From { get; private set; } public decimal Amount { get; private set; }
[Projectable] public int StartMonth => From.Month; // computed by the database}
[QueryView<LeaveRequest>][GroupBy<LeaveRequest>(Properties = "TeamId,AbsenceKindId,StartMonth")][GroupBy<AbsenceKind>(Properties = "Unit", Via = "AbsenceKind")]public partial class AbsenceReportLine{ public Guid? TeamId { get; init; } public Guid AbsenceKindId { get; init; } public int StartMonth { get; init; } public AbsenceUnit Unit { get; init; }
[Sum<LeaveRequest>(Expression = "Amount")] public decimal Taken { get; init; }}
// Generated key:// .GroupBy(e => new { TeamId = e.TeamId, AbsenceKindId = e.AbsenceKindId,// StartMonth = e.From.Month, AbsenceKind_Unit = e.AbsenceKind.Unit })- A
[Projectable]key is grouped by its body. Its getter is not a column: EF Core cannot translate it inside aGroupBy, and the body is what the database can compute. - A key named twice is one key. A view property named like an entity property is a key on its own.
Naming it again in
[GroupBy], asStartMonthabove, does not add a second one. - A key or a
Viathat names nothing is PRAG0732, an error. A view without its key would group by fewer columns than declared, and add up across the one that is missing.
[From<TEntity>]
Section titled “[From<TEntity>]”Maps a view property from a source entity property. Properties not in a [GroupBy] are treated as aggregations.
| Property | Type | Description |
|---|---|---|
Property |
string? |
Source property name (defaults to the view property name if omitted) |
Aggregations
Section titled “Aggregations”Each aggregate is its own attribute, generic over the entity it reads, and takes the expression to aggregate, not a property name:
[Sum<Order>(Expression = "Total")]public decimal TotalRevenue { get; set; }
[Avg<Order>(Expression = "Total")]public decimal AverageOrderValue { get; set; }
[Min<Order>(Expression = "Total")]public decimal SmallestOrder { get; set; }
[Max<Order>(Expression = "Total")]public decimal LargestOrder { get; set; }
[Count<Order>]public int OrderCount { get; set; }
[Count<Order>(Where = "x.Status == OrderStatus.Cancelled")] // the clause is a lambda body: it names xpublic int CancelledCount { get; set; }| Attribute | Required argument | Notes |
|---|---|---|
[Sum<T>], [Avg<T>], [Min<T>], [Max<T>] |
Expression |
The expression to aggregate |
[Count<T>] |
n/a | Where makes it a conditional count |
Expression is written against the entity, and the generator reads every member it names from the
row. So "Quantity * UnitPrice" becomes g.Sum(x => x.Quantity * x.UnitPrice), and a
[Projectable] member becomes its body. A literal, a type or a name after a dot is left as written.
Where is different: it is inserted as written, so it names the row itself (x.…).
[From<T>(Property = "...")] marks the properties that come from the grouping key rather than from an
aggregate.
The generated member is a static Build on the view itself, which takes the source queryable:
// Generated: {Namespace}.OrderSummaryView.QueryView.g.cspublic partial class OrderSummaryView{ public static IQueryable<OrderSummaryView> Build(IQueryable<Order> query);}var summary = await OrderSummaryView.Build(orders.Query()).ToListAsync(ct);
// It is an IQueryable, so it composesvar eu = await OrderSummaryView.Build(orders.Query()) .Where(v => v.Region == "EU") .OrderByDescending(v => v.TotalRevenue) .ToListAsync(ct);Why This Matters
Section titled “Why This Matters”The view class is the specification. Its name describes what it computes (OrderSummaryView), its properties describe the output shape, and its attributes describe the data source. The generated LINQ is correct by construction, with no hand-written GroupBy chains to get wrong.
Combining Projectable, ComputedFilter, and QueryView
Section titled “Combining Projectable, ComputedFilter, and QueryView”These three features compose naturally because they all produce the same artifact: Expression<Func<T, TResult>>.
// Entity with computed properties[Entity]public partial class Invoice{ public decimal Subtotal { get; private set; } public decimal TaxRate { get; private set; } public Guid CustomerId { get; private set; }
[Projectable] public decimal TotalWithTax => Subtotal * (1 + TaxRate);
[ComputedFilter] public bool IsHighValue => TotalWithTax > 10_000m;}The [ComputedFilter] on IsHighValue can reference the [Projectable] property TotalWithTax because both are translated to expressions. The source generator resolves the dependency: IsHighValue’s expression inlines the TotalWithTax expression, producing a single SQL-translatable expression tree.
// Use in a query: both expressions translate to SQLvar highValue = await dbContext.Invoices .WhereHighValue() .OrderByDescending(Invoice.Expr.TotalWithTax) .ToListAsync();
// Use in a view: aggregation over computed properties[QueryView<Invoice>][GroupBy<Invoice>(Properties = "CustomerId")]public partial class CustomerRevenueSummary{ [From<Invoice>(Property = "CustomerId")] public Guid CustomerId { get; set; }
[Sum<Invoice>(Expression = "TotalWithTax")] public decimal TotalRevenue { get; set; }
[Count<Invoice>] public int InvoiceCount { get; set; }}All expressions are translated to SQL by EF Core: no in-memory evaluation, no N+1 queries.
Design Decisions
Section titled “Design Decisions”Why a nested Expr class?
Section titled “Why a nested Expr class?”An expression has to be static to be reusable in LINQ: Where(), Select() and OrderBy() take an
Expression<Func<T, TResult>>, not an instance member. Nesting them in Invoice.Expr rather than a
separate InvoiceExpr type keeps them reachable from the entity that owns them (you find them by
typing Invoice.) and keeps the entity’s own surface free of static members.
Why specifications from ComputedFilter?
Section titled “Why specifications from ComputedFilter?”Specifications are composable (And, Or, Not) and testable (you can assert what a specification matches without a database). Generating them from [ComputedFilter] means every boolean computed property automatically becomes a reusable, composable query building block.
Why declare views as classes?
Section titled “Why declare views as classes?”The alternative, fluent builder APIs, hides the output shape inside method chains. A class declaration makes the output shape visible at a glance: property names, types, and mappings are all declared together. The class also serves as the DTO for the query result, eliminating a mapping step.
Why one attribute per aggregate?
Section titled “Why one attribute per aggregate?”[Sum<T>] and [Count<T>] say what they do at the point they do it, and each carries only the
arguments that make sense for it: Expression for the four that aggregate a value, Where for the
one that counts rows. A single [Aggregate(function)] would have to accept every argument for every
function and validate the combinations at generation time.
