Projections Guide
This guide covers [GenerateProjection] — generating Expression<Func<TEntity, TDto>> for SQL-translatable EF Core queries.
Overview
Section titled “Overview”When you add [GenerateProjection] to a DTO that has [MapFrom<T>], the source generator emits a static Projection property. EF Core translates this expression tree directly to a SQL SELECT clause, fetching only the columns your DTO needs.
[MapFrom<Property>][GenerateProjection]public partial class PropertySummaryDto{ public Guid Id { get; init; } public string Name { get; init; } = ""; public string City { get; init; } = ""; public int StarRating { get; init; } public bool IsActive { get; init; }}The generator creates:
public static Expression<Func<Property, PropertySummaryDto>> Projection { get; } = entity => new PropertySummaryDto { Id = entity.Id, Name = entity.Name, City = entity.City, StarRating = entity.StarRating, IsActive = entity.IsActive, };EF Core translates this to:
SELECT p."Id", p."Name", p."City", p."StarRating", p."IsActive"FROM "Properties" pWHERE ...When to Use Projection vs FromEntity
Section titled “When to Use Projection vs FromEntity”| Feature | FromEntity() | Selector | Projection |
|---|---|---|---|
| SQL translation | No | No | Yes |
| Custom converters | Yes | Yes | No |
| Format strings | Yes | Yes | No |
| CustomizeMapping | Yes | Yes | No |
| BeforeMapping | Yes | Yes | No |
| Nested DTOs | Yes (recursive) | Yes (recursive) | Yes (inlined) |
| Collections of DTOs | Yes | Yes | Yes (inlined) |
| Circular references | Yes (tracked) | Yes (tracked) | Not supported |
| Navigation loading | Manual Include | Manual Include | Automatic (SQL JOIN) |
Rule of thumb: Use Projection whenever you are querying from EF Core. Fall back to FromEntity() only when you need converters, format strings, hooks, or are working with in-memory objects.
Basic Query
Section titled “Basic Query”var dtos = await db.Properties .Where(p => p.IsActive) .Select(PropertySummaryDto.Projection) .ToListAsync();With Pragmatic.Mapping.EFCore Extensions
Section titled “With Pragmatic.Mapping.EFCore Extensions”The Pragmatic.Mapping.EFCore package provides convenience methods with built-in OpenTelemetry tracing:
using Pragmatic.Mapping.EFCore.Extensions;
// Listvar dtos = await db.Properties .Where(p => p.IsActive) .ToListDtoAsync(PropertySummaryDto.Projection);
// Single itemvar dto = await db.Properties .Where(p => p.Id == id) .FirstOrDefaultDtoAsync(PropertySummaryDto.Projection);
// Paginatedvar page = await db.Properties .Where(p => p.IsActive) .OrderBy(p => p.Name) .ToPagedDtoAsync(PropertySummaryDto.Projection, pageNumber: 1, pageSize: 20);
// page.Items -- IReadOnlyList<PropertySummaryDto>// page.TotalCount -- Total matching rows// page.TotalPages -- Computed from TotalCount / PageSize// page.HasNextPage -- Boolean// page.HasPreviousPage
// Offset/Limit (cursor-based)var slice = await db.Properties .OrderBy(p => p.Name) .ToSliceDtoAsync(PropertySummaryDto.Projection, offset: 40, limit: 20);Single or Array
Section titled “Single or Array”var dto = await db.Properties .Where(p => p.Code == "PROP-001") .SingleOrDefaultDtoAsync(PropertySummaryDto.Projection);
var dtos = await db.Properties .Where(p => p.City == "Vienna") .ToArrayDtoAsync(PropertySummaryDto.Projection);Nested DTO Projections
Section titled “Nested DTO Projections”When a DTO has a nested DTO property, the generator inlines the nested projection instead of calling FromEntity(). This keeps the entire expression SQL-translatable.
[MapFrom<Order>][GenerateProjection]public partial class OrderDto{ public Guid Id { get; init; } public decimal Total { get; init; } public AddressDto? ShippingAddress { get; init; } public List<OrderLineDto> Lines { get; init; } = [];}
[MapFrom<Address>]public partial class AddressDto{ public string Street { get; init; } = ""; public string City { get; init; } = "";}
[MapFrom<OrderLine>]public partial class OrderLineDto{ public string ProductName { get; init; } = ""; public int Quantity { get; init; }}The generated projection inlines everything:
public static Expression<Func<Order, OrderDto>> Projection { get; } = entity => new OrderDto { Id = entity.Id, Total = entity.Total, ShippingAddress = entity.ShippingAddress == null ? null : new AddressDto { Street = entity.ShippingAddress!.Street, City = entity.ShippingAddress!.City, }, Lines = entity.Lines.Select(x => new OrderLineDto { ProductName = x.ProductName, Quantity = x.Quantity, }).ToList(), };EF Core translates this to SQL JOINs automatically — no Include() calls needed.
Deep nesting (3+ levels) is supported with recursive inlining. The generator uses unique variable names (__e0, __e1, etc.) to avoid lambda parameter conflicts.
Navigation Flattening in Projections
Section titled “Navigation Flattening in Projections”[MapProperty] with a navigation path works in projections. The generator converts null-conditional access (?.) to null-forgiving access (!.) because Expression Trees do not support ?. — EF Core handles null semantics in SQL.
[MapFrom<RoomType>][GenerateProjection]public partial class RoomTypeSummaryDto{ public string Name { get; init; } = "";
[MapProperty("Property.Name")] public string PropertyName { get; init; } = "";}Generated projection:
entity => new RoomTypeSummaryDto{ Name = entity.Name, PropertyName = entity.Property!.Name, // !. instead of ?.}This translates to a SQL JOIN on the Property table.
Auto-Defaults in Projections
Section titled “Auto-Defaults in Projections”When a nullable source maps to a non-nullable target, the generator uses null-coalescing (??) with EF Core-compatible defaults:
| Source | Target | Generated Expression |
|---|---|---|
string? | string | entity.Name ?? "" |
int? | int | entity.Count ?? 0 |
decimal? | decimal | entity.Amount ?? 0m |
bool? | bool | entity.Flag ?? false |
Guid? | Guid | entity.Id ?? Guid.Empty |
DateTime? | DateTime | entity.Date ?? default(DateTime) |
enum? | enum | entity.Status ?? default(MyEnum) |
These expressions translate cleanly to SQL COALESCE.
Required Navigations
Section titled “Required Navigations”The generator analyzes mapping paths and tracks which navigations the DTO needs. This metadata is available as a static property:
// Generated on ReservationSummaryDtopublic static IReadOnlyList<string> RequiredNavigations { get; } = ["Guest", "Property"];This tells you exactly which Include() calls are needed when using FromEntity() instead of Projection:
// When not using Projection, you need these Includesvar reservation = await db.Reservations .Include(r => r.Guest) .Include(r => r.Property) .FirstOrDefaultAsync(r => r.PersistenceId == id);
var dto = ReservationSummaryDto.FromEntity(reservation);With Projection, these navigations are resolved as SQL JOINs automatically.
Limitations
Section titled “Limitations”Features that cannot be translated to SQL are excluded from projections with compile-time warnings:
| Feature | In FromEntity | In Projection | Diagnostic |
|---|---|---|---|
[MapConverter<T>] | Yes | Excluded | PRAG0320 |
[MapProperty(Format = "...")] | Yes | Excluded | PRAG0321 |
CustomizeMapping() | Yes | Ignored | PRAG0319 |
BeforeMapping() | Yes | Ignored | — |
| Circular references | Tracked | Not supported | — |
| Complex dictionary values | Yes | Not supported | PRAG0322 |
When a property is excluded from projection, it receives default in the SQL result. Structure your DTOs accordingly — consider having a projection-safe DTO for queries and a richer DTO for single-entity display.
Projection vs SelectDto vs Direct Select
Section titled “Projection vs SelectDto vs Direct Select”All three approaches produce the same SQL:
// 1. Direct Select (standard EF Core)var dtos = await db.Properties.Select(PropertySummaryDto.Projection).ToListAsync();
// 2. SelectDto extension (Pragmatic.Mapping.EFCore, same thing + readability)var dtos = await db.Properties.SelectDto(PropertySummaryDto.Projection).ToListAsync();
// 3. ToListDtoAsync (Pragmatic.Mapping.EFCore, convenience + tracing)var dtos = await db.Properties.ToListDtoAsync(PropertySummaryDto.Projection);The ToListDtoAsync and related methods add OpenTelemetry activity tracing (Pragmatic.Mapping activity source) with tags for source type, DTO type, and result count.