Skip to content

Commit 6808d2d

Browse files
authored
Merge pull request #4 from EFNext/feat/proxied-expressives
Introduce ExpressiveFor and ExpressiveForConstructor attributes
2 parents 0046f23 + ffab74a commit 6808d2d

30 files changed

Lines changed: 1878 additions & 14 deletions

README.md

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ Mark computed properties and methods with [`[Expressive]`](#expressive-attribute
9090
| **Any `IQueryable`** — modern syntax + `[Expressive]` expansion | [`.WithExpressionRewrite()`](#irewritablequeryt) |
9191
| **Advanced** — build an `Expression<T>` inline, no attribute needed | [`ExpressionPolyfill.Create`](#expressionpolyfillcreate) |
9292
| **Advanced** — expand `[Expressive]` members in an existing expression tree | [`.ExpandExpressives()`](#expressive-attribute) |
93+
| **Advanced** — make third-party/BCL members expressable | [`[ExpressiveFor]`](#expressivefor--external-member-mapping) |
9394

9495
## Usage
9596

@@ -327,11 +328,63 @@ public double Total => Price * Quantity;
327328
expr.ExpandExpressives(new MyTransformer());
328329
```
329330

331+
## `[ExpressiveFor]` — External Member Mapping
332+
333+
Provide expression-tree bodies for members on types you don't own — BCL methods, third-party libraries, or your own members that can't use `[Expressive]` directly. This lets you use those members in EF Core queries that would otherwise fail with "could not be translated".
334+
335+
```csharp
336+
using ExpressiveSharp.Mapping;
337+
338+
// Static method — params match the target signature
339+
static class MathMappings
340+
{
341+
[ExpressiveFor(typeof(Math), nameof(Math.Clamp))]
342+
static double Clamp(double value, double min, double max)
343+
=> value < min ? min : (value > max ? max : value);
344+
}
345+
346+
// Instance method — first param is the receiver
347+
static class StringMappings
348+
{
349+
[ExpressiveFor(typeof(string), nameof(string.IsNullOrWhiteSpace))]
350+
static bool IsNullOrWhiteSpace(string? s)
351+
=> s == null || s.Trim().Length == 0;
352+
}
353+
354+
// Instance property on your own type
355+
static class EntityMappings
356+
{
357+
[ExpressiveFor(typeof(MyType), nameof(MyType.FullName))]
358+
static string FullName(MyType obj)
359+
=> obj.FirstName + " " + obj.LastName;
360+
}
361+
```
362+
363+
Call sites are unchanged — the replacer substitutes the mapping automatically:
364+
365+
```csharp
366+
// Without [ExpressiveFor]: throws "could not be translated"
367+
// With [ExpressiveFor]: Math.Clamp → ternary expression → translated to SQL
368+
var results = db.Orders
369+
.AsExpressiveDbSet()
370+
.Where(o => Math.Clamp(o.Price, 20, 100) > 50)
371+
.ToList();
372+
```
373+
374+
Use `[ExpressiveForConstructor]` for constructors:
375+
376+
```csharp
377+
[ExpressiveForConstructor(typeof(MyDto))]
378+
static MyDto Create(int id, string name) => new MyDto { Id = id, Name = name };
379+
```
380+
381+
> **Note:** If a member already has `[Expressive]`, adding `[ExpressiveFor]` targeting it is a compile error (EXP0019). `[ExpressiveFor]` is for members that *don't* have `[Expressive]`.
382+
330383
## How It Works
331384

332385
ExpressiveSharp uses two Roslyn source generators:
333386

334-
1. **`ExpressiveGenerator`** — Finds `[Expressive]` members, analyzes them at the semantic level (IOperation), and generates `Expression<Func<...>>` factory code using `Expression.*` calls. Registers them in a per-assembly expression registry for runtime lookup.
387+
1. **`ExpressiveGenerator`** — Finds `[Expressive]` and `[ExpressiveFor]` members, analyzes them at the semantic level (IOperation), and generates `Expression<Func<...>>` factory code using `Expression.*` calls. Registers them in a per-assembly expression registry for runtime lookup.
335388

336389
2. **`PolyfillInterceptorGenerator`** — Uses C# 13 method interceptors to replace `ExpressionPolyfill.Create` calls and `IRewritableQueryable<T>` LINQ methods at their call sites, converting lambdas to expression trees at compile time.
337390

docs/migration-from-projectables.md

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,75 @@ public string? CustomerName => Customer?.Name;
112112

113113
| Old Property | Migration |
114114
|---|---|
115-
| `UseMemberBody = "SomeMethod"` | Remove — no longer supported. This was typically used to work around syntax limitations in Projectable expression bodies (e.g., pointing to a simpler method when block bodies weren't allowed). Since ExpressiveSharp supports block bodies, switch expressions, pattern matching, and more, you likely don't need it. If you do, please open an issue. |
115+
| `UseMemberBody = "SomeMethod"` | Replace with `[ExpressiveFor]`. See [Migrating `UseMemberBody`](#migrating-usememberbody) below. |
116116
| `AllowBlockBody = true` | Remove — block bodies work automatically. `UseExpressives()` registers `FlattenBlockExpressions` globally for EF Core. |
117117
| `ExpandEnumMethods = true` | Remove — enum method expansion is enabled by default |
118118
| `CompatibilityMode.Full / .Limited` | Remove — only the full approach exists (query compiler decoration) |
119119

120+
### Migrating `UseMemberBody`
121+
122+
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 a member whose actual body couldn't be projected.
123+
124+
ExpressiveSharp replaces this with `[ExpressiveFor]` (in the `ExpressiveSharp.Mapping` namespace), which is more explicit and works for external types too.
125+
126+
**Scenario 1: Same-type member with an alternative body**
127+
128+
```csharp
129+
// Before (Projectables) — FullName body can't be projected, so use a helper
130+
public string FullName => $"{FirstName} {LastName}".Trim().ToUpper();
131+
132+
[Projectable(UseMemberBody = nameof(FullNameProjection))]
133+
public string FullName => ...;
134+
private string FullNameProjection => FirstName + " " + LastName;
135+
136+
// After (ExpressiveSharp) — [ExpressiveFor] provides the expression body
137+
using ExpressiveSharp.Mapping;
138+
139+
public string FullName => $"{FirstName} {LastName}".Trim().ToUpper();
140+
141+
// Stub provides the expression-tree-friendly equivalent
142+
[ExpressiveFor(typeof(MyEntity), nameof(MyEntity.FullName))]
143+
static string FullNameExpr(MyEntity e) => e.FirstName + " " + e.LastName;
144+
```
145+
146+
**Scenario 2: External/third-party type methods**
147+
148+
`[ExpressiveFor]` also enables a use case that Projectables' `UseMemberBody` never supported — providing expression tree bodies for methods on types you don't own:
149+
150+
```csharp
151+
using ExpressiveSharp.Mapping;
152+
153+
// Make Math.Clamp usable in EF Core queries
154+
[ExpressiveFor(typeof(Math), nameof(Math.Clamp))]
155+
static double Clamp(double value, double min, double max)
156+
=> value < min ? min : (value > max ? max : value);
157+
158+
// Now this translates to SQL instead of throwing:
159+
db.Orders.Where(o => Math.Clamp(o.Price, 20, 100) > 50)
160+
```
161+
162+
**Scenario 3: Constructors**
163+
164+
```csharp
165+
using ExpressiveSharp.Mapping;
166+
167+
[ExpressiveForConstructor(typeof(OrderDto))]
168+
static OrderDto CreateDto(int id, string name)
169+
=> new OrderDto { Id = id, Name = name };
170+
```
171+
172+
**Key differences from `UseMemberBody`:**
173+
174+
| | `UseMemberBody` (Projectables) | `[ExpressiveFor]` (ExpressiveSharp) |
175+
|---|---|---|
176+
| Scope | Same type only | Any type (including external/third-party) |
177+
| Syntax | Property on `[Projectable]` | Separate attribute on a stub method |
178+
| Target member | Must be in the same class | Any accessible type |
179+
| Namespace | `EntityFrameworkCore.Projectables` | `ExpressiveSharp.Mapping` |
180+
| Constructors | Not supported | `[ExpressiveForConstructor]` |
181+
182+
> **Note:** Many `UseMemberBody` use cases in Projectables existed because of syntax limitations — the projected member's body couldn't use switch expressions, pattern matching, or block bodies. Since ExpressiveSharp supports all of these, you may be able to simply put `[Expressive]` directly on the member and delete the helper entirely.
183+
120184
### MSBuild Properties
121185

122186
| Old Property | Migration |
@@ -137,7 +201,7 @@ The `InterceptorsNamespaces` MSBuild property needed for method interceptors is
137201

138202
4. **`ProjectableOptionsBuilder` callback removed**`UseProjectables(opts => { ... })` becomes `UseExpressives()` with no parameters. Global transformer configuration is done via `ExpressiveOptions.Default` if needed.
139203

140-
5. **`UseMemberBody` property removed**This was typically a workaround for syntax limitations in Projectable expression bodies. Since ExpressiveSharp supports block bodies, switch expressions, pattern matching, and more, you likely don't need it. Remove any `UseMemberBody` assignments. If your use case still requires it, please [open an issue](https://github.com/EFNext/ExpressiveSharp/issues).
204+
5. **`UseMemberBody` property removed**Replaced by `[ExpressiveFor]` from the `ExpressiveSharp.Mapping` namespace. See [Migrating `UseMemberBody`](#migrating-usememberbody).
141205

142206
6. **`CompatibilityMode` removed** — ExpressiveSharp always uses the full query-compiler-decoration approach. The `Limited` compatibility mode does not exist.
143207

@@ -166,6 +230,7 @@ The `InterceptorsNamespaces` MSBuild property needed for method interceptors is
166230
| Modern syntax in LINQ chains | No | Yes (`IRewritableQueryable<T>`) |
167231
| Custom transformers | No | `IExpressionTreeTransformer` interface |
168232
| `ExpressiveDbSet<T>` | No | Yes |
233+
| External member mapping | `UseMemberBody` (same type only) | `[ExpressiveFor]` (any type, including third-party) |
169234
| EF Core specific | Yes | No — works standalone |
170235
| Compatibility modes | Full / Limited | Full only (simpler) |
171236
| Code generation approach | Syntax tree rewriting | Semantic (IOperation) analysis |
@@ -271,12 +336,32 @@ public class MyTransformer : IExpressionTreeTransformer
271336
public double AdjustedTotal => Price * Quantity * 1.1;
272337
```
273338

339+
### External Member Mapping (`[ExpressiveFor]`)
340+
341+
Provide expression-tree bodies for methods on types you don't own. This enables using BCL or third-party utility methods in EF Core queries that would otherwise fail with "could not be translated":
342+
343+
```csharp
344+
using ExpressiveSharp.Mapping;
345+
346+
static class MathMappings
347+
{
348+
[ExpressiveFor(typeof(Math), nameof(Math.Abs))]
349+
static int Abs(int value) => value < 0 ? -value : value;
350+
}
351+
352+
// Math.Abs is now translatable to SQL:
353+
db.Orders.Where(o => Math.Abs(o.Discount) > 10).ToList();
354+
```
355+
356+
This also replaces Projectables' `UseMemberBody` — see [Migrating `UseMemberBody`](#migrating-usememberbody) for details.
357+
274358
## Quick Migration Checklist
275359

276360
1. Remove all `EntityFrameworkCore.Projectables*` NuGet packages
277361
2. Add `ExpressiveSharp.EntityFrameworkCore`
278362
3. Buildthe built-in migration analyzers will flag all Projectables API usage
279363
4. Use **Fix All in Solution** for each diagnostic (`EXP1001`, `EXP1002`, `EXP1003`) to auto-fix
280364
5. Remove any `Projectables_*` MSBuild properties from `.csproj` / `Directory.Build.props`
281-
6. Build again and fix any remaining compilation errors
282-
7. Run your test suite to verify query behavior is unchanged
365+
6. Replace any `UseMemberBody` usage with `[ExpressiveFor]` (see [Migrating `UseMemberBody`](#migrating-usememberbody))
366+
7. Build again and fix any remaining compilation errors
367+
8. Run your test suite to verify query behavior is unchanged
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
using System.Runtime.CompilerServices;
2+
using ExpressiveSharp.Generator.Models;
3+
using Microsoft.CodeAnalysis;
4+
using Microsoft.CodeAnalysis.CSharp.Syntax;
5+
6+
namespace ExpressiveSharp.Generator.Comparers;
7+
8+
/// <summary>
9+
/// Equality comparer for [ExpressiveFor] pipeline tuples,
10+
/// mirroring <see cref="MemberDeclarationSyntaxAndCompilationEqualityComparer"/> for the standard pipeline.
11+
/// </summary>
12+
internal class ExpressiveForMemberCompilationEqualityComparer
13+
: IEqualityComparer<((MethodDeclarationSyntax Method, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation)>
14+
{
15+
private readonly static MemberDeclarationSyntaxEqualityComparer _memberComparer = new();
16+
17+
public bool Equals(
18+
((MethodDeclarationSyntax Method, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) x,
19+
((MethodDeclarationSyntax Method, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) y)
20+
{
21+
var (xLeft, xCompilation) = x;
22+
var (yLeft, yCompilation) = y;
23+
24+
if (ReferenceEquals(xLeft.Method, yLeft.Method) &&
25+
ReferenceEquals(xCompilation, yCompilation) &&
26+
xLeft.GlobalOptions == yLeft.GlobalOptions)
27+
{
28+
return true;
29+
}
30+
31+
if (!ReferenceEquals(xLeft.Method.SyntaxTree, yLeft.Method.SyntaxTree))
32+
{
33+
return false;
34+
}
35+
36+
if (xLeft.Attribute != yLeft.Attribute)
37+
{
38+
return false;
39+
}
40+
41+
if (xLeft.GlobalOptions != yLeft.GlobalOptions)
42+
{
43+
return false;
44+
}
45+
46+
if (!_memberComparer.Equals(xLeft.Method, yLeft.Method))
47+
{
48+
return false;
49+
}
50+
51+
return xCompilation.ExternalReferences.SequenceEqual(yCompilation.ExternalReferences);
52+
}
53+
54+
public int GetHashCode(((MethodDeclarationSyntax Method, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) obj)
55+
{
56+
var (left, compilation) = obj;
57+
unchecked
58+
{
59+
var hash = 17;
60+
hash = hash * 31 + _memberComparer.GetHashCode(left.Method);
61+
hash = hash * 31 + RuntimeHelpers.GetHashCode(left.Method.SyntaxTree);
62+
hash = hash * 31 + left.Attribute.GetHashCode();
63+
hash = hash * 31 + left.GlobalOptions.GetHashCode();
64+
65+
var references = compilation.ExternalReferences;
66+
var referencesHash = 17;
67+
referencesHash = referencesHash * 31 + references.Length;
68+
foreach (var reference in references)
69+
{
70+
referencesHash = referencesHash * 31 + RuntimeHelpers.GetHashCode(reference);
71+
}
72+
hash = hash * 31 + referencesHash;
73+
74+
return hash;
75+
}
76+
}
77+
}

0 commit comments

Comments
 (0)