diff --git a/CLAUDE.md b/CLAUDE.md index a5200dc0..1919a7e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` 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 ? : ` (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 @@ -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 diff --git a/docs/advanced/limitations.md b/docs/advanced/limitations.md index 9de47e8f..9be275ff 100644 --- a/docs/advanced/limitations.md +++ b/docs/advanced/limitations.md @@ -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 diff --git a/docs/reference/diagnostics.md b/docs/reference/diagnostics.md index ae6db680..001c135e 100644 --- a/docs/reference/diagnostics.md +++ b/docs/reference/diagnostics.md @@ -33,7 +33,7 @@ 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` LINQ method resolves to `Queryable` | [Add `using ExpressiveSharp;`](#exp0026-fix) | | [EXP0027](#exp0027) | Info | No `IExpressiveQueryable` overload for `Queryable` method | -- | @@ -41,6 +41,7 @@ See [Troubleshooting](./troubleshooting) for symptom-oriented guidance -- find t | [EXP0029](#exp0029) | Info | `IExpressiveQueryable` chain dropped to plain `IQueryable` | -- | | [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) | @@ -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 ? : ` — 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);EXP0024`. +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. --- @@ -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. diff --git a/src/ExpressiveSharp.CodeFixers/AddExpressiveCodeFixProvider.cs b/src/ExpressiveSharp.CodeFixers/AddExpressiveCodeFixProvider.cs index 209bc983..e23ca77c 100644 --- a/src/ExpressiveSharp.CodeFixers/AddExpressiveCodeFixProvider.cs +++ b/src/ExpressiveSharp.CodeFixers/AddExpressiveCodeFixProvider.cs @@ -12,15 +12,15 @@ namespace ExpressiveSharp.CodeFixers; /// -/// 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. /// [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AddExpressiveCodeFixProvider))] [Shared] public sealed class AddExpressiveCodeFixProvider : CodeFixProvider { public override ImmutableArray FixableDiagnosticIds => - ImmutableArray.Create("EXP0025"); + ImmutableArray.Create("EXP0025", "EXP0032"); public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; diff --git a/src/ExpressiveSharp.CodeFixers/OverrideMissingExpressiveAnalyzer.cs b/src/ExpressiveSharp.CodeFixers/OverrideMissingExpressiveAnalyzer.cs new file mode 100644 index 00000000..cf09bcab --- /dev/null +++ b/src/ExpressiveSharp.CodeFixers/OverrideMissingExpressiveAnalyzer.cs @@ -0,0 +1,92 @@ +using System.Collections.Immutable; +using ExpressiveSharp.CodeFixers.Internal; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace ExpressiveSharp.CodeFixers; + +/// +/// Reports EXP0032 when a member overrides an [Expressive] member but is not itself +/// [Expressive], so instances of its type silently fall back to the base body in +/// expression-tree expansion. Walking up the override chain keeps this cheap and +/// cross-assembly (the derived-side successor to the retired EXP0024). +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class OverrideMissingExpressiveAnalyzer : DiagnosticAnalyzer +{ + public static readonly DiagnosticDescriptor OverrideShouldBeExpressive = new( + id: "EXP0032", + title: "Override of an [Expressive] member is missing [Expressive]", + messageFormat: "'{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.", + category: "Design", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(OverrideShouldBeExpressive); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.Method, SymbolKind.Property); + } + + private static void AnalyzeSymbol(SymbolAnalysisContext context) + { + var symbol = context.Symbol; + if (!symbol.IsOverride) + return; + + // Property accessors arrive as SymbolKind.Method too; the property is handled separately. + if (symbol is IMethodSymbol { MethodKind: not MethodKind.Ordinary }) + return; + + if (ExpressiveSymbolHelpers.HasExpressiveAttribute(symbol) || + ExpressiveSymbolHelpers.HasNotExpressiveAttribute(symbol)) + return; + + if (!OverriddenChainHasExpressive(symbol)) + return; + + if (symbol.DeclaringSyntaxReferences.Length == 0) + return; + + var declSyntax = symbol.DeclaringSyntaxReferences[0].GetSyntax(context.CancellationToken); + var location = GetIdentifierLocation(declSyntax) ?? declSyntax.GetLocation(); + + context.ReportDiagnostic(Diagnostic.Create( + OverrideShouldBeExpressive, + location, + additionalLocations: new[] { declSyntax.GetLocation() }, + properties: null, + symbol.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat))); + } + + private static bool OverriddenChainHasExpressive(ISymbol symbol) + { + for (var current = GetOverridden(symbol); current is not null; current = GetOverridden(current)) + { + if (ExpressiveSymbolHelpers.HasExpressiveAttribute(current)) + return true; + } + + return false; + } + + private static ISymbol? GetOverridden(ISymbol symbol) => symbol switch + { + IMethodSymbol m => m.OverriddenMethod, + IPropertySymbol p => p.OverriddenProperty, + _ => null + }; + + private static Location? GetIdentifierLocation(SyntaxNode declSyntax) => declSyntax switch + { + MethodDeclarationSyntax method => method.Identifier.GetLocation(), + PropertyDeclarationSyntax property => property.Identifier.GetLocation(), + _ => null + }; +} diff --git a/src/ExpressiveSharp.EntityFrameworkCore/ExpressiveOptionsBuilder.cs b/src/ExpressiveSharp.EntityFrameworkCore/ExpressiveOptionsBuilder.cs index 29fa601b..6c9507c2 100644 --- a/src/ExpressiveSharp.EntityFrameworkCore/ExpressiveOptionsBuilder.cs +++ b/src/ExpressiveSharp.EntityFrameworkCore/ExpressiveOptionsBuilder.cs @@ -10,12 +10,25 @@ public sealed class ExpressiveOptionsBuilder internal bool ShouldPreserveThrowExpressions { get; private set; } + internal bool ShouldDisablePolymorphicDispatch { get; private set; } + public ExpressiveOptionsBuilder AddPlugin(IExpressivePlugin plugin) { Plugins.Add(plugin); return this; } + /// + /// Disables runtime polymorphic dispatch of virtual/override [Expressive] members: + /// they expand using the static (declared) type only, never an `is Derived ? ...` chain. Use for + /// providers that cannot translate type tests. Per-member [NotExpressive] is independent. + /// + public ExpressiveOptionsBuilder DisablePolymorphicDispatch() + { + ShouldDisablePolymorphicDispatch = true; + return this; + } + /// /// Prevents the transformer from /// being applied — Expression.Throw nodes are preserved for the LINQ provider to translate. diff --git a/src/ExpressiveSharp.EntityFrameworkCore/Extensions/DbContextOptionsExtensions.cs b/src/ExpressiveSharp.EntityFrameworkCore/Extensions/DbContextOptionsExtensions.cs index a365e839..ac249a17 100644 --- a/src/ExpressiveSharp.EntityFrameworkCore/Extensions/DbContextOptionsExtensions.cs +++ b/src/ExpressiveSharp.EntityFrameworkCore/Extensions/DbContextOptionsExtensions.cs @@ -24,7 +24,8 @@ public static DbContextOptionsBuilder UseExpressives( var builder = new ExpressiveOptionsBuilder(); configure(builder); - var extension = new ExpressiveOptionsExtension(builder.Plugins, builder.ShouldPreserveThrowExpressions); + var extension = new ExpressiveOptionsExtension( + builder.Plugins, builder.ShouldPreserveThrowExpressions, builder.ShouldDisablePolymorphicDispatch); ((IDbContextOptionsBuilderInfrastructure)optionsBuilder).AddOrUpdateExtension(extension); diff --git a/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressiveOptionsExtension.cs b/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressiveOptionsExtension.cs index 8b245e05..1541123a 100644 --- a/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressiveOptionsExtension.cs +++ b/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressiveOptionsExtension.cs @@ -15,12 +15,17 @@ public class ExpressiveOptionsExtension : IDbContextOptionsExtension { private readonly IReadOnlyList _plugins; private readonly bool _preserveThrowExpressions; + private readonly bool _disablePolymorphicDispatch; private readonly int _pluginHash; - public ExpressiveOptionsExtension(IReadOnlyList plugins, bool preserveThrowExpressions = false) + public ExpressiveOptionsExtension( + IReadOnlyList plugins, + bool preserveThrowExpressions = false, + bool disablePolymorphicDispatch = false) { _plugins = plugins; _preserveThrowExpressions = preserveThrowExpressions; + _disablePolymorphicDispatch = disablePolymorphicDispatch; var hash = new HashCode(); foreach (var plugin in plugins) @@ -29,6 +34,7 @@ public ExpressiveOptionsExtension(IReadOnlyList plugins, bool hash.Add(plugin.GetHashCode()); } hash.Add(preserveThrowExpressions); + hash.Add(disablePolymorphicDispatch); _pluginHash = hash.ToHashCode(); Info = new ExtensionInfo(this); @@ -75,9 +81,12 @@ public void ApplyServices(IServiceCollection services) .ToArray(); var preserveThrow = _preserveThrowExpressions; + var disablePolymorphic = _disablePolymorphicDispatch; services.AddSingleton(sp => { var options = new ExpressiveOptions(); + if (disablePolymorphic) + options.DisablePolymorphicDispatch(); if (!preserveThrow) options.AddTransformers(new ReplaceThrowWithDefault()); options.AddTransformers( diff --git a/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs b/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs index 699edc71..8d431d42 100644 --- a/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs +++ b/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs @@ -281,16 +281,8 @@ private static void Execute( factoryCandidate.Identifier.Text)); } - // EXP0024: 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)); - } + // No diagnostic for virtual/abstract/override members: they now dispatch polymorphically at + // runtime (EXP0024 retired; EXP0032 in ExpressiveSharp.CodeFixers covers missing overrides). var generatedClassName = ExpressionClassNameGenerator.GenerateClassName(expressive.ClassNamespace, expressive.NestedInClassNames); var methodSuffix = ExpressionClassNameGenerator.GenerateMethodSuffix(expressive.MemberName, expressive.ParameterTypeNames); diff --git a/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs b/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs index 06c8128f..60a6b95c 100644 --- a/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs +++ b/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs @@ -191,14 +191,8 @@ static internal class Diagnostics DiagnosticSeverity.Warning, isEnabledByDefault: true); - public readonly static DiagnosticDescriptor VirtualMemberDispatchedStatically = new DiagnosticDescriptor( - id: "EXP0024", - 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."); + // EXP0024 retired (virtual members now dispatch polymorphically at runtime); ID reserved, not + // reused. Its successor is EXP0032 (OverrideMissingExpressiveAnalyzer in ExpressiveSharp.CodeFixers). // Diagnostics defined outside this file: // EXP0025 MemberCouldBeExpressive, EXP0026 StubNotResolved, EXP0027 NoStubExists, diff --git a/src/ExpressiveSharp/Extensions/ExpressionExtensions.cs b/src/ExpressiveSharp/Extensions/ExpressionExtensions.cs index 81a14ad2..59614cca 100644 --- a/src/ExpressiveSharp/Extensions/ExpressionExtensions.cs +++ b/src/ExpressiveSharp/Extensions/ExpressionExtensions.cs @@ -18,7 +18,7 @@ public static Expression ExpandExpressives(this Expression expression) /// Like but uses transformers from the given options. /// public static Expression ExpandExpressives(this Expression expression, ExpressiveOptions options) - => ExpandExpressivesCore(expression, options.GetTransformers()); + => ExpandExpressivesCore(expression, options.GetTransformers(), options.IsPolymorphicDispatchEnabled); /// /// Like but uses the explicitly supplied transformers. @@ -30,14 +30,15 @@ public static Expression ExpandExpressives( private static Expression ExpandExpressivesCore( Expression expression, - IReadOnlyList transformers) + IReadOnlyList transformers, + bool polymorphicDispatch = true) { using var activity = ExpressiveDiagnostics.ActivitySource.StartActivity("Expressive.Expand"); var measureDuration = activity is not null || ExpressiveDiagnostics.ExpansionDurationMs.Enabled; var startTimestamp = measureDuration ? Stopwatch.GetTimestamp() : 0L; - var expanded = new ExpressiveReplacer(new ExpressiveResolver()).Replace(expression); + var expanded = new ExpressiveReplacer(new ExpressiveResolver(), polymorphicDispatch).Replace(expression); for (var i = 0; i < transformers.Count; i++) { expanded = transformers[i].Transform(expanded); diff --git a/src/ExpressiveSharp/Services/ExpressiveOptions.cs b/src/ExpressiveSharp/Services/ExpressiveOptions.cs index 581ad9ab..a3a41d47 100644 --- a/src/ExpressiveSharp/Services/ExpressiveOptions.cs +++ b/src/ExpressiveSharp/Services/ExpressiveOptions.cs @@ -38,4 +38,30 @@ public IReadOnlyList GetTransformers() return _transformers.ToArray(); } } + + private bool _polymorphicDispatch = true; + + /// + /// Disables runtime polymorphic dispatch of virtual/override [Expressive] members; + /// they then expand using the static (declared) type only. Per-member [NotExpressive] is + /// independent of this. Default: enabled. + /// + public void DisablePolymorphicDispatch() + { + lock (_lock) + { + _polymorphicDispatch = false; + } + } + + public bool IsPolymorphicDispatchEnabled + { + get + { + lock (_lock) + { + return _polymorphicDispatch; + } + } + } } diff --git a/src/ExpressiveSharp/Services/ExpressiveReplacer.cs b/src/ExpressiveSharp/Services/ExpressiveReplacer.cs index dfd8529e..9b8b6fdb 100644 --- a/src/ExpressiveSharp/Services/ExpressiveReplacer.cs +++ b/src/ExpressiveSharp/Services/ExpressiveReplacer.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using System.Reflection; @@ -14,6 +15,7 @@ namespace ExpressiveSharp.Services; public class ExpressiveReplacer : ExpressionVisitor { private readonly IExpressiveResolver _resolver; + private readonly bool _polymorphicDispatch; private readonly ExpressionArgumentReplacer _expressionArgumentReplacer = new(); private readonly Dictionary _memberCache = new(); // Guards against infinite expansion when an [Expressive] member references itself @@ -24,11 +26,23 @@ public class ExpressiveReplacer : ExpressionVisitor private static readonly ConditionalWeakTable> _compilerGeneratedClosureCache = new(); - internal static void ClearCachesForMetadataUpdate() => _compilerGeneratedClosureCache.Clear(); + // Cached derived-override discovery for polymorphic dispatch (keyed by receiver type + base + // member). Discovery scans every loaded assembly; the cache is dropped when the assembly count + // changes (mirrors ExpressiveResolver) so runtime-compiled assemblies are picked up. + private static readonly ConcurrentDictionary<(Type RootType, MemberInfo BaseMember), PolymorphicPlan> _polymorphicPlanCache = new(); + private static int _polymorphicPlanAssemblyCount; - public ExpressiveReplacer(IExpressiveResolver resolver) + internal static void ClearCachesForMetadataUpdate() + { + _compilerGeneratedClosureCache.Clear(); + _polymorphicPlanCache.Clear(); + Volatile.Write(ref _polymorphicPlanAssemblyCount, 0); + } + + public ExpressiveReplacer(IExpressiveResolver resolver, bool polymorphicDispatch = true) { _resolver = resolver; + _polymorphicDispatch = polymorphicDispatch; } protected bool TryGetReflectedExpression(MemberInfo memberInfo, [NotNullWhen(true)] out LambdaExpression? reflectedExpression) @@ -75,6 +89,15 @@ protected override Expression VisitMethodCall(MethodCallExpression node) VisitMethodCallCore(node); + if (_polymorphicDispatch + && node.Object is { } callInstance + && node.Method is { DeclaringType.IsInterface: false, IsVirtual: true, IsFinal: false } + && Attribute.IsDefined(node.Method, typeof(ExpressiveAttribute), inherit: false) + && TryExpandPolymorphic(node.Method, callInstance, node.Arguments, node.Type, out var polyCall)) + { + return polyCall; + } + var methodInfo = node.Method.DeclaringType?.IsInterface == true ? (node.Object?.Type.GetConcreteMethod(node.Method) ?? node.Method) : node.Method; @@ -165,6 +188,15 @@ when type.IsAssignableFrom(operand.Type) _ => node.Member }; + if (_polymorphicDispatch + && node.Expression is { } memberInstance + && node.Member is PropertyInfo { DeclaringType.IsInterface: false, GetMethod: { IsVirtual: true, IsFinal: false } } + && Attribute.IsDefined(node.Member, typeof(ExpressiveAttribute), inherit: false) + && TryExpandPolymorphic(node.Member, memberInstance, methodArgs: null, node.Type, out var polyMember)) + { + return polyMember; + } + if (!_expandingMembers.Contains(nodeMember) && TryGetReflectedExpression(nodeMember, out var reflectedExpression)) { @@ -190,6 +222,252 @@ when type.IsAssignableFrom(operand.Type) return base.VisitMember(node); } + /// + /// Expands a virtual/override [Expressive] member into a runtime type-test chain + /// (e is Dog ? ((Dog)e).body : baseBody) so each value uses its runtime type's body; + /// EF Core translates the is tests to a TPH discriminator CASE. Returns + /// false (caller falls back to the static path) when there is nothing polymorphic to do. + /// + private bool TryExpandPolymorphic(MemberInfo baseMember, Expression instance, + IReadOnlyList? methodArgs, Type resultType, [NotNullWhen(true)] out Expression? result) + { + result = null; + + // Self-referential member already mid-expansion: let the legacy path emit a plain access. + if (_expandingMembers.Contains(baseMember)) + { + return false; + } + + var rootType = instance.Type; + if (rootType.IsInterface) + { + return false; + } + + var plan = GetOrBuildPolymorphicPlan(rootType, baseMember); + + if (plan.Arms.Length == 0) + { + // No derived overrides: diverge from the static path only when the receiver's own type + // overrides the base slot (so `d.Score` uses ScoreDerived's body, not ScoreBase's). + if (!plan.RootRegistered + || SameSlot(plan.RootMember, baseMember) + || _expandingMembers.Contains(plan.RootMember)) + { + return false; + } + + result = ExpandPolymorphicBody(plan.RootMember, instance, convertTo: null, methodArgs, resultType); + return true; + } + + // Else branch: the receiver type's own body, or a throw when the base slot is abstract + // (unreachable in a closed TPH hierarchy where every concrete row matches an arm). + Expression acc = plan.RootRegistered + ? ExpandPolymorphicBody(plan.RootMember, instance, convertTo: null, methodArgs, resultType) + : Expression.Throw( + Expression.New( + typeof(InvalidOperationException).GetConstructor([typeof(string)])!, + Expression.Constant( + $"No polymorphic [Expressive] override matched the runtime type for member '{plan.RootMember.Name}'.")), + resultType); + + // Ascending depth → folding leaves the most-derived test outermost. + foreach (var arm in plan.Arms) + { + var thenExpr = ExpandPolymorphicBody(arm.Member, instance, convertTo: arm.TestType, methodArgs, resultType); + acc = Expression.Condition(Expression.TypeIs(instance, arm.TestType), thenExpr, acc, resultType); + } + + result = acc; + return true; + } + + private static bool SameSlot(MemberInfo a, MemberInfo b) => a.DeclaringType == b.DeclaringType; + + /// + /// Binds (cast to for a derived arm) + /// into the member's body and recursively expands it. A member already on the expansion stack + /// (e.g. a base.X reference inside its own override) is left as a plain access. + /// + private Expression ExpandPolymorphicBody(MemberInfo member, Expression instance, Type? convertTo, + IReadOnlyList? methodArgs, Type resultType) + { + var boundInstance = convertTo is null ? instance : Expression.Convert(instance, convertTo); + + if (_expandingMembers.Contains(member) || !TryGetReflectedExpressionSafe(member, out var body)) + { + return Coerce(PlainAccess(member, boundInstance, methodArgs), resultType); + } + + _expandingMembers.Add(member); + var added = new List(body.Parameters.Count); + var map = _expressionArgumentReplacer.ParameterArgumentMapping; + try + { + map[body.Parameters[0]] = boundInstance; + added.Add(body.Parameters[0]); + for (var i = 1; i < body.Parameters.Count; i++) + { + if (methodArgs is not null && methodArgs.Count >= i) + { + map[body.Parameters[i]] = methodArgs[i - 1]; + added.Add(body.Parameters[i]); + } + } + + // Substitution is eager, so remove only our own keys (never Clear the shared map, + // which sibling branches and nested expansions also use). + var substituted = _expressionArgumentReplacer.Visit(body.Body); + return Coerce(base.Visit(substituted), resultType); + } + finally + { + foreach (var addedParameter in added) + { + map.Remove(addedParameter); + } + _expandingMembers.Remove(member); + } + } + + private static Expression Coerce(Expression expression, Type type) + => expression.Type == type ? expression : Expression.Convert(expression, type); + + private static Expression PlainAccess(MemberInfo member, Expression instance, IReadOnlyList? methodArgs) + => member switch + { + PropertyInfo property => Expression.Property(instance, property), + MethodInfo method => Expression.Call(instance, method, methodArgs ?? (IReadOnlyList)Array.Empty()), + _ => throw new InvalidOperationException($"Cannot build a plain access for member '{member}'.") + }; + + private PolymorphicPlan GetOrBuildPolymorphicPlan(Type rootType, MemberInfo baseMember) + { + var count = AppDomain.CurrentDomain.GetAssemblies().Length; + if (count != Volatile.Read(ref _polymorphicPlanAssemblyCount)) + { + _polymorphicPlanCache.Clear(); + Volatile.Write(ref _polymorphicPlanAssemblyCount, count); + } + + return _polymorphicPlanCache.GetOrAdd((rootType, baseMember), + key => BuildPolymorphicPlan(key.RootType, key.BaseMember)); + } + + private PolymorphicPlan BuildPolymorphicPlan(Type rootType, MemberInfo baseMember) + { + var rootMember = ResolveConcreteMember(rootType, baseMember) ?? baseMember; + var rootRegistered = TryGetReflectedExpressionSafe(rootMember, out _); + + var arms = new List(); + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + if (assembly.IsDynamic) + { + continue; + } + + Type?[] types; + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + types = ex.Types; + } + catch + { + continue; + } + + foreach (var candidate in types) + { + // Open generic definitions (Box) can't be used in Expression.TypeIs/Convert and + // are never a runtime type; only closed constructions reach a query. + if (candidate is null || candidate.IsInterface || candidate == rootType + || candidate.ContainsGenericParameters + || !rootType.IsAssignableFrom(candidate)) + { + continue; + } + + var concrete = ResolveConcreteMember(candidate, baseMember); + + // One arm per declaring type: a type that only inherits an override is covered by + // its declaring ancestor's `is` test. Skip plain overrides (no registered body) — + // EXP0032 flags those. + if (concrete is null || concrete.DeclaringType != candidate + || !TryGetReflectedExpressionSafe(concrete, out _)) + { + continue; + } + + arms.Add(new PolymorphicArm(candidate, concrete, InheritanceDepth(rootType, candidate))); + } + } + + arms.Sort(static (a, b) => a.Depth.CompareTo(b.Depth)); + return new PolymorphicPlan(rootMember, rootRegistered, arms.ToArray()); + } + + private static MemberInfo? ResolveConcreteMember(Type type, MemberInfo baseMember) + { + try + { + return baseMember switch + { + PropertyInfo property => type.GetConcreteProperty(property), + MethodInfo method => type.GetConcreteMethod(method), + _ => null + }; + } + catch + { + return null; + } + } + + private static int InheritanceDepth(Type root, Type type) + { + var depth = 0; + for (var current = type; current is not null && current != root; current = current.BaseType) + { + depth++; + } + + return depth; + } + + private bool TryGetReflectedExpressionSafe(MemberInfo memberInfo, [NotNullWhen(true)] out LambdaExpression? reflectedExpression) + { + if (IsAbstractMember(memberInfo)) + { + reflectedExpression = null; + return false; + } + + return TryGetReflectedExpression(memberInfo, out reflectedExpression); + } + + private static bool IsAbstractMember(MemberInfo memberInfo) => memberInfo switch + { + MethodInfo method => method.IsAbstract, + PropertyInfo property => (property.GetMethod ?? property.SetMethod)?.IsAbstract ?? false, + _ => false + }; + + private sealed record PolymorphicArm(Type TestType, MemberInfo Member, int Depth); + + private sealed class PolymorphicPlan(MemberInfo rootMember, bool rootRegistered, PolymorphicArm[] arms) + { + public MemberInfo RootMember { get; } = rootMember; + public bool RootRegistered { get; } = rootRegistered; + public PolymorphicArm[] Arms { get; } = arms; + } + protected static bool IsCompilerGeneratedClosure(Type type) => type.Attributes.HasFlag(System.Reflection.TypeAttributes.NestedPrivate) && _compilerGeneratedClosureCache.GetValue(type, static t => diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Infrastructure/PolymorphicDispatchTestBase.cs b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Infrastructure/PolymorphicDispatchTestBase.cs new file mode 100644 index 00000000..073a27a9 --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Infrastructure/PolymorphicDispatchTestBase.cs @@ -0,0 +1,55 @@ +using ExpressiveSharp.EntityFrameworkCore.IntegrationTests.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ExpressiveSharp.EntityFrameworkCore.IntegrationTests.Infrastructure; + +// Verifies that a virtual [Expressive] member (Animal.Description) dispatches on the runtime +// type within a single TPH query: each row uses its own override, translated to a discriminator +// CASE in SQL. +public abstract class PolymorphicDispatchTestBase : EFCoreRelationalTestBase +{ + [TestInitialize] + public async Task SeedAnimals() + { + Context.Animals.AddRange( + new Animal { Id = 1, Name = "Critter" }, + new Dog { Id = 2, Name = "Rex", Breed = "Lab" }, + new Cat { Id = 3, Name = "Tom", Color = "black" }); + await Context.SaveChangesAsync(); + } + + [TestMethod] + public async Task Select_Description_UsesEachRowsRuntimeType() + { + var descriptions = await Context.Animals + .OrderBy(a => a.Id) + .Select(a => a.Description) + .ToListAsync(); + + CollectionAssert.AreEqual( + new[] { "Animal: Critter", "Dog:Lab", "Cat:black" }, + descriptions); + } + + [TestMethod] + public async Task Where_OnDescription_TranslatesAndFiltersByRuntimeType() + { + // The polymorphic CASE appears in a WHERE clause; only the Dog row's branch matches. + var ids = await Context.Animals + .Where(a => a.Description == "Dog:Lab") + .Select(a => a.Id) + .ToListAsync(); + + CollectionAssert.AreEqual(new[] { 2 }, ids); + } + + [TestMethod] + public void Select_Description_EmitsDiscriminatorCase() + { + var sql = Context.Animals.Select(a => a.Description).ToQueryString(); + + StringAssert.Contains(sql, "CASE", "Polymorphic dispatch should translate to a SQL CASE expression. SQL:\n" + sql); + StringAssert.Contains(sql, "Kind", "The CASE should branch on the TPH discriminator column. SQL:\n" + sql); + } +} diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Infrastructure/TestContextFactories.cs b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Infrastructure/TestContextFactories.cs index 8b9a91c5..8fa4d84f 100644 --- a/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Infrastructure/TestContextFactories.cs +++ b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Infrastructure/TestContextFactories.cs @@ -22,6 +22,9 @@ public static SqliteContextHandle CreateSqlite() public static SqliteContextHandle CreateSqliteQueryFilter() => CreateSqlite(opt => new QueryFilterTestDbContext(opt)); + public static SqliteContextHandle CreateSqlitePolymorphic() + => CreateSqlite(opt => new PolymorphicTestDbContext(opt)); + public static SqliteContextHandle CreateSqlite( Func, TContext> factory) where TContext : DbContext { @@ -54,6 +57,9 @@ public static SqlServerContextHandle CreateSqlServer() public static SqlServerContextHandle CreateSqlServerQueryFilter() => CreateSqlServer(opt => new QueryFilterTestDbContext(opt)); + public static SqlServerContextHandle CreateSqlServerPolymorphic() + => CreateSqlServer(opt => new PolymorphicTestDbContext(opt)); + public static SqlServerContextHandle CreateSqlServer( Func, TContext> factory) where TContext : DbContext { @@ -91,6 +97,9 @@ public static PostgresContextHandle CreatePostgres() public static PostgresContextHandle CreatePostgresQueryFilter() => CreatePostgres(opt => new QueryFilterTestDbContext(opt)); + public static PostgresContextHandle CreatePostgresPolymorphic() + => CreatePostgres(opt => new PolymorphicTestDbContext(opt)); + public static PostgresContextHandle CreatePostgres( Func, TContext> factory) where TContext : DbContext { @@ -145,6 +154,9 @@ public static PomeloMySqlContextHandle CreatePomeloMyS public static PomeloMySqlContextHandle CreatePomeloMySqlQueryFilter() => CreatePomeloMySql(opt => new QueryFilterTestDbContext(opt)); + public static PomeloMySqlContextHandle CreatePomeloMySqlPolymorphic() + => CreatePomeloMySql(opt => new PolymorphicTestDbContext(opt)); + public static PomeloMySqlContextHandle CreatePomeloMySql( Func, TContext> factory) where TContext : DbContext { diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Models/Animal.cs b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Models/Animal.cs new file mode 100644 index 00000000..57026b4b --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Models/Animal.cs @@ -0,0 +1,32 @@ +using ExpressiveSharp; + +namespace ExpressiveSharp.EntityFrameworkCore.IntegrationTests.Models; + +// TPH hierarchy exercising runtime polymorphic dispatch of a virtual [Expressive] member. +// Description is overridden per concrete type; expansion must emit an `is Dog`/`is Cat` +// type-test chain that EF Core translates to a discriminator CASE. +public class Animal +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + + [Expressive] + public virtual string Description => "Animal: " + Name; +} + +public class Dog : Animal +{ + public string Breed { get; set; } = ""; + + // Breed is a Dog-only column: expansion casts the receiver to ((Dog)a).Breed under `is Dog`. + [Expressive] + public override string Description => "Dog:" + Breed; +} + +public class Cat : Animal +{ + public string Color { get; set; } = ""; + + [Expressive] + public override string Description => "Cat:" + Color; +} diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/PolymorphicTestDbContext.cs b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/PolymorphicTestDbContext.cs new file mode 100644 index 00000000..1692ab75 --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/PolymorphicTestDbContext.cs @@ -0,0 +1,29 @@ +using ExpressiveSharp.EntityFrameworkCore.IntegrationTests.Models; +using Microsoft.EntityFrameworkCore; + +namespace ExpressiveSharp.EntityFrameworkCore.IntegrationTests; + +// Table-per-hierarchy mapping for Animal/Dog/Cat. The virtual [Expressive] Description +// expands to a runtime type-test chain, which EF Core translates against the "Kind" +// discriminator. +public class PolymorphicTestDbContext : DbContext +{ + public DbSet Animals => Set(); + + public PolymorphicTestDbContext(DbContextOptions options) : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedNever(); + entity.HasDiscriminator("Kind") + .HasValue("animal") + .HasValue("dog") + .HasValue("cat"); + }); + } +} diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/PomeloMySql/PolymorphicDispatchTests.cs b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/PomeloMySql/PolymorphicDispatchTests.cs new file mode 100644 index 00000000..424f5d9f --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/PomeloMySql/PolymorphicDispatchTests.cs @@ -0,0 +1,20 @@ +#if TEST_POMELO_MYSQL && !NET10_0_OR_GREATER +using ExpressiveSharp.EntityFrameworkCore.IntegrationTests.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ExpressiveSharp.EntityFrameworkCore.IntegrationTests.Tests.PomeloMySql; + +[TestClass] +public class PolymorphicDispatchTests : PolymorphicDispatchTestBase +{ + protected override IAsyncDisposable CreateContextHandle(out DbContext context) + { + if (!ContainerFixture.IsDockerAvailable) + Assert.Inconclusive("Docker not available"); + var handle = TestContextFactories.CreatePomeloMySqlPolymorphic(); + context = handle.Context; + return handle; + } +} +#endif diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Postgres/PolymorphicDispatchTests.cs b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Postgres/PolymorphicDispatchTests.cs new file mode 100644 index 00000000..bea570ee --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Postgres/PolymorphicDispatchTests.cs @@ -0,0 +1,20 @@ +#if TEST_POSTGRES +using ExpressiveSharp.EntityFrameworkCore.IntegrationTests.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ExpressiveSharp.EntityFrameworkCore.IntegrationTests.Tests.Postgres; + +[TestClass] +public class PolymorphicDispatchTests : PolymorphicDispatchTestBase +{ + protected override IAsyncDisposable CreateContextHandle(out DbContext context) + { + if (!ContainerFixture.IsDockerAvailable) + Assert.Inconclusive("Docker not available"); + var handle = TestContextFactories.CreatePostgresPolymorphic(); + context = handle.Context; + return handle; + } +} +#endif diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/SqlServer/PolymorphicDispatchTests.cs b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/SqlServer/PolymorphicDispatchTests.cs new file mode 100644 index 00000000..5681aeb5 --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/SqlServer/PolymorphicDispatchTests.cs @@ -0,0 +1,20 @@ +#if TEST_SQLSERVER +using ExpressiveSharp.EntityFrameworkCore.IntegrationTests.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ExpressiveSharp.EntityFrameworkCore.IntegrationTests.Tests.SqlServer; + +[TestClass] +public class PolymorphicDispatchTests : PolymorphicDispatchTestBase +{ + protected override IAsyncDisposable CreateContextHandle(out DbContext context) + { + if (!ContainerFixture.IsDockerAvailable) + Assert.Inconclusive("Docker not available"); + var handle = TestContextFactories.CreateSqlServerPolymorphic(); + context = handle.Context; + return handle; + } +} +#endif diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/PolymorphicDispatchDisabledTests.cs b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/PolymorphicDispatchDisabledTests.cs new file mode 100644 index 00000000..9946cc57 --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/PolymorphicDispatchDisabledTests.cs @@ -0,0 +1,47 @@ +using ExpressiveSharp.EntityFrameworkCore.IntegrationTests.Models; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ExpressiveSharp.EntityFrameworkCore.IntegrationTests.Tests.Sqlite; + +[TestClass] +public class PolymorphicDispatchDisabledTests +{ + [TestMethod] + public async Task DisablePolymorphicDispatch_FallsBackToStaticBaseBody() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + try + { + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .UseExpressives(o => o.DisablePolymorphicDispatch()) + .Options; + + await using var context = new PolymorphicTestDbContext(options); + await context.Database.EnsureCreatedAsync(); + + context.Animals.AddRange( + new Animal { Id = 1, Name = "Critter" }, + new Dog { Id = 2, Name = "Rex", Breed = "Lab" }, + new Cat { Id = 3, Name = "Tom", Color = "black" }); + await context.SaveChangesAsync(); + + var descriptions = await context.Animals + .OrderBy(a => a.Id) + .Select(a => a.Description) + .ToListAsync(); + + // Dispatch disabled: every row uses the base Animal.Description body. + CollectionAssert.AreEqual( + new[] { "Animal: Critter", "Animal: Rex", "Animal: Tom" }, + descriptions); + } + finally + { + await connection.DisposeAsync(); + } + } +} diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/PolymorphicDispatchTests.cs b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/PolymorphicDispatchTests.cs new file mode 100644 index 00000000..b175d929 --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/PolymorphicDispatchTests.cs @@ -0,0 +1,16 @@ +using ExpressiveSharp.EntityFrameworkCore.IntegrationTests.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ExpressiveSharp.EntityFrameworkCore.IntegrationTests.Tests.Sqlite; + +[TestClass] +public class PolymorphicDispatchTests : PolymorphicDispatchTestBase +{ + protected override IAsyncDisposable CreateContextHandle(out DbContext context) + { + var handle = TestContextFactories.CreateSqlitePolymorphic(); + context = handle.Context; + return handle; + } +} diff --git a/tests/ExpressiveSharp.Generator.Tests/CodeFixers/OverrideMissingExpressiveAnalyzerTests.cs b/tests/ExpressiveSharp.Generator.Tests/CodeFixers/OverrideMissingExpressiveAnalyzerTests.cs new file mode 100644 index 00000000..f4d5ea65 --- /dev/null +++ b/tests/ExpressiveSharp.Generator.Tests/CodeFixers/OverrideMissingExpressiveAnalyzerTests.cs @@ -0,0 +1,219 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ExpressiveSharp.CodeFixers; +using ExpressiveSharp.Generator.Tests.Infrastructure; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ExpressiveSharp.Generator.Tests.CodeFixers; + +[TestClass] +public sealed class OverrideMissingExpressiveAnalyzerTests : GeneratorTestBase +{ + [TestMethod] + public async Task OverrideProperty_MissingExpressive_WarnsEXP0032() + { + var diagnostics = await RunAnalyzerAsync( + """ + namespace Foo { + class Animal { + public string Name { get; set; } + [Expressive] public virtual string Description => "Animal: " + Name; + } + class Dog : Animal { + public override string Description => "Dog: " + Name; + } + } + """); + + Assert.IsTrue(diagnostics.Any(d => d.Id == "EXP0032"), + "Expected EXP0032 for an override of an [Expressive] property that is missing [Expressive]"); + } + + [TestMethod] + public async Task OverrideMethod_MissingExpressive_WarnsEXP0032() + { + var diagnostics = await RunAnalyzerAsync( + """ + namespace Foo { + class Animal { + public string Name { get; set; } + [Expressive] public virtual string Describe() => "Animal: " + Name; + } + class Dog : Animal { + public override string Describe() => "Dog: " + Name; + } + } + """); + + Assert.IsTrue(diagnostics.Any(d => d.Id == "EXP0032"), + "Expected EXP0032 for an override of an [Expressive] method that is missing [Expressive]"); + } + + [TestMethod] + public async Task Override_WithExpressive_NoWarning() + { + var diagnostics = await RunAnalyzerAsync( + """ + namespace Foo { + class Animal { + public string Name { get; set; } + [Expressive] public virtual string Description => "Animal: " + Name; + } + class Dog : Animal { + [Expressive] public override string Description => "Dog: " + Name; + } + } + """); + + Assert.IsFalse(diagnostics.Any(d => d.Id == "EXP0032"), + "An override that is itself [Expressive] participates in dispatch and must not warn"); + } + + [TestMethod] + public async Task Override_OfNonExpressiveBase_NoWarning() + { + var diagnostics = await RunAnalyzerAsync( + """ + namespace Foo { + class Animal { + public string Name { get; set; } + public virtual string Description => "Animal: " + Name; + } + class Dog : Animal { + public override string Description => "Dog: " + Name; + } + } + """); + + Assert.IsFalse(diagnostics.Any(d => d.Id == "EXP0032"), + "Overriding a non-[Expressive] member is unrelated to expansion and must not warn"); + } + + [TestMethod] + public async Task Override_WithNotExpressive_NoWarning() + { + var diagnostics = await RunAnalyzerAsync( + """ + namespace Foo { + class Animal { + public string Name { get; set; } + [Expressive] public virtual string Description => "Animal: " + Name; + } + class Dog : Animal { + [NotExpressive] public override string Description => "Dog: " + Name; + } + } + """); + + Assert.IsFalse(diagnostics.Any(d => d.Id == "EXP0032"), + "[NotExpressive] is the explicit opt-out and must silence EXP0032"); + } + + [TestMethod] + public async Task CodeFix_AddsExpressive_ToOverride() + { + const string source = """ + using ExpressiveSharp; + namespace Foo + { + class Animal + { + public string Name { get; set; } + [Expressive] public virtual string Description => "Animal: " + Name; + } + + class Dog : Animal + { + public override string Description => "Dog: " + Name; + } + } + """; + + var fixedSource = await ApplyCodeFixAsync(source); + + var lines = fixedSource.Split('\n').Select(l => l.TrimEnd('\r')).ToArray(); + var overrideLine = System.Array.FindIndex(lines, l => l.Contains("public override string Description")); + Assert.IsTrue(overrideLine > 0, "Should find the overriding property in output"); + Assert.IsTrue(lines[overrideLine - 1].Trim() == "[Expressive]", + $"Expected [Expressive] on the line before the override, but got: '{lines[overrideLine - 1].Trim()}'"); + } + + private async Task> RunAnalyzerAsync(string source) + { + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest); + var compilation = CSharpCompilation.Create( + "TestCompilation", + new[] + { + CSharpSyntaxTree.ParseText( + """ + global using System; + global using ExpressiveSharp; + """, parseOptions, "GlobalUsings.cs"), + CSharpSyntaxTree.ParseText(source, parseOptions, "TestFile.cs"), + }, + GetDefaultReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var analyzer = new OverrideMissingExpressiveAnalyzer(); + var compilationWithAnalyzers = compilation.WithAnalyzers( + ImmutableArray.Create(analyzer)); + + return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(CancellationToken.None); + } + + private async Task ApplyCodeFixAsync(string source) + { + var workspace = new AdhocWorkspace(); + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest); + + var projectId = ProjectId.CreateNewId(); + var projectInfo = ProjectInfo.Create( + projectId, + VersionStamp.Create(), + "TestProject", + "TestProject", + LanguageNames.CSharp, + compilationOptions: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary), + parseOptions: parseOptions, + metadataReferences: GetDefaultReferences()); + + var project = workspace.AddProject(projectInfo); + var doc = workspace.AddDocument(project.Id, "TestFile.cs", SourceText.From(source)); + project = doc.Project; + + var compilation = await project.GetCompilationAsync() + ?? throw new System.Exception("Failed to get compilation"); + + var analyzer = new OverrideMissingExpressiveAnalyzer(); + var withAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); + var diagnostics = await withAnalyzers.GetAnalyzerDiagnosticsAsync(CancellationToken.None); + var diagnostic = diagnostics.FirstOrDefault(d => d.Id == "EXP0032"); + Assert.IsNotNull(diagnostic, "Expected EXP0032 diagnostic to be emitted"); + + var fixDoc = project.Solution.GetDocument(diagnostic.Location.SourceTree); + Assert.IsNotNull(fixDoc, "Should find workspace document for diagnostic location"); + + var codeFix = new AddExpressiveCodeFixProvider(); + var actions = new List(); + var context = new CodeFixContext(fixDoc, diagnostic, (action, _) => actions.Add(action), CancellationToken.None); + await codeFix.RegisterCodeFixesAsync(context); + Assert.IsTrue(actions.Count > 0, "Expected at least one code fix action"); + + var operations = await actions[0].GetOperationsAsync(CancellationToken.None); + var apply = operations.OfType().First(); + var fixedDoc = apply.ChangedSolution.GetDocument(fixDoc.Id); + Assert.IsNotNull(fixedDoc, "Should find fixed document"); + + return (await fixedDoc.GetTextAsync()).ToString(); + } +} diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/DiagnosticTests.cs b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/DiagnosticTests.cs index be26d5bf..c97c0893 100644 --- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/DiagnosticTests.cs +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/DiagnosticTests.cs @@ -210,8 +210,9 @@ static class Mappings { } [TestMethod] - public void VirtualMethod_ReportsEXP0024() + public void VirtualMethod_DoesNotReportEXP0024_NowDispatchesPolymorphically() { + // EXP0024 is retired: virtual [Expressive] members now dispatch polymorphically at runtime. var compilation = CreateCompilation( """ namespace Foo { @@ -225,15 +226,13 @@ class Animal { """); var result = RunExpressiveGenerator(compilation); - var diag = result.Diagnostics.FirstOrDefault(d => d.Id == "EXP0024"); - Assert.IsNotNull(diag, "Expected EXP0024 for a virtual [Expressive] member"); - Assert.AreEqual(DiagnosticSeverity.Warning, diag.Severity); - Assert.IsTrue(result.GeneratedTrees.Length > 0, - "Generator should still produce output alongside the EXP0024 warning"); + Assert.IsFalse(result.Diagnostics.Any(d => d.Id == "EXP0024"), + "EXP0024 is retired and must not be reported for a virtual [Expressive] member"); + Assert.IsTrue(result.GeneratedTrees.Length > 0, "Generator should still produce output"); } [TestMethod] - public void VirtualAndOverrideProperties_BothReportEXP0024() + public void VirtualAndOverrideProperties_DoNotReportEXP0024() { var compilation = CreateCompilation( """ @@ -253,8 +252,8 @@ class Dog : Animal { """); var result = RunExpressiveGenerator(compilation); - Assert.AreEqual(2, result.Diagnostics.Count(d => d.Id == "EXP0024"), - "Expected EXP0024 for both the virtual base property and its override"); + Assert.IsFalse(result.Diagnostics.Any(d => d.Id == "EXP0024"), + "EXP0024 is retired for both the virtual base property and its override"); } [TestMethod] diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/InterfaceTests.cs b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/InterfaceTests.cs index e7d88704..e1b366a0 100644 --- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/InterfaceTests.cs +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/InterfaceTests.cs @@ -55,11 +55,10 @@ public interface IDefaultBase : IBase var result = RunExpressiveGenerator(compilation); - // A default interface member is implicitly virtual, so EXP0024 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("EXP0024", result.Diagnostics[0].Id); + // EXP0024 is retired. Runtime polymorphic dispatch covers class virtual members; default + // interface members are not in scope (they keep static interface-implementation resolution), + // but they no longer warn. The generator still emits the expression for the declared body. + Assert.AreEqual(0, result.Diagnostics.Length); Assert.AreEqual(1, result.GeneratedTrees.Length); return Verifier.Verify(result.GeneratedTrees[0].ToString()); diff --git a/tests/ExpressiveSharp.IntegrationTests/Scenarios/Store/Models/LineItem.cs b/tests/ExpressiveSharp.IntegrationTests/Scenarios/Store/Models/LineItem.cs index 74ccdce5..cae518cd 100644 --- a/tests/ExpressiveSharp.IntegrationTests/Scenarios/Store/Models/LineItem.cs +++ b/tests/ExpressiveSharp.IntegrationTests/Scenarios/Store/Models/LineItem.cs @@ -8,12 +8,9 @@ public class LineItem 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. EXP0024 is expected here - // by design and suppressed. -#pragma warning disable EXP0024 + // Virtual [Expressive] member with no derived overrides — regression coverage that expansion + // still reaches the query provider as translatable SQL. With no overrides anywhere the + // polymorphic plan is trivial, so this expands exactly like a non-virtual member (no type-test). [Expressive] public virtual bool IsExpensive => UnitPrice > 40; -#pragma warning restore EXP0024 } diff --git a/tests/ExpressiveSharp.IntegrationTests/Tests/ExpansionEdgeCasesTests.cs b/tests/ExpressiveSharp.IntegrationTests/Tests/ExpansionEdgeCasesTests.cs index d5461608..1f2adef7 100644 --- a/tests/ExpressiveSharp.IntegrationTests/Tests/ExpansionEdgeCasesTests.cs +++ b/tests/ExpressiveSharp.IntegrationTests/Tests/ExpansionEdgeCasesTests.cs @@ -1,4 +1,5 @@ using System.Linq.Expressions; +using ExpressiveSharp.Services; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace ExpressiveSharp.IntegrationTests.Tests; @@ -7,33 +8,94 @@ namespace ExpressiveSharp.IntegrationTests.Tests; public class ExpansionEdgeCasesTests { [TestMethod] - public void VirtualMethod_Expansion_UsesStaticDeclaredType() + public void VirtualMethod_OnBaseReceiver_DispatchesPolymorphically() { - var derived = new VirtualDispatchDerived { Id = 7, Name = "x" }; - + // Static receiver is the base type; the body is chosen by the runtime type. Expression> expr = b => b.Describe(); - var expanded = (Expression>)expr.ExpandExpressives(); - var fromExpansion = expanded.Compile()(derived); + var fn = ((Expression>)expr.ExpandExpressives()).Compile(); + + Assert.AreEqual("derived#7/x", fn(new VirtualDispatchDerived { Id = 7, Name = "x" })); + Assert.AreEqual("base#3", fn(new VirtualDispatchBase { Id = 3 })); - Assert.AreEqual("base#7", fromExpansion); + // The expansion is a runtime type-test, not a static inline. + Assert.IsTrue(expr.ExpandExpressives().ToString().Contains("Is VirtualDispatchDerived", StringComparison.Ordinal), + "Expected a runtime `is` type-test in the expansion. Got: " + expr.ExpandExpressives()); } [TestMethod] - public void BaseSlotProperty_ExpandsBaseBody_NotOverride() + public void OverrideProperty_OnDerivedReceiver_ExpandsOverrideBody() { + // Receiver static type is itself the override declarer, so its body wins over the base slot. + // ScoreDerived.Score => base.Score + 1 => (Id * 2) + 1. Expression> expr = d => d.Score; var expanded = (Expression>)expr.ExpandExpressives(); - Assert.AreEqual(10, expanded.Compile()(new ScoreDerived { Id = 5 })); + Assert.AreEqual(11, expanded.Compile()(new ScoreDerived { Id = 5 })); } [TestMethod] - public void BaseSlotMethod_ExpandsBaseBody_NotOverride() + public void OverrideProperty_OnBaseReceiver_DispatchesPolymorphically() { + Expression> expr = b => b.Score; + var fn = ((Expression>)expr.ExpandExpressives()).Compile(); + + Assert.AreEqual(11, fn(new ScoreDerived { Id = 5 })); // (5 * 2) + 1 + Assert.AreEqual(10, fn(new ScoreBase { Id = 5 })); // 5 * 2 + } + + [TestMethod] + public void OverrideMethod_OnDerivedReceiver_ExpandsOverrideBody() + { + // GreetDerived.Greet() => base.Greet() + 1 => (Id * 10) + 1. Expression> expr = d => d.Greet(); var expanded = (Expression>)expr.ExpandExpressives(); - Assert.AreEqual(30, expanded.Compile()(new GreetDerived { Id = 3 })); + Assert.AreEqual(31, expanded.Compile()(new GreetDerived { Id = 3 })); + } + + [TestMethod] + public void MethodWithArgument_DispatchesPolymorphically() + { + Expression> expr = c => c.Calc(2); + var fn = ((Expression>)expr.ExpandExpressives()).Compile(); + + Assert.AreEqual(12, fn(new CalcDerived { Id = 6 })); // Id * n + Assert.AreEqual(8, fn(new CalcBase { Id = 6 })); // Id + n + } + + [TestMethod] + public void MultiLevelHierarchy_PicksNearestOverride() + { + Expression> expr = n => n.Kind; + var fn = ((Expression>)expr.ExpandExpressives()).Compile(); + + Assert.AreEqual("leaf:1", fn(new Leaf { Id = 1 })); // most-derived override + Assert.AreEqual("branch:2", fn(new Branch { Id = 2 })); // intermediate override + Assert.AreEqual("branch:3", fn(new PlainBranch { Id = 3 })); // inherits Branch's override + } + + [TestMethod] + public void OpenGenericDerivedType_IsSkipped_UsesBaseBodyWithoutThrowing() + { + // The open generic derived type can't be a runtime type / Expression.TypeIs operand, so + // discovery skips it: every instance uses the base body (no crash during expansion). + Expression> expr = b => b.Describe(); + var fn = ((Expression>)expr.ExpandExpressives()).Compile(); + + Assert.AreEqual("base:5", fn(new GenericDescribeBase { Id = 5 })); + Assert.AreEqual("base:7", fn(new GenericDescribe { Id = 7 })); + } + + [TestMethod] + public void DisablePolymorphicDispatch_RevertsToStaticBaseBody() + { + var options = new ExpressiveOptions(); + options.DisablePolymorphicDispatch(); + + Expression> expr = b => b.Describe(); + var fn = ((Expression>)expr.ExpandExpressives(options)).Compile(); + + Assert.AreEqual("base#7", fn(new VirtualDispatchDerived { Id = 7, Name = "x" })); } [TestMethod] @@ -124,7 +186,6 @@ public void Polyfill_StringRangeSlice_ProducesSubstring() } } -#pragma warning disable EXP0024 public class VirtualDispatchBase { public int Id { get; set; } @@ -168,7 +229,61 @@ public class GreetDerived : GreetBase [Expressive] public override int Greet() => base.Greet() + 1; } -#pragma warning restore EXP0024 + +public class CalcBase +{ + public int Id { get; set; } + + [Expressive] + public virtual int Calc(int n) => Id + n; +} + +public class CalcDerived : CalcBase +{ + [Expressive] + public override int Calc(int n) => Id * n; +} + +public abstract class Node +{ + public int Id { get; set; } + + [Expressive] + public virtual string Kind => $"node:{Id}"; +} + +public class Branch : Node +{ + [Expressive] + public override string Kind => $"branch:{Id}"; +} + +public class Leaf : Branch +{ + [Expressive] + public override string Kind => $"leaf:{Id}"; +} + +// Inherits Branch's override without redeclaring — should resolve via the `is Branch` arm. +public class PlainBranch : Branch +{ +} + +public class GenericDescribeBase +{ + public int Id { get; set; } + + [Expressive] + public virtual string Describe() => "base:" + Id; +} + +// Open generic override: discovery must skip it (ContainsGenericParameters) rather than emit an +// invalid `is GenericDescribe<>` test. +public class GenericDescribe : GenericDescribeBase +{ + [Expressive] + public override string Describe() => "generic:" + Id; +} public class RecursiveTree {