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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ CI targets both .NET 8.0 and .NET 10.0 SDKs.
- `ExpressiveGenerator` — finds `[Expressive]` members, validates them via `ExpressiveInterpreter`, emits expression trees via `ExpressionTreeEmitter`, and builds a runtime registry via `ExpressionRegistryEmitter`
- `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)

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

### Key Source Files

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

### Diagnostics

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

## Testing

Expand Down
56 changes: 25 additions & 31 deletions docs/advanced/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,63 +113,57 @@ 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 [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:
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:

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

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

public class Dog : Animal
{
[Expressive] // EXP0024
[Expressive]
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
// Expands to: a is Dog ? ("Dog: " + ((Dog)a).Name) : ("Animal: " + a.Name)
db.Animals.AsExpressive().Select(a => a.Describe());
```

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

### Recommended: test the runtime type explicitly
### Mark every override `[Expressive]`

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
});
```
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.

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

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

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

db.Animals.AsExpressive().Select(a => a.Describe());
// Standalone
var options = new ExpressiveOptions();
options.DisablePolymorphicDispatch();
expression.ExpandExpressives(options);
```

Per-override `[NotExpressive]` is independent of this switch.

### Caveats

- **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.
- **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.
- **Interfaces are not in scope.** Default interface members keep static interface-implementation resolution; only class virtual members dispatch polymorphically.

::: 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 -- EXP0024 only concerns members you *also* mark `[Expressive]`.
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.
:::

## Performance: First-Execution Overhead
Expand Down
81 changes: 41 additions & 40 deletions docs/reference/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,15 @@ See [Troubleshooting](./troubleshooting) for symptom-oriented guidance -- find t
| [EXP0021](#exp0021) | Error | `[ExpressiveProperty]` requires an instance stub | -- |
| [EXP0022](#exp0022) | Error | `[ExpressiveProperty]` target shadows inherited member | -- |
| [EXP0023](#exp0023) | Warning | Unsupported operation ignored | -- |
| [EXP0024](#exp0024) | Warning | `[Expressive]` member is virtual and will not dispatch polymorphically | -- |
| EXP0024 | _(retired)_ | `[Expressive]` member is virtual and will not dispatch polymorphically — virtual members now dispatch polymorphically at runtime | -- |
| [EXP0025](#exp0025) | Warning | Referenced member could benefit from `[Expressive]` | [Add `[Expressive]`](#exp0025-fix) |
| [EXP0026](#exp0026) | Warning | `IExpressiveQueryable<T>` LINQ method resolves to `Queryable` | [Add `using ExpressiveSharp;`](#exp0026-fix) |
| [EXP0027](#exp0027) | Info | No `IExpressiveQueryable<T>` overload for `Queryable` method | -- |
| [EXP0028](#exp0028) | Info | Plain `IQueryable` chain references an `[Expressive]` member without `.AsExpressive()` | [Wrap with `.AsExpressive()`](#exp0028-fix) |
| [EXP0029](#exp0029) | Info | `IExpressiveQueryable<T>` chain dropped to plain `IQueryable<T>` | -- |
| [EXP0030](#exp0030) | Warning | `WindowFunction.Ntile` requires a positive bucket count | -- |
| [EXP0031](#exp0031) | Warning | `WindowFunction.Lag`/`Lead` offset must be non-negative | -- |
| [EXP0032](#exp0032) | Warning | Override of an `[Expressive]` member is missing `[Expressive]` | [Add `[Expressive]`](#exp0025-fix) |
| [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 @@ -599,48 +600,13 @@ surrounding expression emitted without it.

---

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

**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}";
**Status:** Retired. This warning no longer exists.

// 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}",
};
```
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).

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

---

Expand Down Expand Up @@ -804,6 +770,41 @@ db.Orders.AsExpressiveDbSet()

---

### EXP0032 -- Override of an `[Expressive]` member is missing `[Expressive]` {#exp0032}

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

**Message:**
```
'{0}' overrides an [Expressive] member but is not itself marked [Expressive]. In expression-tree
expansion (e.g. EF Core, MongoDB) instances of this type fall back to the base body instead of
this override. Add [Expressive] so it participates in polymorphic dispatch, or [NotExpressive] to
silence this.
```

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

**Fix:** Add `[Expressive]` to the override (the [Add `[Expressive]`](#exp0025-fix) code fix does this), so it participates in dispatch:

```csharp
class Animal
{
[Expressive] public virtual string Description => "Animal: " + Name;
}

class Dog : Animal
{
public override string Description => "Dog: " + Name; // ⚠ EXP0032
// Fix:
[Expressive] public override string Description => "Dog: " + Name;
}
```

If the override intentionally should not be translated (it stays client-only), mark it `[NotExpressive]` to silence the diagnostic.

---

## Window Function Diagnostics (EXP0030--EXP0031)

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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@
namespace ExpressiveSharp.CodeFixers;

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

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

Expand Down
Loading
Loading