You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
Copy file name to clipboardExpand all lines: docs/guide/integrations/mongodb.md
+6Lines changed: 6 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -43,6 +43,12 @@ db.Customers
43
43
44
44
No custom MQL is emitted — MongoDB's own translator does all the heavy lifting after ExpressiveSharp has normalized the tree.
45
45
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
+
46
52
## Async Methods
47
53
48
54
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:
Copy file name to clipboardExpand all lines: docs/guide/migration-from-projectables.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -122,7 +122,7 @@ Both the old `Ignore` and `Rewrite` behaviors converge to the same result in Exp
122
122
123
123
| Old Property | Migration |
124
124
|---|---|
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. |
126
126
|`AllowBlockBody = true`| Keep -- block bodies remain opt-in. Set per-member or globally via `Expressive_AllowBlockBody` MSBuild property. |
127
127
|`ExpandEnumMethods = true`| Remove -- enum method expansion is enabled by default. |
128
128
|`CompatibilityMode.Full / .Limited`| Remove -- only the full approach exists. |
Copy file name to clipboardExpand all lines: docs/recipes/projection-middleware.md
+14-13Lines changed: 14 additions & 13 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1,6 +1,6 @@
1
1
# Computed Properties in Projection Middleware
2
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.
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
4
5
5
## Why plain `[Expressive]` isn't enough
6
6
@@ -10,7 +10,7 @@ HotChocolate's `[UseProjection]` middleware (and similar mechanisms in other lib
10
10
query { users { fullName } }
11
11
```
12
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.
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
14
15
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
16
@@ -23,7 +23,7 @@ Turning on `Projectable = true` makes the property writable (so the projection m
23
23
24
24
## Before and after
25
25
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.
SQL emitted: `SELECT u.LastName || ', ' || u.FirstName AS "FullName" FROM Users u`-- formula pushed into SQL.
61
61
62
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.
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.
122
122
123
123
## Full AutoMapper example
124
124
@@ -161,8 +161,9 @@ internal static class UserMappings
161
161
162
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
163
164
-
## Further reading
164
+
## See Also
165
165
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`
Copy file name to clipboardExpand all lines: docs/reference/projectable-properties.md
+28-28Lines changed: 28 additions & 28 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -24,9 +24,9 @@ db.Users.Select(src => new User { FullName = src.FullName });
24
24
25
25
What happens next, silently:
26
26
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.
28
28
- 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.
30
30
- HC constructs `User` instances with all fields at their defaults (`FirstName = ""`, `LastName = ""`), then reads `user.FullName`.
31
31
- The getter evaluates `"" + ", " + ""` and returns `", "`.
32
32
- Your GraphQL response is `{ "users": [{ "fullName": ", " }, { "fullName": ", " }] }`.
@@ -69,57 +69,57 @@ A Projectable property has two states, distinguished by the backing field's valu
69
69
|**Not materialized**|`null`| Evaluates the formula from dependencies | In-memory construction (`new User { FirstName = "Ada" }`) |
70
70
|**Materialized**| non-null | The stored value | EF / HC wrote to `init` after computing the formula in SQL |
71
71
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.
73
73
74
74
```csharp
75
-
// State 1 — in memory, field is null, formula fires
75
+
// State 1 -- in memory, field is null, formula fires
// State 2 — after SQL materialization, stored value wins
79
+
// State 2 -- after SQL materialization, stored value wins
80
80
varu2=awaitdb.Users.FirstAsync();
81
81
u2.FullName; // "Lovelace, Ada" (stored, originally computed server-side)
82
82
83
83
// 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
86
86
```
87
87
88
88
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.
89
89
90
90
### Gotcha: stale values after dependency mutation
91
91
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.
93
93
94
94
```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.
96
96
varuser=awaitdb.Users.FirstAsync(u=>u.Id==1);
97
97
user.FullName; // "Lovelace, Ada"
98
98
99
99
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.
101
101
```
102
102
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.
104
104
105
105
**The staleness applies only to materialized instances.** Two cases where the formula still fires on every read:
106
106
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.
109
109
110
110
**If you need the formula to rerun after dependency mutation on a materialized instance**, you have a few options:
111
111
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.
115
115
116
116
## When to use it vs. plain `[Expressive]`
117
117
118
118
Only turn `Projectable = true` on when you actually have the problem above. The quick test:
119
119
120
120
> *Does anything in my stack generate `Select(src => new Entity { ... })` over this entity type?*
121
121
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.
123
123
124
124
## Syntax
125
125
@@ -134,7 +134,7 @@ public string FullName
134
134
}
135
135
```
136
136
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).
138
138
-**Init/set accessor**: must be `=> <backingField> = value`. Transformations like `value?.Trim()` are rejected with [EXP0023](./diagnostics#exp0023).
139
139
-**Class does not need to be `partial`.****Property does not need to be `partial`.**
140
140
@@ -143,15 +143,15 @@ public string FullName
143
143
Either the C# 14 `field` keyword (preferred) or a manually declared private nullable field works:
144
144
145
145
```csharp
146
-
// Option A — `field` keyword (C# 14+)
146
+
// Option A -- `field` keyword (C# 14+)
147
147
[Expressive(Projectable=true)]
148
148
publicstringFullName
149
149
{
150
150
get=>field?? (LastName+", "+FirstName);
151
151
init=>field=value;
152
152
}
153
153
154
-
// Option B — manual backing field
154
+
// Option B -- manual backing field
155
155
privatestring?_fullName;
156
156
157
157
[Expressive(Projectable=true)]
@@ -168,8 +168,8 @@ The manual field must be **private**, **on the same type as the property**, and
168
168
169
169
Both are accepted. Pick based on whether callers should be able to override the stored value after construction:
170
170
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.
173
173
174
174
## Restrictions
175
175
@@ -198,7 +198,7 @@ No `[NotMapped]` annotation or manual `modelBuilder.Ignore(...)` call is require
198
198
199
199
## Comparison with `[ExpressiveFor]`
200
200
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:
202
202
203
203
```csharp
204
204
publicclassUser
@@ -215,13 +215,13 @@ internal static class UserMappings
215
215
216
216
Both produce identical SQL and both work with HC/AutoMapper. Pick based on preference:
217
217
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.
220
220
221
221
See the [migration guide](../guide/migration-from-projectables#migrating-usememberbody) for side-by-side examples.
0 commit comments