Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ Mark computed properties and methods with [`[Expressive]`](#expressive-attribute
| **Any `IQueryable`** — modern syntax + `[Expressive]` expansion | [`.WithExpressionRewrite()`](#irewritablequeryt) |
| **Advanced** — build an `Expression<T>` inline, no attribute needed | [`ExpressionPolyfill.Create`](#expressionpolyfillcreate) |
| **Advanced** — expand `[Expressive]` members in an existing expression tree | [`.ExpandExpressives()`](#expressive-attribute) |
| **Advanced** — make third-party/BCL members expressable | [`[ExpressiveFor]`](#expressivefor--external-member-mapping) |

## Usage

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

## `[ExpressiveFor]` — External Member Mapping

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

```csharp
using ExpressiveSharp.Mapping;

// Static method — params match the target signature
static class MathMappings
{
[ExpressiveFor(typeof(Math), nameof(Math.Clamp))]
static double Clamp(double value, double min, double max)
=> value < min ? min : (value > max ? max : value);
}

// Instance method — first param is the receiver
static class StringMappings
{
[ExpressiveFor(typeof(string), nameof(string.IsNullOrWhiteSpace))]
static bool IsNullOrWhiteSpace(string? s)
=> s == null || s.Trim().Length == 0;
}

// Instance property on your own type
static class EntityMappings
{
[ExpressiveFor(typeof(MyType), nameof(MyType.FullName))]
static string FullName(MyType obj)
=> obj.FirstName + " " + obj.LastName;
}
```

Call sites are unchanged — the replacer substitutes the mapping automatically:

```csharp
// Without [ExpressiveFor]: throws "could not be translated"
// With [ExpressiveFor]: Math.Clamp → ternary expression → translated to SQL
var results = db.Orders
.AsExpressiveDbSet()
.Where(o => Math.Clamp(o.Price, 20, 100) > 50)
.ToList();
```

Use `[ExpressiveForConstructor]` for constructors:

```csharp
[ExpressiveForConstructor(typeof(MyDto))]
static MyDto Create(int id, string name) => new MyDto { Id = id, Name = name };
```

> **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]`.

## How It Works

ExpressiveSharp uses two Roslyn source generators:

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

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.

Expand Down
93 changes: 89 additions & 4 deletions docs/migration-from-projectables.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,75 @@ public string? CustomerName => Customer?.Name;

| Old Property | Migration |
|---|---|
| `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. |
| `UseMemberBody = "SomeMethod"` | Replace with `[ExpressiveFor]`. See [Migrating `UseMemberBody`](#migrating-usememberbody) below. |
| `AllowBlockBody = true` | Remove — block bodies work automatically. `UseExpressives()` registers `FlattenBlockExpressions` globally for EF Core. |
| `ExpandEnumMethods = true` | Remove — enum method expansion is enabled by default |
| `CompatibilityMode.Full / .Limited` | Remove — only the full approach exists (query compiler decoration) |

### Migrating `UseMemberBody`

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.

ExpressiveSharp replaces this with `[ExpressiveFor]` (in the `ExpressiveSharp.Mapping` namespace), which is more explicit and works for external types too.

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

```csharp
// Before (Projectables) — FullName body can't be projected, so use a helper
public string FullName => $"{FirstName} {LastName}".Trim().ToUpper();

[Projectable(UseMemberBody = nameof(FullNameProjection))]
public string FullName => ...;
private string FullNameProjection => FirstName + " " + LastName;

// After (ExpressiveSharp) — [ExpressiveFor] provides the expression body
using ExpressiveSharp.Mapping;

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

// Stub provides the expression-tree-friendly equivalent
[ExpressiveFor(typeof(MyEntity), nameof(MyEntity.FullName))]
static string FullNameExpr(MyEntity e) => e.FirstName + " " + e.LastName;
```

**Scenario 2: External/third-party type methods**

`[ExpressiveFor]` also enables a use case that Projectables' `UseMemberBody` never supported — providing expression tree bodies for methods on types you don't own:

```csharp
using ExpressiveSharp.Mapping;

// Make Math.Clamp usable in EF Core queries
[ExpressiveFor(typeof(Math), nameof(Math.Clamp))]
static double Clamp(double value, double min, double max)
=> value < min ? min : (value > max ? max : value);

// Now this translates to SQL instead of throwing:
db.Orders.Where(o => Math.Clamp(o.Price, 20, 100) > 50)
```

**Scenario 3: Constructors**

```csharp
using ExpressiveSharp.Mapping;

[ExpressiveForConstructor(typeof(OrderDto))]
static OrderDto CreateDto(int id, string name)
=> new OrderDto { Id = id, Name = name };
```

**Key differences from `UseMemberBody`:**

| | `UseMemberBody` (Projectables) | `[ExpressiveFor]` (ExpressiveSharp) |
|---|---|---|
| Scope | Same type only | Any type (including external/third-party) |
| Syntax | Property on `[Projectable]` | Separate attribute on a stub method |
| Target member | Must be in the same class | Any accessible type |
| Namespace | `EntityFrameworkCore.Projectables` | `ExpressiveSharp.Mapping` |
| Constructors | Not supported | `[ExpressiveForConstructor]` |

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

### MSBuild Properties

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

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

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).
5. **`UseMemberBody` property removed** — Replaced by `[ExpressiveFor]` from the `ExpressiveSharp.Mapping` namespace. See [Migrating `UseMemberBody`](#migrating-usememberbody).

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

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

### External Member Mapping (`[ExpressiveFor]`)

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":

```csharp
using ExpressiveSharp.Mapping;

static class MathMappings
{
[ExpressiveFor(typeof(Math), nameof(Math.Abs))]
static int Abs(int value) => value < 0 ? -value : value;
}

// Math.Abs is now translatable to SQL:
db.Orders.Where(o => Math.Abs(o.Discount) > 10).ToList();
```

This also replaces Projectables' `UseMemberBody` — see [Migrating `UseMemberBody`](#migrating-usememberbody) for details.

## Quick Migration Checklist

1. Remove all `EntityFrameworkCore.Projectables*` NuGet packages
2. Add `ExpressiveSharp.EntityFrameworkCore`
3. Build — the built-in migration analyzers will flag all Projectables API usage
4. Use **Fix All in Solution** for each diagnostic (`EXP1001`, `EXP1002`, `EXP1003`) to auto-fix
5. Remove any `Projectables_*` MSBuild properties from `.csproj` / `Directory.Build.props`
6. Build again and fix any remaining compilation errors
7. Run your test suite to verify query behavior is unchanged
6. Replace any `UseMemberBody` usage with `[ExpressiveFor]` (see [Migrating `UseMemberBody`](#migrating-usememberbody))
7. Build again and fix any remaining compilation errors
8. Run your test suite to verify query behavior is unchanged
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using System.Runtime.CompilerServices;
using ExpressiveSharp.Generator.Models;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace ExpressiveSharp.Generator.Comparers;

/// <summary>
/// Equality comparer for [ExpressiveFor] pipeline tuples,
/// mirroring <see cref="MemberDeclarationSyntaxAndCompilationEqualityComparer"/> for the standard pipeline.
/// </summary>
internal class ExpressiveForMemberCompilationEqualityComparer
: IEqualityComparer<((MethodDeclarationSyntax Method, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation)>
{
private readonly static MemberDeclarationSyntaxEqualityComparer _memberComparer = new();

public bool Equals(
((MethodDeclarationSyntax Method, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) x,
((MethodDeclarationSyntax Method, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) y)
{
var (xLeft, xCompilation) = x;
var (yLeft, yCompilation) = y;

if (ReferenceEquals(xLeft.Method, yLeft.Method) &&
ReferenceEquals(xCompilation, yCompilation) &&
xLeft.GlobalOptions == yLeft.GlobalOptions)
{
return true;
}

if (!ReferenceEquals(xLeft.Method.SyntaxTree, yLeft.Method.SyntaxTree))
{
return false;
}

if (xLeft.Attribute != yLeft.Attribute)
{
return false;
}

if (xLeft.GlobalOptions != yLeft.GlobalOptions)
{
return false;
}

if (!_memberComparer.Equals(xLeft.Method, yLeft.Method))
{
return false;
}

return xCompilation.ExternalReferences.SequenceEqual(yCompilation.ExternalReferences);
}

public int GetHashCode(((MethodDeclarationSyntax Method, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) obj)
{
var (left, compilation) = obj;
unchecked
{
var hash = 17;
hash = hash * 31 + _memberComparer.GetHashCode(left.Method);
hash = hash * 31 + RuntimeHelpers.GetHashCode(left.Method.SyntaxTree);
hash = hash * 31 + left.Attribute.GetHashCode();
hash = hash * 31 + left.GlobalOptions.GetHashCode();

var references = compilation.ExternalReferences;
var referencesHash = 17;
referencesHash = referencesHash * 31 + references.Length;
foreach (var reference in references)
{
referencesHash = referencesHash * 31 + RuntimeHelpers.GetHashCode(reference);
}
hash = hash * 31 + referencesHash;

return hash;
}
}
}
Loading
Loading