|
| 1 | +# Computed Properties in Projection Middleware |
| 2 | + |
| 3 | +If your computed property returns an empty value -- or is silently dropped from the response -- when consumed by **HotChocolate**, **AutoMapper's `ProjectTo`**, **Mapperly's projection mode**, or any other framework that emits `Select(src => new Entity { Member = src.Member, ... })` over your entity type, this recipe is the fix. |
| 4 | + |
| 5 | +## Why plain `[Expressive]` isn't enough |
| 6 | + |
| 7 | +HotChocolate's `[UseProjection]` middleware (and similar mechanisms in other libraries) generates a projection expression from the GraphQL selection set. Empirically, when the GraphQL query asks for only `fullName`: |
| 8 | + |
| 9 | +```graphql |
| 10 | +query { users { fullName } } |
| 11 | +``` |
| 12 | + |
| 13 | +HotChocolate inspects the `User.FullName` property, finds it is **read-only** (no setter), and silently drops it from the projection. The generated SQL is `SELECT 1 FROM Users` -- nothing is fetched. At materialization time, HC constructs fresh `User` instances with all fields at their defaults (`FirstName = ""`, `LastName = ""`), calls the `FullName` getter, and the formula returns `", "`. The response looks successful but the data is wrong. |
| 14 | + |
| 15 | +The same mechanism affects AutoMapper's `ProjectTo<Entity>`, Mapperly's generated projections, and any hand-rolled `Select(u => new User { ... })` that projects into the source type itself. |
| 16 | + |
| 17 | +## The fix: `[Expressive(Projectable = true)]` |
| 18 | + |
| 19 | +Turning on `Projectable = true` makes the property writable (so the projection middleware emits a binding) while still registering the formula for SQL translation. The dual-direction runtime behavior is: |
| 20 | + |
| 21 | +- **In memory**, reading the property evaluates the formula from dependencies (same as plain `[Expressive]`). |
| 22 | +- **After materialization from SQL**, reading the property returns the stored value (which the middleware's binding wrote via the `init` accessor). |
| 23 | + |
| 24 | +## Before and after |
| 25 | + |
| 26 | +**Before** -- plain `[Expressive]` on a read-only property. Broken for projection middleware. |
| 27 | + |
| 28 | +```csharp |
| 29 | +public class User |
| 30 | +{ |
| 31 | + public string FirstName { get; set; } = ""; |
| 32 | + public string LastName { get; set; } = ""; |
| 33 | + |
| 34 | + [Expressive] |
| 35 | + public string FullName => LastName + ", " + FirstName; |
| 36 | +} |
| 37 | +``` |
| 38 | + |
| 39 | +GraphQL response: `{ "users": [{ "fullName": ", " }, { "fullName": ", " }] }` -- wrong. |
| 40 | +SQL emitted: `SELECT 1 FROM Users` -- nothing fetched. |
| 41 | + |
| 42 | +**After** -- `Projectable = true`. |
| 43 | + |
| 44 | +```csharp |
| 45 | +public class User |
| 46 | +{ |
| 47 | + public string FirstName { get; set; } = ""; |
| 48 | + public string LastName { get; set; } = ""; |
| 49 | + |
| 50 | + [Expressive(Projectable = true)] |
| 51 | + public string FullName |
| 52 | + { |
| 53 | + get => field ?? (LastName + ", " + FirstName); |
| 54 | + init => field = value; |
| 55 | + } |
| 56 | +} |
| 57 | +``` |
| 58 | + |
| 59 | +GraphQL response: `{ "users": [{ "fullName": "Lovelace, Ada" }, { "fullName": "Turing, Alan" }] }` -- correct. |
| 60 | +SQL emitted: `SELECT u.LastName || ', ' || u.FirstName AS "FullName" FROM Users u` -- formula pushed into SQL. |
| 61 | + |
| 62 | +No HC glue code is required beyond the normal `.UseExpressives()` on the DbContext options. The convention auto-ignores the property in EF's model (so no `FullName` column is created), and the projection rewrite happens automatically when the query compiler intercepts. |
| 63 | + |
| 64 | +## Full HotChocolate example |
| 65 | + |
| 66 | +```csharp |
| 67 | +// Entity |
| 68 | +public class User |
| 69 | +{ |
| 70 | + public int Id { get; set; } |
| 71 | + public string FirstName { get; set; } = ""; |
| 72 | + public string LastName { get; set; } = ""; |
| 73 | + public string Email { get; set; } = ""; |
| 74 | + |
| 75 | + [Expressive(Projectable = true)] |
| 76 | + public string FullName |
| 77 | + { |
| 78 | + get => field ?? (LastName + ", " + FirstName); |
| 79 | + init => field = value; |
| 80 | + } |
| 81 | + |
| 82 | + [Expressive(Projectable = true)] |
| 83 | + public string DisplayLabel |
| 84 | + { |
| 85 | + get => field ?? (FullName + " <" + Email + ">"); |
| 86 | + init => field = value; |
| 87 | + } |
| 88 | +} |
| 89 | + |
| 90 | +// DbContext |
| 91 | +public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options) |
| 92 | +{ |
| 93 | + public DbSet<User> Users => Set<User>(); |
| 94 | +} |
| 95 | + |
| 96 | +// Program.cs |
| 97 | +builder.Services.AddDbContext<AppDbContext>(o => o |
| 98 | + .UseSqlServer(connectionString) |
| 99 | + .UseExpressives()); |
| 100 | + |
| 101 | +builder.Services |
| 102 | + .AddGraphQLServer() |
| 103 | + .AddQueryType<Query>() |
| 104 | + .AddProjections(); |
| 105 | + |
| 106 | +// Query type |
| 107 | +public class Query |
| 108 | +{ |
| 109 | + [UseProjection] |
| 110 | + public IQueryable<User> GetUsers([Service] AppDbContext db) => db.Users; |
| 111 | +} |
| 112 | +``` |
| 113 | + |
| 114 | +A GraphQL query for `{ users { displayLabel } }` now produces: |
| 115 | + |
| 116 | +```sql |
| 117 | +SELECT (u.LastName || ', ' || u.FirstName) || ' <' || u.Email || '>' AS "DisplayLabel" |
| 118 | +FROM Users u |
| 119 | +``` |
| 120 | + |
| 121 | +Notice how `DisplayLabel` composes with `FullName` (which is itself Projectable) -- the transitive rewrite is handled automatically by the expression resolver. |
| 122 | + |
| 123 | +## Full AutoMapper example |
| 124 | + |
| 125 | +AutoMapper's `ProjectTo<T>()` emits the same `new T { ... }` pattern as HotChocolate, so Projectable members work the same way: |
| 126 | + |
| 127 | +```csharp |
| 128 | +var config = new MapperConfiguration(cfg => |
| 129 | +{ |
| 130 | + cfg.CreateMap<User, User>(); // same-type projection |
| 131 | +}); |
| 132 | + |
| 133 | +var users = await db.Users |
| 134 | + .ProjectTo<User>(config) |
| 135 | + .ToListAsync(); |
| 136 | + |
| 137 | +// SQL emitted: SELECT u.Id, u.FirstName, u.LastName, u.Email, |
| 138 | +// (u.LastName || ', ' || u.FirstName) AS "FullName", |
| 139 | +// (... || u.Email || '>') AS "DisplayLabel" |
| 140 | +// FROM Users u |
| 141 | +``` |
| 142 | + |
| 143 | +## When to use `[ExpressiveFor]` instead |
| 144 | + |
| 145 | +If your class can't use the C# 14 `field` keyword, or you want to keep the formula in a separate mapping class, the [`[ExpressiveFor]`](../reference/expressive-for) pattern is a verbose alternative: |
| 146 | + |
| 147 | +```csharp |
| 148 | +public class User |
| 149 | +{ |
| 150 | + public string FirstName { get; set; } = ""; |
| 151 | + public string LastName { get; set; } = ""; |
| 152 | + public string FullName { get; set; } = ""; // plain auto-property |
| 153 | +} |
| 154 | + |
| 155 | +internal static class UserMappings |
| 156 | +{ |
| 157 | + [ExpressiveFor(typeof(User), nameof(User.FullName))] |
| 158 | + private static string FullName(User u) => u.LastName + ", " + u.FirstName; |
| 159 | +} |
| 160 | +``` |
| 161 | + |
| 162 | +Both approaches produce the same SQL and work identically with HotChocolate / AutoMapper. The `Projectable` form is more concise and keeps the formula on the property itself; the `ExpressiveFor` form is explicit about the separation. See the [migration guide](../guide/migration-from-projectables) for a side-by-side comparison. |
| 163 | + |
| 164 | +## See Also |
| 165 | + |
| 166 | +- [Projectable Properties](../reference/projectable-properties) -- full reference including restrictions and runtime semantics |
| 167 | +- [`[ExpressiveFor]` Mapping](../reference/expressive-for) -- alternative pattern for scenarios where you can't modify the entity type |
| 168 | +- [Migrating from Projectables](../guide/migration-from-projectables) -- side-by-side migration paths for `UseMemberBody` |
| 169 | +- [Computed Entity Properties](./computed-properties) -- plain `[Expressive]` computed values for DTO projections |
0 commit comments