Skip to content

Commit c8ddf4e

Browse files
committed
polymorphically dispatch support, wave 1
1 parent f6ec03e commit c8ddf4e

27 files changed

Lines changed: 1081 additions & 133 deletions

File tree

‎CLAUDE.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ CI targets both .NET 8.0 and .NET 10.0 SDKs.
4545
- `ExpressiveGenerator` — finds `[Expressive]` members, validates them via `ExpressiveInterpreter`, emits expression trees via `ExpressionTreeEmitter`, and builds a runtime registry via `ExpressionRegistryEmitter`
4646
- `PolyfillInterceptorGenerator` — uses C# 13 `[InterceptsLocation]` to rewrite `ExpressionPolyfill.Create()` and `IExpressiveQueryable<T>` LINQ call sites from delegate form to expression tree form. Supports all standard `Queryable` methods, multi-lambda methods (Join, GroupJoin, GroupBy overloads), non-lambda-first methods (Zip, ExceptBy, etc.), and custom target types via `[PolyfillTarget]` (e.g., EF Core's `EntityFrameworkQueryableExtensions` for async methods)
4747

48-
2. **Runtime:** `ExpressiveResolver` looks up generated expressions by (DeclaringType, MemberName, ParameterTypes). `ExpressiveReplacer` is an `ExpressionVisitor` that substitutes `[Expressive]` member accesses with the generated expression trees. Transformers (in `Transformers/`) post-process trees for provider compatibility.
48+
2. **Runtime:** `ExpressiveResolver` looks up generated expressions by (DeclaringType, MemberName, ParameterTypes). `ExpressiveReplacer` is an `ExpressionVisitor` that substitutes `[Expressive]` member accesses with the generated expression trees. For `virtual`/`override` `[Expressive]` members it dispatches **polymorphically**: it discovers derived `[Expressive]` overrides across loaded assemblies (cached, refreshed on assembly load) and emits a runtime type-test chain `instance is Derived ? <derived body> : <base body>` (EF Core translates this to a TPH discriminator `CASE`). Opt out globally via `ExpressiveOptions.DisablePolymorphicDispatch()` (EF: `UseExpressives(o => o.DisablePolymorphicDispatch())`); per-override `[NotExpressive]` opts a single type out. Transformers (in `Transformers/`) post-process trees for provider compatibility.
4949

5050
### Key Source Files
5151

@@ -119,7 +119,7 @@ ExpressiveSharp.EntityFrameworkCore.CodeFixers (Roslyn analyzer, netstandard2.0)
119119

120120
### Diagnostics
121121

122-
34 diagnostic codes: a contiguous `EXP0001–EXP0031` plus the migration band `EXP1001–EXP1003`. Generator diagnostics live in `src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs` (`EXP0001–EXP0012` core `[Expressive]`, `EXP0013–EXP0017` `[ExpressiveFor]`, `EXP0018–EXP0022` `[ExpressiveProperty]`, `EXP0023` ignored operation, `EXP0024` virtual member); analyzer diagnostics `EXP0025–EXP0029` in `ExpressiveSharp.CodeFixers`; window-function diagnostics `EXP0030`/`EXP0031` in `WindowFunctionLiteralArgsAnalyzer` (`EntityFrameworkCore.CodeFixers`); migration `EXP1001–EXP1003` in `MigrationAnalyzer`. The canonical reference is `docs/reference/diagnostics.md`. Key ones: EXP0001 (requires body), EXP0004 (block body requires opt-in), EXP0008 (unsupported operation, default value used), EXP0023 (unsupported operation ignored, e.g. alignment specifiers), EXP0016 (`[ExpressiveFor]` conflicts with `[Expressive]`), EXP0030 (`Ntile` non-positive literal), EXP0031 (`Lag`/`Lead` negative literal offset).
122+
Diagnostic codes span `EXP0001–EXP0032` (with `EXP0024` **retired** — see below — so the ID is reserved but unused) plus the migration band `EXP1001–EXP1003`. Generator diagnostics live in `src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs` (`EXP0001–EXP0012` core `[Expressive]`, `EXP0013–EXP0017` `[ExpressiveFor]`, `EXP0018–EXP0022` `[ExpressiveProperty]`, `EXP0023` ignored operation); analyzer diagnostics `EXP0025–EXP0029` and `EXP0032` in `ExpressiveSharp.CodeFixers`; window-function diagnostics `EXP0030`/`EXP0031` in `WindowFunctionLiteralArgsAnalyzer` (`EntityFrameworkCore.CodeFixers`); migration `EXP1001–EXP1003` in `MigrationAnalyzer`. The canonical reference is `docs/reference/diagnostics.md`. Key ones: EXP0001 (requires body), EXP0004 (block body requires opt-in), EXP0008 (unsupported operation, default value used), EXP0023 (unsupported operation ignored, e.g. alignment specifiers), EXP0016 (`[ExpressiveFor]` conflicts with `[Expressive]`), EXP0030 (`Ntile` non-positive literal), EXP0031 (`Lag`/`Lead` negative literal offset), EXP0032 (override of an `[Expressive]` member missing `[Expressive]`). **EXP0024 (retired):** virtual/`abstract`/`override` `[Expressive]` members used to warn that they would not dispatch polymorphically; they now **do** dispatch polymorphically at runtime (`ExpressiveReplacer` emits an `is Derived ? … : base` chain, translated to a TPH discriminator `CASE`), so the warning was removed and EXP0032 took over the derived-side guidance.
123123

124124
## Testing
125125

‎docs/advanced/limitations.md‎

Lines changed: 25 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -113,63 +113,57 @@ static bool IsNullOrWhiteSpace(string? s)
113113

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

116-
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.
117-
118-
If you mark a `virtual`, `abstract`, or `override` member `[Expressive]` (a default interface member counts too -- it is implicitly virtual), the generator reports [EXP0024](../reference/diagnostics#exp0024). 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:
116+
Virtual, `abstract`, and `override` `[Expressive]` members **dispatch polymorphically at runtime**. When such a member is expanded for a query provider, `ExpressiveReplacer` discovers the derived `[Expressive]` overrides across the loaded assemblies and emits a runtime type-test chain, so each row uses its own runtime type's body:
119117

120118
```csharp
121119
public class Animal
122120
{
123121
public string Name { get; set; } = "";
124122

125-
[Expressive] // EXP0024
123+
[Expressive]
126124
public virtual string Describe() => $"Animal: {Name}";
127125
}
128126

129127
public class Dog : Animal
130128
{
131-
[Expressive] // EXP0024
129+
[Expressive]
132130
public override string Describe() => $"Dog: {Name}";
133131
}
134132

135-
// The static type is Animal, so expansion inlines the BASE body -- even for Dog rows:
136-
db.Animals.AsExpressive().Select(a => a.Describe()); // => "Animal: {Name}" in SQL
133+
// Expands to: a is Dog ? ("Dog: " + ((Dog)a).Name) : ("Animal: " + a.Name)
134+
db.Animals.AsExpressive().Select(a => a.Describe());
137135
```
138136

139-
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.)
137+
EF Core translates `a is Dog` to a table-per-hierarchy discriminator check, so the query emits a `CASE` over the discriminator column and returns the right text per row. In-memory delegates (`.Compile()`) evaluate the same `is` test against the CLR runtime type, so behavior converges with provider translation.
140138

141-
### Recommended: test the runtime type explicitly
139+
### Mark every override `[Expressive]`
142140

143-
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`:
144-
145-
```csharp
146-
db.Animals.AsExpressive().Select(a => a switch
147-
{
148-
Dog d => d.Describe(), // expands Dog.Describe
149-
_ => a.Describe(), // expands Animal.Describe
150-
});
151-
```
141+
Only overrides that are themselves `[Expressive]` participate. An override that forgets the attribute is invisible to expansion — instances of that type silently fall back to the **base** body — so the analyzer reports [EXP0032](../reference/diagnostics#exp0032) with an "Add `[Expressive]`" fix. If an override is intentionally client-only, mark it `[NotExpressive]` to opt out.
152142

153-
### Recommended: use a non-virtual static/extension method
143+
### Opting out
154144

155-
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 EXP0024:
145+
Polymorphic dispatch is on by default. To turn it off for an entire context (e.g. a provider that cannot translate type tests), call `DisablePolymorphicDispatch()` — virtual members then expand using the static (declared) type only:
156146

157147
```csharp
158-
public static class AnimalExpressions
159-
{
160-
[Expressive]
161-
public static string Describe(this Animal a) => a switch
162-
{
163-
Dog d => $"Dog: {d.Name}",
164-
_ => $"Animal: {a.Name}",
165-
};
166-
}
148+
// EF Core
149+
optionsBuilder.UseExpressives(o => o.DisablePolymorphicDispatch());
167150

168-
db.Animals.AsExpressive().Select(a => a.Describe());
151+
// Standalone
152+
var options = new ExpressiveOptions();
153+
options.DisablePolymorphicDispatch();
154+
expression.ExpandExpressives(options);
169155
```
170156

157+
Per-override `[NotExpressive]` is independent of this switch.
158+
159+
### Caveats
160+
161+
- **Discovery is runtime and best-effort.** Overrides are found in the assemblies loaded when the query first runs (the plan is cached and refreshed when new assemblies load). For EF Core this is a non-issue: entity types are registered when the model is built, before any query. An override whose assembly loads later and is not referenced by the query falls back to the base body.
162+
- **Provider must translate type tests.** EF Core relational providers translate `is`/discriminator checks for TPH; some mappings (TPC) or providers may not. If a provider cannot translate the conditional, the query throws at translation time — the same failure mode as any untranslatable expression.
163+
- **Interfaces are not in scope.** Default interface members keep static interface-implementation resolution; only class virtual members dispatch polymorphically.
164+
171165
::: tip
172-
Declaring entity members `virtual` is common in EF Core because it enables lazy-loading proxies. That remains fine for plain navigation and scalar properties -- EXP0024 only concerns members you *also* mark `[Expressive]`.
166+
Declaring entity members `virtual` is common in EF Core because it enables lazy-loading proxies. That remains fine for plain navigation and scalar properties; for `[Expressive]` members it now additionally enables polymorphic dispatch.
173167
:::
174168

175169
## Performance: First-Execution Overhead

‎docs/reference/diagnostics.md‎

Lines changed: 41 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,15 @@ See [Troubleshooting](./troubleshooting) for symptom-oriented guidance -- find t
3333
| [EXP0021](#exp0021) | Error | `[ExpressiveProperty]` requires an instance stub | -- |
3434
| [EXP0022](#exp0022) | Error | `[ExpressiveProperty]` target shadows inherited member | -- |
3535
| [EXP0023](#exp0023) | Warning | Unsupported operation ignored | -- |
36-
| [EXP0024](#exp0024) | Warning | `[Expressive]` member is virtual and will not dispatch polymorphically | -- |
36+
| EXP0024 | _(retired)_ | `[Expressive]` member is virtual and will not dispatch polymorphically — virtual members now dispatch polymorphically at runtime | -- |
3737
| [EXP0025](#exp0025) | Warning | Referenced member could benefit from `[Expressive]` | [Add `[Expressive]`](#exp0025-fix) |
3838
| [EXP0026](#exp0026) | Warning | `IExpressiveQueryable<T>` LINQ method resolves to `Queryable` | [Add `using ExpressiveSharp;`](#exp0026-fix) |
3939
| [EXP0027](#exp0027) | Info | No `IExpressiveQueryable<T>` overload for `Queryable` method | -- |
4040
| [EXP0028](#exp0028) | Info | Plain `IQueryable` chain references an `[Expressive]` member without `.AsExpressive()` | [Wrap with `.AsExpressive()`](#exp0028-fix) |
4141
| [EXP0029](#exp0029) | Info | `IExpressiveQueryable<T>` chain dropped to plain `IQueryable<T>` | -- |
4242
| [EXP0030](#exp0030) | Warning | `WindowFunction.Ntile` requires a positive bucket count | -- |
4343
| [EXP0031](#exp0031) | Warning | `WindowFunction.Lag`/`Lead` offset must be non-negative | -- |
44+
| [EXP0032](#exp0032) | Warning | Override of an `[Expressive]` member is missing `[Expressive]` | [Add `[Expressive]`](#exp0025-fix) |
4445
| [EXP1001](#exp1001) | Warning | Replace `[Projectable]` with `[Expressive]` | [Replace attribute](#exp1001-fix) |
4546
| [EXP1002](#exp1002) | Warning | Replace `UseProjectables()` with `UseExpressives()` | [Replace method call](#exp1002-fix) |
4647
| [EXP1003](#exp1003) | Warning | Replace Projectables namespace | [Replace namespace](#exp1003-fix) |
@@ -599,48 +600,13 @@ surrounding expression emitted without it.
599600

600601
---
601602

602-
### EXP0024 -- Virtual member will not dispatch polymorphically {#exp0024}
603+
### EXP0024 -- Virtual member will not dispatch polymorphically _(retired)_ {#exp0024}
603604

604-
**Severity:** Warning
605-
**Category:** Design
606-
607-
**Message:**
608-
```
609-
[Expressive] member '{0}' is virtual, abstract, or an override. When it is expanded into an
610-
expression tree (e.g. for EF Core or MongoDB), the call is resolved using the static (declared)
611-
type, so an overridden body in a derived type is never used. Test the runtime type explicitly
612-
(e.g. 'x switch { Derived d => d.Member, _ => x.Member }'), or move the logic into a non-virtual
613-
[Expressive] static/extension method.
614-
```
615-
616-
**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.
617-
618-
This differs from compiling the expression to a delegate and invoking it in memory, where the CLR dispatches on the runtime type.
619-
620-
**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.
621-
622-
```csharp
623-
// Warning: virtual [Expressive] member
624-
[Expressive]
625-
public virtual string Describe() => $"Animal: {Name}";
605+
**Status:** Retired. This warning no longer exists.
626606

627-
// Fix 1: test the runtime type explicitly
628-
db.Animals.AsExpressive().Select(a => a switch
629-
{
630-
Dog d => d.Describe(),
631-
_ => a.Describe(),
632-
});
633-
634-
// Fix 2: non-virtual [Expressive] extension method that does the type test once
635-
[Expressive]
636-
public static string Describe(this Animal a) => a switch
637-
{
638-
Dog d => $"Dog: {d.Name}",
639-
_ => $"Animal: {a.Name}",
640-
};
641-
```
607+
Virtual, `abstract`, and `override` `[Expressive]` members now **dispatch polymorphically at runtime**. When a virtual `[Expressive]` member is expanded for a query provider, `ExpressiveReplacer` discovers the derived `[Expressive]` overrides and emits a runtime type-test chain — `entity is Dog ? <Dog body> : <base body>` — which EF Core translates to a table-per-hierarchy discriminator `CASE`. Each row therefore uses its runtime type's body. See [Limitations: virtual and polymorphic members](../advanced/limitations#virtual-polymorphic-members).
642608

643-
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 EXP0024` or `<NoWarn>$(NoWarn);EXP0024</NoWarn>`.
609+
The ID `EXP0024` is reserved (not reused). Its derived-side successor is [EXP0032](#exp0032), which flags a derived override that forgets `[Expressive]` and would silently fall back to the base body.
644610

645611
---
646612

@@ -804,6 +770,41 @@ db.Orders.AsExpressiveDbSet()
804770

805771
---
806772

773+
### EXP0032 -- Override of an `[Expressive]` member is missing `[Expressive]` {#exp0032}
774+
775+
**Severity:** Warning
776+
**Category:** Design
777+
778+
**Message:**
779+
```
780+
'{0}' overrides an [Expressive] member but is not itself marked [Expressive]. In expression-tree
781+
expansion (e.g. EF Core, MongoDB) instances of this type fall back to the base body instead of
782+
this override. Add [Expressive] so it participates in polymorphic dispatch, or [NotExpressive] to
783+
silence this.
784+
```
785+
786+
**Cause:** A `virtual`/`abstract` `[Expressive]` member is overridden, but the override is **not** itself marked `[Expressive]`. Runtime polymorphic dispatch can only inline overrides that are registered as `[Expressive]`, so for instances of the overriding type the query silently falls back to the **base** body — almost always a bug. The check walks *up* the override chain (cheap and cross-assembly), unlike the derived-type discovery that expansion performs at runtime.
787+
788+
**Fix:** Add `[Expressive]` to the override (the [Add `[Expressive]`](#exp0025-fix) code fix does this), so it participates in dispatch:
789+
790+
```csharp
791+
class Animal
792+
{
793+
[Expressive] public virtual string Description => "Animal: " + Name;
794+
}
795+
796+
class Dog : Animal
797+
{
798+
public override string Description => "Dog: " + Name; // ⚠ EXP0032
799+
// Fix:
800+
[Expressive] public override string Description => "Dog: " + Name;
801+
}
802+
```
803+
804+
If the override intentionally should not be translated (it stays client-only), mark it `[NotExpressive]` to silence the diagnostic.
805+
806+
---
807+
807808
## Window Function Diagnostics (EXP0030--EXP0031)
808809

809810
These diagnostics are emitted by the `WindowFunctionLiteralArgsAnalyzer` in the `ExpressiveSharp.EntityFrameworkCore.CodeFixers` package (shipped with `ExpressiveSharp.EntityFrameworkCore`). They validate constant literal arguments to `WindowFunction.*` calls before they reach the database. Only compile-time constant arguments are checked; a variable count or offset is never flagged. See [Window Functions](../guide/window-functions) for the full feature reference.

‎src/ExpressiveSharp.CodeFixers/AddExpressiveCodeFixProvider.cs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,15 @@
1212
namespace ExpressiveSharp.CodeFixers;
1313

1414
/// <summary>
15-
/// Code fix for EXP0025: adds [Expressive] to the referenced member's declaration.
16-
/// The diagnostic's additional location (index 0) points to that declaration.
15+
/// Code fix for EXP0025 and EXP0032: adds [Expressive] to the referenced/overriding member's
16+
/// declaration. The diagnostic's additional location (index 0) points to that declaration.
1717
/// </summary>
1818
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AddExpressiveCodeFixProvider))]
1919
[Shared]
2020
public sealed class AddExpressiveCodeFixProvider : CodeFixProvider
2121
{
2222
public override ImmutableArray<string> FixableDiagnosticIds =>
23-
ImmutableArray.Create("EXP0025");
23+
ImmutableArray.Create("EXP0025", "EXP0032");
2424

2525
public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
2626

0 commit comments

Comments
 (0)