Skip to content

Commit 07cde84

Browse files
authored
Merge pull request #40 from EFNext/feat/expressive-for-synthesize
Wrap up replacing Projectables with ExpressiveFor with synthesized properties
2 parents cc0c346 + 6d62c4c commit 07cde84

48 files changed

Lines changed: 2245 additions & 2962 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/.vitepress/config.mts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ 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' },
6160
{ text: '[ExpressiveFor] Mapping', link: '/reference/expressive-for' },
61+
{ text: '[ExpressiveProperty] Attribute', link: '/reference/expressive-property' },
6262
{ text: 'Null-Conditional Rewrite', link: '/reference/null-conditional-rewrite' },
6363
{ text: 'Pattern Matching', link: '/reference/pattern-matching' },
6464
{ text: 'Switch Expressions', link: '/reference/switch-expressions' },

‎docs/guide/integrations/mongodb.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ No custom MQL is emitted — MongoDB's own translator does all the heavy lifting
4545

4646
## `[Expressive]` Properties Are Unmapped from BSON
4747

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.
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 [synthesized properties](../../reference/expressive-for#synthesizing-a-property-with-synthesize-true), which have a writable `init` accessor and would otherwise be serialized as a real BSON field.
4949

5050
::: warning Ordering constraint
5151
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:

‎docs/guide/migration-from-projectables.md‎

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ 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 `[Expressive(Projectable = true)]` or `[ExpressiveFor]`. See [Migrating UseMemberBody](#migrating-usememberbody) below. |
125+
| `UseMemberBody = "SomeMethod"` | Replace with `[ExpressiveProperty]` or plain `[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. |
@@ -131,37 +131,36 @@ Both the old `Ignore` and `Rewrite` behaviors converge to the same result in Exp
131131

132132
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 offers **two replacement options**, depending on your scenario:
134+
ExpressiveSharp offers **two replacement shapes**, depending on your scenario:
135135

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).
136+
- **`[ExpressiveProperty]`** -- the closest analogue: you write only the formula; the generator synthesizes the settable target property on a `partial` class. The property participates in projection middleware because it has an `init` accessor. Best fit when you want a dedicated property backed purely by an expression.
137+
- **Plain `[ExpressiveFor]`** -- when the target property already exists (or lives on an external type you do not own). No property is synthesized; the stub maps to an existing member.
138138

139-
Either is correct; pick based on ergonomic preference and whether you need the cross-type capability.
139+
Pick based on whether you want the generator to declare the target property for you.
140140

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):
141+
**Option A -- `[ExpressiveProperty]`** (formula-only, property is generated):
146142

147143
```csharp
148144
// Before (Projectables)
149145
[Projectable(UseMemberBody = nameof(FullNameProjection))]
150146
public string FullName { get; init; }
151147
private string FullNameProjection => LastName + ", " + FirstName;
152148

153-
// After (ExpressiveSharp) -- formula lives on the property
154-
[Expressive(Projectable = true)]
155-
public string FullName
149+
// After (ExpressiveSharp) -- partial class, stub only; FullName is generated
150+
public partial class Customer
156151
{
157-
get => field ?? (LastName + ", " + FirstName);
158-
init => field = value;
152+
[ExpressiveProperty("FullName")]
153+
private string FullNameExpression => LastName + ", " + FirstName;
159154
}
160155
```
161156

162-
See the [Projectable Properties reference](../reference/projectable-properties) and the [Projection Middleware recipe](../recipes/projection-middleware) for the complete feature.
157+
The generator picks between a coalesce shape (non-nullable targets) and a ternary+flag shape (nullable targets) so materialized `null` stays distinguishable from "not materialized." See the [`[ExpressiveProperty]` reference](../reference/expressive-property) and the [Projection Middleware recipe](../recipes/projection-middleware).
158+
159+
::: warning Target name must be a string literal
160+
The target property does not exist during the generator's pass, so `nameof(FullName)` fails to resolve. Always pass the name as a string literal: `[ExpressiveProperty("FullName")]`.
161+
:::
163162

164-
**Option B -- `[ExpressiveFor]`** (separate stub, also supports cross-type mapping):
163+
**Option B -- plain `[ExpressiveFor]`** (target property already exists, or lives on an external type):
165164

166165
**Scenario 1: Same-type member with an alternative body**
167166

‎docs/recipes/projection-middleware.md‎

Lines changed: 29 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ HotChocolate inspects the `User.FullName` property, finds it is **read-only** (n
1414

1515
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.
1616

17-
## The fix: `[Expressive(Projectable = true)]`
17+
## The fix: `[ExpressiveProperty]`
1818

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:
19+
Write the formula as a stub on a `partial` class and let the generator synthesize a settable property for you. The synthesized property is writable (so the projection middleware emits a binding) while still registering the formula for SQL translation. The dual-direction runtime behavior is:
2020

21-
- **In memory**, reading the property evaluates the formula from dependencies (same as plain `[Expressive]`).
21+
- **In memory**, reading the property evaluates the stub formula from dependencies (same as plain `[Expressive]`).
2222
- **After materialization from SQL**, reading the property returns the stored value (which the middleware's binding wrote via the `init` accessor).
2323

2424
## Before and after
@@ -39,52 +39,46 @@ public class User
3939
GraphQL response: `{ "users": [{ "fullName": ", " }, { "fullName": ", " }] }` -- wrong.
4040
SQL emitted: `SELECT 1 FROM Users` -- nothing fetched.
4141

42-
**After** -- `Projectable = true`.
42+
**After** -- `[ExpressiveProperty]` on a formula stub.
4343

4444
```csharp
45-
public class User
45+
public partial class User
4646
{
4747
public string FirstName { get; set; } = "";
4848
public string LastName { get; set; } = "";
4949

50-
[Expressive(Projectable = true)]
51-
public string FullName
52-
{
53-
get => field ?? (LastName + ", " + FirstName);
54-
init => field = value;
55-
}
50+
// The generator emits a settable FullName property whose getter falls through
51+
// to this stub when no value has been materialized yet.
52+
[ExpressiveProperty("FullName")]
53+
private string FullNameExpression => LastName + ", " + FirstName;
5654
}
5755
```
5856

5957
GraphQL response: `{ "users": [{ "fullName": "Lovelace, Ada" }, { "fullName": "Turing, Alan" }] }` -- correct.
6058
SQL emitted: `SELECT u.LastName || ', ' || u.FirstName AS "FullName" FROM Users u` -- formula pushed into SQL.
6159

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.
60+
No HC glue code is required beyond the normal `.UseExpressives()` on the DbContext options. The convention auto-ignores the synthesized property in EF's model (so no `FullName` column is created), and the projection rewrite happens automatically when the query compiler intercepts.
61+
62+
::: warning Target name must be a string literal
63+
`nameof(FullName)` won't resolve here because `FullName` doesn't exist until the generator emits it. Pass the name as a string literal: `[ExpressiveProperty("FullName")]`.
64+
:::
6365

6466
## Full HotChocolate example
6567

6668
```csharp
6769
// Entity
68-
public class User
70+
public partial class User
6971
{
7072
public int Id { get; set; }
7173
public string FirstName { get; set; } = "";
7274
public string LastName { get; set; } = "";
7375
public string Email { get; set; } = "";
7476

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-
}
77+
[ExpressiveProperty("FullName")]
78+
private string FullNameExpression => LastName + ", " + FirstName;
79+
80+
[ExpressiveProperty("DisplayLabel")]
81+
private string DisplayLabelExpression => FullName + " <" + Email + ">";
8882
}
8983

9084
// DbContext
@@ -118,11 +112,11 @@ SELECT (u.LastName || ', ' || u.FirstName) || ' <' || u.Email || '>' AS "Display
118112
FROM Users u
119113
```
120114

121-
Notice how `DisplayLabel` composes with `FullName` (which is itself Projectable) -- the transitive rewrite is handled automatically by the expression resolver.
115+
Notice how `DisplayLabel` composes with `FullName` (which is itself synthesized) -- the transitive rewrite is handled automatically by the expression resolver.
122116

123117
## Full AutoMapper example
124118

125-
AutoMapper's `ProjectTo<T>()` emits the same `new T { ... }` pattern as HotChocolate, so Projectable members work the same way:
119+
AutoMapper's `ProjectTo<T>()` emits the same `new T { ... }` pattern as HotChocolate, so synthesized members work the same way:
126120

127121
```csharp
128122
var config = new MapperConfiguration(cfg =>
@@ -140,30 +134,26 @@ var users = await db.Users
140134
// FROM Users u
141135
```
142136

143-
## When to use `[ExpressiveFor]` instead
137+
## When the target property already exists
144138

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:
139+
If you already have a settable auto-property (e.g. because it is used for DTO shape or deserialization), use the plain `[ExpressiveFor]` form:
146140

147141
```csharp
148142
public class User
149143
{
150144
public string FirstName { get; set; } = "";
151145
public string LastName { get; set; } = "";
152-
public string FullName { get; set; } = ""; // plain auto-property
153-
}
146+
public string FullName { get; set; } = ""; // existing auto-property
154147
155-
internal static class UserMappings
156-
{
157-
[ExpressiveFor(typeof(User), nameof(User.FullName))]
158-
private static string FullName(User u) => u.LastName + ", " + u.FirstName;
148+
[ExpressiveFor(nameof(FullName))]
149+
private string FullNameExpression => LastName + ", " + FirstName;
159150
}
160151
```
161152

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.
153+
The two forms produce the same SQL behaviour; the difference is who declares the target property. Use `[ExpressiveProperty]` when the property exists only to support projection middleware; use plain `[ExpressiveFor]` when the property has its own reason to exist.
163154

164155
## See Also
165156

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
157+
- [`[ExpressiveProperty]` Attribute](../reference/expressive-property) -- full reference
168158
- [Migrating from Projectables](../guide/migration-from-projectables) -- side-by-side migration paths for `UseMemberBody`
169159
- [Computed Entity Properties](./computed-properties) -- plain `[Expressive]` computed values for DTO projections

0 commit comments

Comments
 (0)