Skip to content

Commit 90c7a82

Browse files
koenbeukclaude
andcommitted
docs: align Projectable pages with post-facelift conventions
- Replace Unicode em-dashes with double-hyphens to match reference/recipe style - Rename "Further reading" → "See Also" in the projection-middleware recipe - Update UseMemberBody migration row to mention both replacement options - Add MongoDB BSON unmapping convention note Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 91d51e8 commit 90c7a82

4 files changed

Lines changed: 49 additions & 42 deletions

File tree

docs/guide/integrations/mongodb.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ 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 registers 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+
The convention is registered automatically the first time you construct an `ExpressiveMongoCollection<T>` or call `.AsExpressive()` on a collection. No opt-in is required.
51+
4652
## Async Methods
4753

4854
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: 1 addition & 1 deletion
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 `[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. |

docs/recipes/projection-middleware.md

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Computed Properties in Projection Middleware
22

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.
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.
44

55
## Why plain `[Expressive]` isn't enough
66

@@ -10,7 +10,7 @@ HotChocolate's `[UseProjection]` middleware (and similar mechanisms in other lib
1010
query { users { fullName } }
1111
```
1212

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

@@ -23,7 +23,7 @@ Turning on `Projectable = true` makes the property writable (so the projection m
2323

2424
## Before and after
2525

26-
**Before** plain `[Expressive]` on a read-only property. Broken for projection middleware.
26+
**Before** -- plain `[Expressive]` on a read-only property. Broken for projection middleware.
2727

2828
```csharp
2929
public class User
@@ -36,10 +36,10 @@ public class User
3636
}
3737
```
3838

39-
GraphQL response: `{ "users": [{ "fullName": ", " }, { "fullName": ", " }] }` wrong.
40-
SQL emitted: `SELECT 1 FROM Users` nothing fetched.
39+
GraphQL response: `{ "users": [{ "fullName": ", " }, { "fullName": ", " }] }` -- wrong.
40+
SQL emitted: `SELECT 1 FROM Users` -- nothing fetched.
4141

42-
**After** `Projectable = true`.
42+
**After** -- `Projectable = true`.
4343

4444
```csharp
4545
public class User
@@ -56,8 +56,8 @@ public class User
5656
}
5757
```
5858

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.
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.
6161

6262
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.
6363

@@ -118,7 +118,7 @@ SELECT (u.LastName || ', ' || u.FirstName) || ' <' || u.Email || '>' AS "Display
118118
FROM Users u
119119
```
120120

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

123123
## Full AutoMapper example
124124

@@ -161,8 +161,9 @@ internal static class UserMappings
161161

162162
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.
163163

164-
## Further reading
164+
## See Also
165165

166-
- [Projectable Properties reference](../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.
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

docs/reference/projectable-properties.md

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ db.Users.Select(src => new User { FullName = src.FullName });
2424

2525
What happens next, silently:
2626

27-
- HotChocolate checks whether `FullName` is writable (it looks at `PropertyInfo.CanWrite`). It isn't getter only.
27+
- HotChocolate checks whether `FullName` is writable (it looks at `PropertyInfo.CanWrite`). It isn't -- getter only.
2828
- HC **drops** the `FullName = src.FullName` binding from the projection. No warning, no error.
29-
- EF emits `SELECT 1 FROM Users` nothing is fetched because the projection is empty.
29+
- EF emits `SELECT 1 FROM Users` -- nothing is fetched because the projection is empty.
3030
- HC constructs `User` instances with all fields at their defaults (`FirstName = ""`, `LastName = ""`), then reads `user.FullName`.
3131
- The getter evaluates `"" + ", " + ""` and returns `", "`.
3232
- Your GraphQL response is `{ "users": [{ "fullName": ", " }, { "fullName": ", " }] }`.
@@ -69,57 +69,57 @@ A Projectable property has two states, distinguished by the backing field's valu
6969
| **Not materialized** | `null` | Evaluates the formula from dependencies | In-memory construction (`new User { FirstName = "Ada" }`) |
7070
| **Materialized** | non-null | The stored value | EF / HC wrote to `init` after computing the formula in SQL |
7171

72-
The `??` operator picks between the two. In both cases, reading `user.FullName` returns the correct value the difference is only *where* the value came from.
72+
The `??` operator picks between the two. In both cases, reading `user.FullName` returns the correct value -- the difference is only *where* the value came from.
7373

7474
```csharp
75-
// State 1 in memory, field is null, formula fires
75+
// State 1 -- in memory, field is null, formula fires
7676
var u1 = new User { FirstName = "Ada", LastName = "Lovelace" };
7777
u1.FullName; // "Lovelace, Ada" (computed)
7878
79-
// State 2 after SQL materialization, stored value wins
79+
// State 2 -- after SQL materialization, stored value wins
8080
var u2 = await db.Users.FirstAsync();
8181
u2.FullName; // "Lovelace, Ada" (stored, originally computed server-side)
8282
8383
// Mutation behavior differs between states
84-
u1.FirstName = "Augusta"; u1.FullName; // "Lovelace, Augusta" formula reruns
85-
u2.FirstName = "Augusta"; u2.FullName; // "Lovelace, Ada" stored value wins
84+
u1.FirstName = "Augusta"; u1.FullName; // "Lovelace, Augusta" -- formula reruns
85+
u2.FirstName = "Augusta"; u2.FullName; // "Lovelace, Ada" -- stored value wins
8686
```
8787

8888
The mutation-after-materialization behavior mirrors how EF's change tracking works: once a value is loaded, it stays put until something deliberately writes to it.
8989

9090
### Gotcha: stale values after dependency mutation
9191

92-
Once the backing field has been written which happens every time the property is materialized from a SQL projection mutating the formula's dependencies does **not** update the stored value. This can surprise users coming from [EFCore.Projectables](https://github.com/koenbeuk/EntityFrameworkCore.Projectables), where the formula always reruns on every read.
92+
Once the backing field has been written -- which happens every time the property is materialized from a SQL projection -- mutating the formula's dependencies does **not** update the stored value. This can surprise users coming from [EFCore.Projectables](https://github.com/koenbeuk/EntityFrameworkCore.Projectables), where the formula always reruns on every read.
9393

9494
```csharp
95-
// Loaded via EF/HC projection that included FullName `field` is now populated.
95+
// Loaded via EF/HC projection that included FullName -- `field` is now populated.
9696
var user = await db.Users.FirstAsync(u => u.Id == 1);
9797
user.FullName; // "Lovelace, Ada"
9898
9999
user.FirstName = "Augusta";
100-
user.FullName; // Still "Lovelace, Ada" the stored value wins, formula is not rerun.
100+
user.FullName; // Still "Lovelace, Ada" -- the stored value wins, formula is not rerun.
101101
```
102102

103-
**Why this behaves this way**: the stored value is authoritative. ExpressiveSharp treats a materialized property the same way EF treats any loaded property if you want a change reflected, you write it explicitly. This keeps the two states (in-memory-computed vs. SQL-materialized) from silently disagreeing with each other.
103+
**Why this behaves this way**: the stored value is authoritative. ExpressiveSharp treats a materialized property the same way EF treats any loaded property -- if you want a change reflected, you write it explicitly. This keeps the two states (in-memory-computed vs. SQL-materialized) from silently disagreeing with each other.
104104

105105
**The staleness applies only to materialized instances.** Two cases where the formula still fires on every read:
106106

107-
- **Constructed in memory** (`new User { FirstName = "Ada", LastName = "Lovelace" }`) `field` is null, mutations to `FirstName`/`LastName` propagate as you'd expect.
108-
- **Loaded without projecting the property** (e.g. `db.Users.FirstAsync()` without a `Select` that includes `FullName`) the `init` accessor was never called, so `field` is still null.
107+
- **Constructed in memory** (`new User { FirstName = "Ada", LastName = "Lovelace" }`) -- `field` is null, mutations to `FirstName`/`LastName` propagate as you'd expect.
108+
- **Loaded without projecting the property** (e.g. `db.Users.FirstAsync()` without a `Select` that includes `FullName`) -- the `init` accessor was never called, so `field` is still null.
109109

110110
**If you need the formula to rerun after dependency mutation on a materialized instance**, you have a few options:
111111

112-
1. **Re-fetch the entity** let EF re-run the query with the new values.
113-
2. **Use plain `[Expressive]` with a DTO projection** if you're not going through projection middleware, a read-only `[Expressive]` on a DTO type is simpler and has no staleness.
114-
3. **Expose a reset method** for example `public void ResetFullName() { typeof(User).GetField(...).SetValue(this, null); }` to null out the backing field and let the formula fire again on the next read. This is rarely worth the complexity; options 1 and 2 cover the common cases.
112+
1. **Re-fetch the entity** -- let EF re-run the query with the new values.
113+
2. **Use plain `[Expressive]` with a DTO projection** -- if you're not going through projection middleware, a read-only `[Expressive]` on a DTO type is simpler and has no staleness.
114+
3. **Expose a reset method** -- for example `public void ResetFullName() { typeof(User).GetField(...).SetValue(this, null); }` to null out the backing field and let the formula fire again on the next read. This is rarely worth the complexity; options 1 and 2 cover the common cases.
115115

116116
## When to use it vs. plain `[Expressive]`
117117

118118
Only turn `Projectable = true` on when you actually have the problem above. The quick test:
119119

120120
> *Does anything in my stack generate `Select(src => new Entity { ... })` over this entity type?*
121121
122-
If you can answer yes (HotChocolate with `[UseProjection]`, AutoMapper `ProjectTo<SameType>`, Mapperly projections, hand-rolled patterns), the property needs to be Projectable. If not if you only ever project into DTOs or read the property directly plain `[Expressive]` is simpler and has no restrictions.
122+
If you can answer yes (HotChocolate with `[UseProjection]`, AutoMapper `ProjectTo<SameType>`, Mapperly projections, hand-rolled patterns), the property needs to be Projectable. If not -- if you only ever project into DTOs or read the property directly -- plain `[Expressive]` is simpler and has no restrictions.
123123

124124
## Syntax
125125

@@ -134,7 +134,7 @@ public string FullName
134134
}
135135
```
136136

137-
- **Get accessor**: must be `=> <backingField> ?? (<formula>)`. The generator matches this exact shape ternaries (`a != null ? a : b`), block bodies with `if`/`return`, and other forms are rejected with [EXP0022](./diagnostics#exp0022).
137+
- **Get accessor**: must be `=> <backingField> ?? (<formula>)`. The generator matches this exact shape -- ternaries (`a != null ? a : b`), block bodies with `if`/`return`, and other forms are rejected with [EXP0022](./diagnostics#exp0022).
138138
- **Init/set accessor**: must be `=> <backingField> = value`. Transformations like `value?.Trim()` are rejected with [EXP0023](./diagnostics#exp0023).
139139
- **Class does not need to be `partial`.** **Property does not need to be `partial`.**
140140

@@ -143,15 +143,15 @@ public string FullName
143143
Either the C# 14 `field` keyword (preferred) or a manually declared private nullable field works:
144144

145145
```csharp
146-
// Option A `field` keyword (C# 14+)
146+
// Option A -- `field` keyword (C# 14+)
147147
[Expressive(Projectable = true)]
148148
public string FullName
149149
{
150150
get => field ?? (LastName + ", " + FirstName);
151151
init => field = value;
152152
}
153153

154-
// Option B manual backing field
154+
// Option B -- manual backing field
155155
private string? _fullName;
156156

157157
[Expressive(Projectable = true)]
@@ -168,8 +168,8 @@ The manual field must be **private**, **on the same type as the property**, and
168168

169169
Both are accepted. Pick based on whether callers should be able to override the stored value after construction:
170170

171-
- `init` value can only be set through object initializers (EF, HC) or the constructor. Recommended default.
172-
- `set` callers can also assign `user.FullName = "..."` directly. Useful if you want to support manual overrides.
171+
- `init` -- value can only be set through object initializers (EF, HC) or the constructor. Recommended default.
172+
- `set` -- callers can also assign `user.FullName = "..."` directly. Useful if you want to support manual overrides.
173173

174174
## Restrictions
175175

@@ -198,7 +198,7 @@ No `[NotMapped]` annotation or manual `modelBuilder.Ignore(...)` call is require
198198

199199
## Comparison with `[ExpressiveFor]`
200200

201-
`[ExpressiveFor]` is the verbose alternative the formula lives in a separate static stub instead of on the property:
201+
`[ExpressiveFor]` is the verbose alternative -- the formula lives in a separate static stub instead of on the property:
202202

203203
```csharp
204204
public class User
@@ -215,13 +215,13 @@ internal static class UserMappings
215215

216216
Both produce identical SQL and both work with HC/AutoMapper. Pick based on preference:
217217

218-
- **`[Expressive(Projectable = true)]`** formula lives on the property. Single declaration site. Recommended default.
219-
- **`[ExpressiveFor]`** formula in a separate class. More explicit. Required when you can't modify the entity type (third-party code) or need to map cross-type.
218+
- **`[Expressive(Projectable = true)]`** -- formula lives on the property. Single declaration site. Recommended default.
219+
- **`[ExpressiveFor]`** -- formula in a separate class. More explicit. Required when you can't modify the entity type (third-party code) or need to map cross-type.
220220

221221
See the [migration guide](../guide/migration-from-projectables#migrating-usememberbody) for side-by-side examples.
222222

223223
## Further reading
224224

225-
- [Projection Middleware recipe](../recipes/projection-middleware) end-to-end HotChocolate + AutoMapper examples.
226-
- [`[Expressive]` Attribute reference](./expressive-attribute) base attribute.
227-
- [`[ExpressiveFor]` reference](./expressive-for) verbose alternative.
225+
- [Projection Middleware recipe](../recipes/projection-middleware) -- end-to-end HotChocolate + AutoMapper examples.
226+
- [`[Expressive]` Attribute reference](./expressive-attribute) -- base attribute.
227+
- [`[ExpressiveFor]` reference](./expressive-for) -- verbose alternative.

0 commit comments

Comments
 (0)