Skip to content
Merged
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
19 changes: 19 additions & 0 deletions .devcontainer/devcontainer-lock.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"version": "2.17.0",
"resolved": "ghcr.io/devcontainers/features/docker-in-docker@sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c",
"integrity": "sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c"
},
"ghcr.io/devcontainers/features/dotnet:2": {
"version": "2.5.0",
"resolved": "ghcr.io/devcontainers/features/dotnet@sha256:0fc16547ed4db6d7ff2a9f5981d2b93eb314e568affb9958029ad794f1f9a093",
"integrity": "sha256:0fc16547ed4db6d7ff2a9f5981d2b93eb314e568affb9958029ad794f1f9a093"
},
"ghcr.io/devcontainers/features/node:1": {
"version": "1.7.1",
"resolved": "ghcr.io/devcontainers/features/node@sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6",
"integrity": "sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6"
}
}
}
61 changes: 61 additions & 0 deletions docs/advanced/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,67 @@ static bool IsNullOrWhiteSpace(string? s)
```
:::

## Virtual and Polymorphic Members {#virtual-polymorphic-members}

Expression-tree expansion happens at **compile time** and works purely from the **static (declared) type** of each receiver. It has no runtime instance to inspect, so it cannot honor C# virtual dispatch.

If you mark a `virtual`, `abstract`, or `override` member `[Expressive]` (a default interface member counts too -- it is implicitly virtual), the generator reports [EXP0038](../reference/diagnostics#exp0038). When the member is expanded for a query provider (EF Core, MongoDB), the call is resolved against the declared type and the **base** body is always inlined -- an overridden body in a derived type is never used:

```csharp
public class Animal
{
public string Name { get; set; } = "";

[Expressive] // EXP0038
public virtual string Describe() => $"Animal: {Name}";
}

public class Dog : Animal
{
[Expressive] // EXP0038
public override string Describe() => $"Dog: {Name}";
}

// The static type is Animal, so expansion inlines the BASE body -- even for Dog rows:
db.Animals.AsExpressive().Select(a => a.Describe()); // => "Animal: {Name}" in SQL
```

This is by design: a query provider translates the expression to SQL/MQL and never materializes a CLR object, so there is no runtime type to dispatch on. (Contrast this with compiling the expression to a delegate and invoking it in memory, where the CLR *does* dispatch on the runtime type.)

### Recommended: test the runtime type explicitly

Branch on the concrete type so each arm has a statically-typed receiver. Every branch then expands to the correct body and the provider emits a `CASE`:

```csharp
db.Animals.AsExpressive().Select(a => a switch
{
Dog d => d.Describe(), // expands Dog.Describe
_ => a.Describe(), // expands Animal.Describe
});
```

### Recommended: use a non-virtual static/extension method

Move the logic into a single non-virtual `[Expressive]` method that performs the type test itself. This keeps the polymorphic shape in one place and produces no EXP0038:

```csharp
public static class AnimalExpressions
{
[Expressive]
public static string Describe(this Animal a) => a switch
{
Dog d => $"Dog: {d.Name}",
_ => $"Animal: {a.Name}",
};
}

db.Animals.AsExpressive().Select(a => a.Describe());
```

::: tip
Declaring entity members `virtual` is common in EF Core because it enables lazy-loading proxies. That remains fine for plain navigation and scalar properties -- EXP0038 only concerns members you *also* mark `[Expressive]`.
:::

## Performance: First-Execution Overhead

`ExpandExpressives()` walks the expression tree and substitutes `[Expressive]` member references on every query execution. This adds a small cost to the first execution of each unique query shape. EF Core caches the compiled query afterward, so subsequent executions of the same shape skip the expansion entirely.
Expand Down
48 changes: 48 additions & 0 deletions docs/reference/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ See [Troubleshooting](./troubleshooting) for symptom-oriented guidance -- find t
| [EXP0034](#exp0034) | Error | `[ExpressiveProperty]` requires an instance stub | -- |
| [EXP0035](#exp0035) | Error | `[ExpressiveProperty]` target shadows inherited member | -- |
| [EXP0036](#exp0036) | Info | `IExpressiveQueryable<T>` chain dropped to plain `IQueryable<T>` | -- |
| [EXP0038](#exp0038) | Warning | `[Expressive]` member is virtual and will not dispatch polymorphically | -- |
| [EXP1001](#exp1001) | Warning | Replace `[Projectable]` with `[Expressive]` | [Replace attribute](#exp1001-fix) |
| [EXP1002](#exp1002) | Warning | Replace `UseProjectables()` with `UseExpressives()` | [Replace method call](#exp1002-fix) |
| [EXP1003](#exp1003) | Warning | Replace Projectables namespace | [Replace namespace](#exp1003-fix) |
Expand Down Expand Up @@ -697,6 +698,53 @@ db.Orders.AsExpressiveDbSet()

---

## Virtual Member Diagnostic (EXP0038)

### EXP0038 -- Virtual member will not dispatch polymorphically {#exp0038}

**Severity:** Warning
**Category:** Design

**Message:**
```
[Expressive] member '{0}' is virtual, abstract, or an override. When it is expanded into an
expression tree (e.g. for EF Core or MongoDB), the call is resolved using the static (declared)
type, so an overridden body in a derived type is never used. Test the runtime type explicitly
(e.g. 'x switch { Derived d => d.Member, _ => x.Member }'), or move the logic into a non-virtual
[Expressive] static/extension method.
```

**Cause:** An `[Expressive]` member is declared `virtual`, `abstract`, or `override` (a default interface member counts -- it is implicitly virtual). Expression-tree expansion happens at compile time and only sees the **static (declared) type** of the receiver, so it cannot honor C# virtual dispatch. When the member is expanded for a query provider, the **base** body is always inlined; an overridden body in a derived type is never used.

This differs from compiling the expression to a delegate and invoking it in memory, where the CLR dispatches on the runtime type.

**Fix:** Branch on the runtime type so each arm has a statically-typed receiver, or move the logic into a single non-virtual `[Expressive]` static/extension method. See [Limitations: virtual and polymorphic members](../advanced/limitations#virtual-polymorphic-members) for full examples.

```csharp
// Warning: virtual [Expressive] member
[Expressive]
public virtual string Describe() => $"Animal: {Name}";

// Fix 1: test the runtime type explicitly
db.Animals.AsExpressive().Select(a => a switch
{
Dog d => d.Describe(),
_ => a.Describe(),
});

// Fix 2: non-virtual [Expressive] extension method that does the type test once
[Expressive]
public static string Describe(this Animal a) => a switch
{
Dog d => $"Dog: {d.Name}",
_ => $"Animal: {a.Name}",
};
```

If a virtual member is intentional (you only ever compile it to an in-memory delegate, never translate it through a provider), suppress the warning with `#pragma warning disable EXP0038` or `<NoWarn>$(NoWarn);EXP0038</NoWarn>`.

---

## Migration Diagnostics (EXP1001--EXP1003)

These diagnostics are emitted by the `MigrationAnalyzer` in the `ExpressiveSharp.EntityFrameworkCore.CodeFixers` package. They detect usage of the legacy `EntityFrameworkCore.Projectables` library and offer automated code fixes to migrate to ExpressiveSharp.
Expand Down
11 changes: 11 additions & 0 deletions src/ExpressiveSharp.Generator/ExpressiveGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,17 @@ private static void Execute(
factoryCandidate.Identifier.Text));
}

// EXP0038: virtual/abstract/override members are expanded using the static (declared)
// type. Once the body is inlined into an expression tree (EF Core, MongoDB, ...), C#
// virtual dispatch is lost, so an overridden body in a derived type is never used.
if (memberSymbol.IsVirtual || memberSymbol.IsAbstract || memberSymbol.IsOverride)
{
context.ReportDiagnostic(Diagnostic.Create(
Infrastructure.Diagnostics.VirtualMemberDispatchedStatically,
memberSymbol.Locations.Length > 0 ? memberSymbol.Locations[0] : Location.None,
memberSymbol.Name));
}

var generatedClassName = ExpressionClassNameGenerator.GenerateClassName(expressive.ClassNamespace, expressive.NestedInClassNames);
var methodSuffix = ExpressionClassNameGenerator.GenerateMethodSuffix(expressive.MemberName, expressive.ParameterTypeNames);
var generatedFileName = expressive.ClassTypeParameterList is not null
Expand Down
11 changes: 11 additions & 0 deletions src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -198,4 +198,15 @@ static internal class Diagnostics
category: "Design",
DiagnosticSeverity.Error,
isEnabledByDefault: true);

// EXP0036/EXP0037 live in WindowFunctionLiteralArgsAnalyzer (EntityFrameworkCore.CodeFixers).

public readonly static DiagnosticDescriptor VirtualMemberDispatchedStatically = new DiagnosticDescriptor(
id: "EXP0038",
title: "[Expressive] member is virtual and will not dispatch polymorphically",
messageFormat: "[Expressive] member '{0}' is virtual, abstract, or an override. When it is expanded into an expression tree (e.g. for EF Core or MongoDB), the call is resolved using the static (declared) type, so an overridden body in a derived type is never used. Test the runtime type explicitly (e.g. 'x switch {{ Derived d => d.Member, _ => x.Member }}'), or move the logic into a non-virtual [Expressive] static/extension method.",
category: "Design",
DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "Expression-tree expansion happens at compile time and only sees the static (declared) type of the receiver, so it cannot honor C# virtual dispatch. Declaring an [Expressive] member virtual/abstract/override therefore silently expands the base body for query providers. This differs from compiling the expression to a delegate and invoking it in memory, where the CLR dispatches on the runtime type.");
}
16 changes: 5 additions & 11 deletions src/ExpressiveSharp/Services/ExpressiveReplacer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,6 @@ protected bool TryGetReflectedExpression(MemberInfo memberInfo, [NotNullWhen(tru
[return: NotNullIfNotNull(nameof(node))]
public virtual Expression? Replace(Expression? node) => Visit(node);

private static bool IsPolymorphicallyDispatched(MethodInfo? method)
=> method is not null && method.IsVirtual && !method.IsFinal;

protected override Expression VisitMethodCall(MethodCallExpression node)
{
// Replace MethodGroup arguments with their reflected expressions.
Expand All @@ -78,10 +75,11 @@ protected override Expression VisitMethodCall(MethodCallExpression node)

VisitMethodCallCore(node);

var methodInfo = node.Object?.Type.GetConcreteMethod(node.Method) ?? node.Method;
var methodInfo = node.Method.DeclaringType?.IsInterface == true
? (node.Object?.Type.GetConcreteMethod(node.Method) ?? node.Method)
: node.Method;

if (!IsPolymorphicallyDispatched(methodInfo) &&
!_expandingMembers.Contains(methodInfo) &&
if (!_expandingMembers.Contains(methodInfo) &&
TryGetReflectedExpression(methodInfo, out var reflectedExpression))
{
_expandingMembers.Add(methodInfo);
Expand Down Expand Up @@ -162,15 +160,11 @@ when type.IsAssignableFrom(operand.Type)
_ => node.Expression
};
var nodeMember = node.Member switch {
PropertyInfo property when nodeExpression is not null
PropertyInfo property when nodeExpression is not null && property.DeclaringType?.IsInterface == true
=> nodeExpression.Type.GetConcreteProperty(property),
_ => node.Member
};

var virtualAccessor = nodeMember is PropertyInfo prop ? prop.GetMethod : null;
if (IsPolymorphicallyDispatched(virtualAccessor))
return base.VisitMember(node);

if (!_expandingMembers.Contains(nodeMember) &&
TryGetReflectedExpression(nodeMember, out var reflectedExpression))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,4 +190,23 @@ public async Task ExpressiveFor_Inside_ExpressiveMember_ComposesCorrectly()
// Totals: 240, 1500, 30, 250 → clamped to [0, 200]: 200, 200, 30, 200
CollectionAssert.AreEqual(new[] { 200.0, 200.0, 30.0, 200.0 }, results);
}

[TestMethod]
public async Task VirtualExpressiveMember_FiltersInDatabase()
{
// Regression for the reverted "bad commit": a virtual [Expressive] member must expand
// (using its static/declared body) and translate to SQL. The polymorphism gate used to
// skip expansion, so LineItem.IsExpensive reached the provider untranslated and threw.
// Compare against the database's own non-expressive predicate so the assertion is
// independent of the seed details.
Expression<Func<LineItem, bool>> expr = li => li.IsExpensive;
var expanded = (Expression<Func<LineItem, bool>>)expr.ExpandExpressives();

var expected = await Context.Set<LineItem>().Where(li => li.UnitPrice > 40).CountAsync();
Assert.IsTrue(expected > 0, "Seed should contain at least one expensive line item.");

var actual = await Context.Set<LineItem>().Where(expanded).CountAsync();

Assert.AreEqual(expected, actual);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,74 @@ static class Mappings {
"Expected EXP0014 for unresolvable target type in [ExpressiveFor]");
}

[TestMethod]
public void VirtualMethod_ReportsEXP0038()
{
var compilation = CreateCompilation(
"""
namespace Foo {
class Animal {
public string Name { get; set; }

[Expressive]
public virtual string Describe() => "Animal: " + Name;
}
}
""");
var result = RunExpressiveGenerator(compilation);

var diag = result.Diagnostics.FirstOrDefault(d => d.Id == "EXP0038");
Assert.IsNotNull(diag, "Expected EXP0038 for a virtual [Expressive] member");
Assert.AreEqual(DiagnosticSeverity.Warning, diag.Severity);
Assert.IsTrue(result.GeneratedTrees.Length > 0,
"Generator should still produce output alongside the EXP0038 warning");
}

[TestMethod]
public void VirtualAndOverrideProperties_BothReportEXP0038()
{
var compilation = CreateCompilation(
"""
namespace Foo {
class Animal {
public string Name { get; set; }

[Expressive]
public virtual string Label => Name;
}

class Dog : Animal {
[Expressive]
public override string Label => "Dog: " + Name;
}
}
""");
var result = RunExpressiveGenerator(compilation);

Assert.AreEqual(2, result.Diagnostics.Count(d => d.Id == "EXP0038"),
"Expected EXP0038 for both the virtual base property and its override");
}

[TestMethod]
public void NonVirtualMember_DoesNotReportEXP0038()
{
var compilation = CreateCompilation(
"""
namespace Foo {
class Animal {
public string Name { get; set; }

[Expressive]
public string Describe() => "Animal: " + Name;
}
}
""");
var result = RunExpressiveGenerator(compilation);

Assert.IsFalse(result.Diagnostics.Any(d => d.Id == "EXP0038"),
"A non-virtual [Expressive] member must not report EXP0038");
}

// NOTE: EXP0010 (InterceptorEmissionFailed) is intentionally not tested.
// It is a catch-all for unhandled exceptions during interceptor generation
// in PolyfillInterceptorGenerator. No natural user input triggers it — it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ public interface IDefaultBase : IBase

var result = RunExpressiveGenerator(compilation);

Assert.AreEqual(0, result.Diagnostics.Length);
// A default interface member is implicitly virtual, so EXP0038 fires: when expanded into
// an expression tree the call resolves against the static (interface) type, not a runtime
// override. The generator still emits the expression for the declared body.
Assert.AreEqual(1, result.Diagnostics.Length);
Assert.AreEqual("EXP0038", result.Diagnostics[0].Id);
Assert.AreEqual(1, result.GeneratedTrees.Length);

return Verifier.Verify(result.GeneratedTrees[0].ToString());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,13 @@ public class LineItem
public string ProductName { get; set; } = "";
public double UnitPrice { get; set; }
public int Quantity { get; set; }

// Virtual [Expressive] member — regression coverage that static-type expansion still reaches
// the query provider as translatable SQL. The reverted "bad commit" gate skipped expansion for
// virtual members, so this would hit EF Core untranslated and throw. EXP0038 is expected here
// by design and suppressed.
#pragma warning disable EXP0038
[Expressive]
public virtual bool IsExpensive => UnitPrice > 40;
#pragma warning restore EXP0038
}
Loading
Loading