Skip to content

Commit ad1718a

Browse files
authored
Merge pull request #38 from EFNext/feat/expressive-for-enhancements
Simplified ExpressiveFor
2 parents 245ce8b + de4629b commit ad1718a

23 files changed

Lines changed: 1230 additions & 266 deletions

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,12 @@ public string FullName
161161

162162
See the [Projectable Properties reference](../reference/projectable-properties) and the [Projection Middleware recipe](../recipes/projection-middleware) for the complete feature.
163163

164-
**Option B -- `[ExpressiveFor]`** (separate stub, supports cross-type mapping):
164+
**Option B -- `[ExpressiveFor]`** (separate stub, also supports cross-type mapping):
165165

166166
**Scenario 1: Same-type member with an alternative body**
167167

168+
Use the co-located form: a property stub on the same class combined with the single-argument attribute. `this` is the receiver naturally -- the migration reads almost identically to `UseMemberBody`.
169+
168170
```csharp
169171
// Before (Projectables)
170172
public string FullName => $"{FirstName} {LastName}".Trim().ToUpper();
@@ -178,8 +180,8 @@ using ExpressiveSharp.Mapping;
178180

179181
public string FullName => $"{FirstName} {LastName}".Trim().ToUpper();
180182

181-
[ExpressiveFor(typeof(MyEntity), nameof(MyEntity.FullName))]
182-
static string FullNameExpr(MyEntity e) => e.FirstName + " " + e.LastName;
183+
[ExpressiveFor(nameof(FullName))]
184+
private string FullNameExpression => FirstName + " " + LastName;
183185
```
184186

185187
**Scenario 2: External/third-party type methods**
@@ -212,9 +214,9 @@ static OrderDto CreateDto(int id, string name)
212214

213215
| | `UseMemberBody` (Projectables) | `[ExpressiveFor]` (ExpressiveSharp) |
214216
|---|---|---|
215-
| Scope | Same type only | Any type (including external/third-party) |
217+
| Scope | Same type only | Same type **or** any accessible type (including external/third-party) |
216218
| Syntax | Property on `[Projectable]` | Separate attribute on a stub method |
217-
| Target member | Must be in the same class | Any accessible type |
219+
| Target member | Must be in the same class | Co-located (single-arg form, `this` is receiver) or cross-type (two-arg form) |
218220
| Namespace | `EntityFrameworkCore.Projectables` | `ExpressiveSharp.Mapping` |
219221
| Constructors | Not supported | `[ExpressiveForConstructor]` |
220222

‎docs/recipes/external-member-mapping.md‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,23 +191,47 @@ static class DateTimeMappings
191191
|------|-------------|
192192
| EXP0014 | `[ExpressiveFor]` target type not found |
193193
| EXP0015 | `[ExpressiveFor]` target member not found on the specified type |
194-
| EXP0016 | `[ExpressiveFor]` stub method must be `static` |
195194
| EXP0017 | Return type of stub does not match target member's return type |
196195
| EXP0019 | Target member already has `[Expressive]` -- use `[Expressive]` directly instead |
197196
| EXP0020 | Duplicate `[ExpressiveFor]` mapping for the same target member |
198197

199198
## Tips
200199

201200
::: tip Match the signature exactly
202-
For static methods, the stub parameters must match the target method signature. For instance members, add the receiver as the first parameter (e.g., `static string FullName(Person p)`).
201+
For static methods, the stub parameters must match the target method signature. For instance members you have three options: write a `static` method stub whose first parameter is the receiver (e.g. `static string FullName(Person p)`), write an `instance` method stub on the target type itself where the stub's `this` is the receiver, or write a **property stub** on the target type (parameterless; `this` is the receiver) -- property stubs can only target other properties.
202+
:::
203+
204+
::: tip Co-locate when possible
205+
When the target is on the same type as the stub, you can drop `typeof(...)` and use the single-argument form -- it targets a member on the stub's containing type. Combined with an **instance property stub**, this is the ergonomic shape for the case where a property has its own backing storage (a plain settable auto-property used for DTO shape, serialization, or in-memory assignment in tests) but you want queries to compute it from other columns:
206+
207+
```csharp
208+
public class Person
209+
{
210+
public string FirstName { get; set; } = "";
211+
public string LastName { get; set; } = "";
212+
213+
// Regular auto-property — can be assigned directly (for DTOs, tests, deserialization).
214+
public string FullName { get; set; } = "";
215+
216+
// When used in a LINQ expression tree, FullName is rewritten to this body,
217+
// so EF Core projects it from the underlying columns instead of trying to
218+
// map it to a column of its own. `this` is the receiver automatically.
219+
[ExpressiveFor(nameof(FullName))]
220+
private string FullNameExpression => FirstName + " " + LastName;
221+
}
222+
```
223+
224+
A method stub (`string FullNameExpression() => ...`) works the same way and is appropriate when the target is a method or you need a block body.
225+
226+
If the property has no backing storage and the same body works at both runtime and query time, put `[Expressive]` directly on the property and delete the stub.
203227
:::
204228

205229
::: tip Consider [Expressive] first
206230
Many `[ExpressiveFor]` use cases exist because of syntax limitations in other libraries. Since ExpressiveSharp supports switch expressions, pattern matching, string interpolation, and more, you may be able to put `[Expressive]` directly on the member and skip the external mapping entirely.
207231
:::
208232

209233
::: warning Placement
210-
`[ExpressiveFor]` stubs must be in a `static` class. The class can be in any namespace -- it is discovered at compile time by the source generator.
234+
`[ExpressiveFor]` stubs can live either in a `static` helper class (with a `static` method stub) or on the target type itself as an instance method or property. Either way, they are discovered at compile time by the source generator.
211235
:::
212236

213237
## See Also

‎docs/reference/diagnostics.md‎

Lines changed: 4 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ The ExpressiveSharp source generator and companion analyzers emit diagnostics du
66
See [Troubleshooting](./troubleshooting) for symptom-oriented guidance -- find the error message or behavior you see and get step-by-step resolution.
77
:::
88

9+
::: info Retired diagnostics
10+
`EXP0016` ("`[ExpressiveFor]` stub must be static") has been retired. Instance stubs on the target type are now permitted; constructor-stub and unrelated-type mismatches surface as `EXP0015` (member not found) instead.
11+
:::
12+
913
## Overview
1014

1115
| ID | Severity | Title | Code Fix |
@@ -25,7 +29,6 @@ See [Troubleshooting](./troubleshooting) for symptom-oriented guidance -- find t
2529
| [EXP0013](#exp0013) | Warning | Member could benefit from `[Expressive]` | [Add `[Expressive]`](#exp0013-fix) |
2630
| [EXP0014](#exp0014) | Error | `[ExpressiveFor]` target type not found | -- |
2731
| [EXP0015](#exp0015) | Error | `[ExpressiveFor]` target member not found | -- |
28-
| [EXP0016](#exp0016) | Error | `[ExpressiveFor]` stub must be static | -- |
2932
| [EXP0017](#exp0017) | Error | `[ExpressiveFor]` return type mismatch | -- |
3033
| [EXP0019](#exp0019) | Error | `[ExpressiveFor]` conflicts with `[Expressive]` | -- |
3134
| [EXP0020](#exp0020) | Error | Duplicate `[ExpressiveFor]` mapping | -- |
@@ -417,32 +420,6 @@ static double Clamp(double value, double min, double max)
417420

418421
---
419422

420-
### EXP0016 -- Stub must be static {#exp0016}
421-
422-
**Severity:** Error
423-
**Category:** Design
424-
425-
**Message:**
426-
```
427-
[ExpressiveFor] stub method '{0}' must be static
428-
```
429-
430-
**Cause:** The stub method is not declared `static`.
431-
432-
**Fix:** Add the `static` modifier:
433-
434-
```csharp
435-
// Error: not static
436-
[ExpressiveFor(typeof(Math), nameof(Math.Clamp))]
437-
double Clamp(double value, double min, double max) => /* ... */;
438-
439-
// Fixed
440-
[ExpressiveFor(typeof(Math), nameof(Math.Clamp))]
441-
static double Clamp(double value, double min, double max) => /* ... */;
442-
```
443-
444-
---
445-
446423
### EXP0017 -- Return type mismatch {#exp0017}
447424

448425
**Severity:** Error

‎docs/reference/expressive-for.md‎

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,21 @@ using ExpressiveSharp.Mapping;
1010

1111
## How It Works
1212

13-
You write a static stub method whose body defines the expression-tree replacement. The `[ExpressiveFor]` attribute tells the generator which external member this stub maps to. At runtime, the replacer substitutes calls to the target member with the stub's expression tree -- call sites remain unchanged.
13+
You write a stub member -- a method **or** a property -- whose body defines the expression-tree replacement. The `[ExpressiveFor]` attribute tells the generator which external member this stub maps to. At runtime, the replacer substitutes calls to the target member with the stub's expression tree -- call sites remain unchanged.
1414

1515
## Mapping Rules
1616

17-
- The stub method **must be `static`** (EXP0016 if not).
18-
- For **static methods**, the stub's parameters must match the target method's parameters exactly.
19-
- For **instance methods**, the first parameter of the stub is the receiver (`this`), followed by the target method's parameters.
20-
- For **instance properties**, the stub takes a single parameter: the receiver.
21-
- The return type must match (EXP0017 if not).
17+
- The stub can be a **method** (receiver supplied as the first parameter for instance targets, or `this` for instance stubs on the target type) **or** a **property** (parameterless; `this` is the receiver for instance stubs).
18+
- The single-argument form `[ExpressiveFor(nameof(X))]` is shorthand for `[ExpressiveFor(typeof(ContainingType), nameof(X))]` -- use it when the target member is on the same type as the stub.
19+
- For **static methods** (and static stubs over static members), the stub's parameters must match the target method's parameters exactly.
20+
- For **instance methods** with a `static` stub, the first parameter of the stub is the receiver (`this`), followed by the target method's parameters.
21+
- For **instance methods** with an `instance` stub on the target type, `this` is the receiver; remaining parameters match the target's exactly.
22+
- For **instance properties** with a `static` method stub, the stub takes a single parameter: the receiver.
23+
- For **instance properties** with an `instance` method or property stub on the target type, the stub is parameterless.
24+
- For **static properties**, the stub is parameterless.
25+
- Property stubs can only target other properties (no parameters to carry method arguments).
26+
- The return type / property type must match (EXP0017 if not).
27+
- Constructor stubs (`[ExpressiveForConstructor]`) must still be `static` methods; instance or property ctor stubs have no coherent meaning.
2228

2329
## Static Method Mapping
2430

@@ -63,10 +69,55 @@ static class EntityMappings
6369
}
6470
```
6571

72+
## Co-located Form (Instance Stub + Single-argument Attribute)
73+
74+
When the target is on the same type as the stub, the most ergonomic form combines an **instance stub** with the **single-argument** attribute. `this` is the receiver automatically. Use this form when a property has its own backing storage -- e.g. a plain settable auto-property used for DTO shape, serialization, or in-memory assignment in tests -- but queries should still compute it from other columns.
75+
76+
A **property stub** is often the cleanest choice for this (no parentheses, reads like the target it replaces):
77+
78+
```csharp
79+
public class Person
80+
{
81+
public string FirstName { get; set; } = "";
82+
public string LastName { get; set; } = "";
83+
84+
// Regular auto-property — assignable directly (for DTOs, tests, deserialization).
85+
public string FullName { get; set; } = "";
86+
87+
// When FullName appears in a LINQ expression tree, it is rewritten to this body,
88+
// so EF Core projects it from FirstName/LastName instead of mapping it to its own column.
89+
[ExpressiveFor(nameof(FullName))]
90+
private string FullNameExpression => FirstName + " " + LastName;
91+
}
92+
```
93+
94+
A **method stub** is equivalent in behaviour and appropriate when the target is a method or when you need a block body:
95+
96+
```csharp
97+
[ExpressiveFor(nameof(FullName))]
98+
private string FullNameExpression() => FirstName + " " + LastName;
99+
```
100+
101+
Both forms are equivalent to the verbose `[ExpressiveFor(typeof(Person), nameof(Person.FullName))] static string FullName(Person obj) => obj.FirstName + " " + obj.LastName;` but reuse `this` instead of threading a receiver parameter. When the EF Core integration is enabled, both the target property **and** the stub property itself are automatically excluded from the model (no `[NotMapped]` needed -- see [Automatic NotMapped for `[ExpressiveFor]` targets](#automatic-notmapped-for-expressivefor-targets)).
102+
103+
::: warning When to prefer `[Expressive]` instead
104+
If the property has no backing storage and the same body works at both runtime and query time, put `[Expressive]` directly on it (`[Expressive] public string FullName => FirstName + " " + LastName;`) and skip the stub. `[ExpressiveFor]` is for the dual-body case; `[Expressive]` is for the single-body case.
105+
:::
106+
66107
::: tip
67108
The stub can use any C# syntax that `[Expressive]` supports -- switch expressions, pattern matching, null-conditional operators, and more.
68109
:::
69110

111+
## Automatic NotMapped for `[ExpressiveFor]` targets
112+
113+
When `UseExpressives()` is active, EF Core's model builder automatically ignores properties that are:
114+
115+
1. Decorated with `[Expressive]`,
116+
2. Decorated with `[ExpressiveFor]` (a property stub itself), **or**
117+
3. The target of an `[ExpressiveFor]` stub anywhere in the loaded assemblies.
118+
119+
You do not need to add `[NotMapped]` to a property you are expressing externally or using as a property stub -- the `ExpressivePropertiesNotMappedConvention` detects these cases via attribute metadata and the generated registry and calls `Ignore()` for you.
120+
70121
## Constructor Mapping with `[ExpressiveForConstructor]`
71122

72123
Use `[ExpressiveForConstructor]` to provide an expression-tree body for a constructor on a type you do not own:
@@ -114,7 +165,6 @@ The following diagnostics are specific to `[ExpressiveFor]` and `[ExpressiveForC
114165
|------|----------|-------------|
115166
| [EXP0014](./diagnostics#exp0014) | Error | Target type specified in `[ExpressiveFor]` could not be resolved |
116167
| [EXP0015](./diagnostics#exp0015) | Error | No member with the given name found on the target type matching the stub's parameter signature |
117-
| [EXP0016](./diagnostics#exp0016) | Error | The stub method must be `static` |
118168
| [EXP0017](./diagnostics#exp0017) | Error | Return type of the stub does not match the target member's return type |
119169
| [EXP0019](./diagnostics#exp0019) | Error | The target member already has `[Expressive]` -- remove one of the two attributes |
120170
| [EXP0020](./diagnostics#exp0020) | Error | Duplicate mapping -- only one stub per target member is allowed |

‎docs/reference/projectable-properties.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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 alternative -- the formula lives in a separate stub (static or co-located instance method) instead of on the property:
202202

203203
```csharp
204204
public class User

‎src/ExpressiveSharp.Abstractions/Mapping/ExpressiveForAttribute.cs‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,14 @@ namespace ExpressiveSharp.Mapping;
1212
/// <para>For <b>instance properties</b>, the stub takes a single parameter (the receiver) and returns the property type.</para>
1313
/// <para>For <b>static properties</b>, the stub is parameterless and returns the property type.</para>
1414
/// </remarks>
15-
[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)]
15+
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = false)]
1616
public sealed class ExpressiveForAttribute : Attribute
1717
{
1818
/// <summary>
19-
/// The type that declares the target member.
19+
/// The type that declares the target member, or <c>null</c> when the single-argument constructor
20+
/// is used (in which case the target defaults to the stub's containing type at generator time).
2021
/// </summary>
21-
public Type TargetType { get; }
22+
public Type? TargetType { get; }
2223

2324
/// <summary>
2425
/// The name of the target member on <see cref="TargetType"/>.
@@ -43,4 +44,14 @@ public ExpressiveForAttribute(Type targetType, string memberName)
4344
TargetType = targetType;
4445
MemberName = memberName;
4546
}
47+
48+
/// <summary>
49+
/// Shorthand for <c>[ExpressiveFor(typeof(ContainingType), memberName)]</c> —
50+
/// use when the target member is on the same type as the stub.
51+
/// </summary>
52+
public ExpressiveForAttribute(string memberName)
53+
{
54+
TargetType = null;
55+
MemberName = memberName;
56+
}
4657
}

‎src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressiveOptionsExtension.cs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ public ExpressiveOptionsExtension(IReadOnlyList<IExpressivePlugin> plugins, bool
4343
[SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Required to decorate query compiler")]
4444
public void ApplyServices(IServiceCollection services)
4545
{
46+
// The expressive resolver is stateless at the instance level (all caches are process-static),
47+
// so a singleton lifetime is appropriate and cheap to share across scopes.
48+
services.TryAddSingleton<IExpressiveResolver, ExpressiveResolver>();
49+
4650
// Register conventions
4751
services.AddScoped<IConventionSetPlugin, ExpressiveDbSetDiscoveryConventionPlugin>();
4852
services.AddScoped<IConventionSetPlugin, ExpressivePropertiesNotMappedConventionPlugin>();

‎src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressivePropertiesNotMappedConvention.cs‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,29 @@
11
using System.Reflection;
2+
using ExpressiveSharp.Mapping;
3+
using ExpressiveSharp.Services;
24
using Microsoft.EntityFrameworkCore.Metadata.Builders;
35
using Microsoft.EntityFrameworkCore.Metadata.Conventions;
46

57
namespace ExpressiveSharp.EntityFrameworkCore.Infrastructure.Internal;
68

79
/// <summary>
8-
/// Convention that marks properties decorated with <see cref="ExpressiveAttribute"/>
9-
/// as unmapped in the EF Core model, since they have no backing database column.
10+
/// Convention that marks properties as unmapped in the EF Core model when they have no backing
11+
/// database column:
12+
/// <list type="bullet">
13+
/// <item>decorated with <see cref="ExpressiveAttribute"/>,</item>
14+
/// <item>decorated with <see cref="ExpressiveForAttribute"/> (the property is a stub itself), or</item>
15+
/// <item>the target of an <see cref="ExpressiveForAttribute"/> stub elsewhere in the solution.</item>
16+
/// </list>
1017
/// </summary>
1118
public class ExpressivePropertiesNotMappedConvention : IEntityTypeAddedConvention
1219
{
20+
private readonly IExpressiveResolver _resolver;
21+
22+
public ExpressivePropertiesNotMappedConvention(IExpressiveResolver resolver)
23+
{
24+
_resolver = resolver;
25+
}
26+
1327
public void ProcessEntityTypeAdded(
1428
IConventionEntityTypeBuilder entityTypeBuilder,
1529
IConventionContext<IConventionEntityTypeBuilder> context)
@@ -19,7 +33,9 @@ public void ProcessEntityTypeAdded(
1933

2034
foreach (var property in entityTypeBuilder.Metadata.ClrType.GetRuntimeProperties())
2135
{
22-
if (property.GetCustomAttribute<ExpressiveAttribute>() is not null)
36+
if (property.GetCustomAttribute<ExpressiveAttribute>() is not null
37+
|| property.GetCustomAttribute<ExpressiveForAttribute>() is not null
38+
|| _resolver.FindExternalExpression(property) is not null)
2339
{
2440
entityTypeBuilder.Ignore(property.Name);
2541
}
Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
1+
using ExpressiveSharp.Services;
12
using Microsoft.EntityFrameworkCore.Metadata.Conventions;
23
using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure;
34

45
namespace ExpressiveSharp.EntityFrameworkCore.Infrastructure.Internal;
56

67
public class ExpressivePropertiesNotMappedConventionPlugin : IConventionSetPlugin
78
{
9+
private readonly IExpressiveResolver _resolver;
10+
11+
public ExpressivePropertiesNotMappedConventionPlugin(IExpressiveResolver resolver)
12+
{
13+
_resolver = resolver;
14+
}
15+
816
public ConventionSet ModifyConventions(ConventionSet conventionSet)
917
{
10-
conventionSet.EntityTypeAddedConventions.Add(new ExpressivePropertiesNotMappedConvention());
18+
conventionSet.EntityTypeAddedConventions.Add(new ExpressivePropertiesNotMappedConvention(_resolver));
1119
return conventionSet;
1220
}
1321
}

0 commit comments

Comments
 (0)