Skip to content

Commit 8026759

Browse files
authored
Merge pull request #31 from EFNext/feat/expressive-projectables
feat: add [Expressive(Projectable = true)] for projection middleware compatibility
2 parents c6cc588 + 811f5df commit 8026759

24 files changed

Lines changed: 2299 additions & 3 deletions

File tree

docs/.vitepress/config.mts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ const sidebar: DefaultTheme.Sidebar = {
5757
text: 'Reference',
5858
items: [
5959
{ text: '[Expressive] Attribute', link: '/reference/expressive-attribute' },
60+
{ text: 'Projectable Properties', link: '/reference/projectable-properties' },
6061
{ text: '[ExpressiveFor] Mapping', link: '/reference/expressive-for' },
6162
{ text: 'Null-Conditional Rewrite', link: '/reference/null-conditional-rewrite' },
6263
{ text: 'Pattern Matching', link: '/reference/pattern-matching' },
@@ -85,6 +86,7 @@ const sidebar: DefaultTheme.Sidebar = {
8586
text: 'Recipes',
8687
items: [
8788
{ text: 'Computed Entity Properties', link: '/recipes/computed-properties' },
89+
{ text: 'Projection Middleware (HotChocolate, AutoMapper)', link: '/recipes/projection-middleware' },
8890
{ text: 'DTO Projections with Constructors', link: '/recipes/dto-projections' },
8991
{ text: 'Scoring & Classification', link: '/recipes/scoring-classification' },
9092
{ text: 'Nullable Navigation Properties', link: '/recipes/nullable-navigation' },

docs/guide/integrations/mongodb.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,27 @@ db.Customers
4343

4444
No custom MQL is emitted — MongoDB's own translator does all the heavy lifting after ExpressiveSharp has normalized the tree.
4545

46+
## `[Expressive]` Properties Are Unmapped from BSON
47+
48+
ExpressiveSharp provides a MongoDB `IClassMapConvention` that unmaps every `[Expressive]`-decorated property from the BSON class map, so the property's backing field is not persisted to documents. This matters most for [Projectable properties](../../reference/projectable-properties), which have a writable `init` accessor and would otherwise be serialized as a real BSON field.
49+
50+
::: warning Ordering constraint
51+
MongoDB builds and caches a class map the first time you call `IMongoDatabase.GetCollection<T>()` for a given `T`. A convention registered *after* that call does not apply to the cached map. If any of your document types use `[Expressive]`, register the convention before the first `GetCollection<T>` call:
52+
53+
```csharp
54+
using ExpressiveSharp.MongoDB.Infrastructure;
55+
56+
// At application startup, before any GetCollection<T>:
57+
ExpressiveMongoIgnoreConvention.EnsureRegistered();
58+
59+
var client = new MongoClient(connectionString);
60+
var db = client.GetDatabase("shop");
61+
var customers = db.GetCollection<Customer>("customers"); // class map built now
62+
```
63+
64+
The convention is also registered automatically when you construct `ExpressiveMongoCollection<T>` or call `collection.AsExpressive()` — but only if that happens before any `GetCollection<T>` call for a type with `[Expressive]` properties. The explicit `EnsureRegistered()` call is the most reliable pattern.
65+
:::
66+
4667
## Async Methods
4768

4869
All MongoDB async LINQ methods (from `MongoQueryable`) work with modern syntax via interceptors. They are stubs on `IExpressiveMongoQueryable<T>` that forward to their `MongoQueryable` counterparts:

docs/guide/migration-from-projectables.md

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,16 +122,46 @@ Both the old `Ignore` and `Rewrite` behaviors converge to the same result in Exp
122122

123123
| Old Property | Migration |
124124
|---|---|
125-
| `UseMemberBody = "SomeMethod"` | Replace with `[ExpressiveFor]`. See [Migrating UseMemberBody](#migrating-usememberbody) below. |
125+
| `UseMemberBody = "SomeMethod"` | Replace with `[Expressive(Projectable = true)]` or `[ExpressiveFor]`. See [Migrating UseMemberBody](#migrating-usememberbody) below. |
126126
| `AllowBlockBody = true` | Keep -- block bodies remain opt-in. Set per-member or globally via `Expressive_AllowBlockBody` MSBuild property. |
127127
| `ExpandEnumMethods = true` | Remove -- enum method expansion is enabled by default. |
128128
| `CompatibilityMode.Full / .Limited` | Remove -- only the full approach exists. |
129129

130130
### Migrating `UseMemberBody`
131131

132-
In Projectables, `UseMemberBody` let you point one member's expression body at another member -- typically to work around syntax limitations or to provide an expression-tree-friendly alternative.
132+
In Projectables, `UseMemberBody` let you point one member's expression body at another member -- typically to work around syntax limitations or to provide an expression-tree-friendly alternative for projection middleware (HotChocolate, AutoMapper) that required a writable target.
133133

134-
ExpressiveSharp replaces this with `[ExpressiveFor]` (in the `ExpressiveSharp.Mapping` namespace), which is more explicit and works for external types too.
134+
ExpressiveSharp offers **two replacement options**, depending on your scenario:
135+
136+
- **`[Expressive(Projectable = true)]`** -- the ergonomic fit when your goal was specifically to participate in projection middleware. Keeps the formula on the property itself via the `field ?? (<formula>)` pattern. Closest to Projectables' intent.
137+
- **`[ExpressiveFor]`** -- the verbose but explicit alternative. Works for external types too (scenarios `UseMemberBody` never supported).
138+
139+
Either is correct; pick based on ergonomic preference and whether you need the cross-type capability.
140+
141+
::: info About the `Projectable` name overlap
142+
ExpressiveSharp's `Projectable` attribute property and the EFCore.Projectables library's `[Projectable]` attribute share a name because both describe the same capability -- a computed property that participates in LINQ projections. They are different mechanisms; the shared word is intentional to reduce migration friction.
143+
:::
144+
145+
**Option A -- `[Expressive(Projectable = true)]`** (single declaration):
146+
147+
```csharp
148+
// Before (Projectables)
149+
[Projectable(UseMemberBody = nameof(FullNameProjection))]
150+
public string FullName { get; init; }
151+
private string FullNameProjection => LastName + ", " + FirstName;
152+
153+
// After (ExpressiveSharp) -- formula lives on the property
154+
[Expressive(Projectable = true)]
155+
public string FullName
156+
{
157+
get => field ?? (LastName + ", " + FirstName);
158+
init => field = value;
159+
}
160+
```
161+
162+
See the [Projectable Properties reference](../reference/projectable-properties) and the [Projection Middleware recipe](../recipes/projection-middleware) for the complete feature.
163+
164+
**Option B -- `[ExpressiveFor]`** (separate stub, supports cross-type mapping):
135165

136166
**Scenario 1: Same-type member with an alternative body**
137167

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
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

Comments
 (0)