From 902d7056e3e7a8183e2cc04aac35b7fa4153792c Mon Sep 17 00:00:00 2001 From: Koen Date: Mon, 1 Jun 2026 00:46:57 +0000 Subject: [PATCH 1/2] Allow virtual expressive members but warn; added documentation on the restriction as well as a suggested workaround --- .devcontainer/devcontainer-lock.json | 19 ++++++ docs/advanced/limitations.md | 61 +++++++++++++++++ docs/reference/diagnostics.md | 48 +++++++++++++ .../ExpressiveGenerator.cs | 11 +++ .../Infrastructure/Diagnostics.cs | 11 +++ .../Services/ExpressiveReplacer.cs | 10 +-- .../Infrastructure/StoreQueryTestBase.cs | 19 ++++++ .../ExpressiveGenerator/DiagnosticTests.cs | 68 +++++++++++++++++++ .../ExpressiveGenerator/InterfaceTests.cs | 6 +- .../Scenarios/Store/Models/LineItem.cs | 9 +++ .../Tests/ExpansionEdgeCasesTests.cs | 17 ++++- 11 files changed, 266 insertions(+), 13 deletions(-) create mode 100644 .devcontainer/devcontainer-lock.json diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 00000000..59875fc9 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,19 @@ +{ + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "version": "2.17.0", + "resolved": "ghcr.io/devcontainers/features/docker-in-docker@sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c", + "integrity": "sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c" + }, + "ghcr.io/devcontainers/features/dotnet:2": { + "version": "2.5.0", + "resolved": "ghcr.io/devcontainers/features/dotnet@sha256:0fc16547ed4db6d7ff2a9f5981d2b93eb314e568affb9958029ad794f1f9a093", + "integrity": "sha256:0fc16547ed4db6d7ff2a9f5981d2b93eb314e568affb9958029ad794f1f9a093" + }, + "ghcr.io/devcontainers/features/node:1": { + "version": "1.7.1", + "resolved": "ghcr.io/devcontainers/features/node@sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6", + "integrity": "sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6" + } + } +} diff --git a/docs/advanced/limitations.md b/docs/advanced/limitations.md index 82fbba52..1c7e44fe 100644 --- a/docs/advanced/limitations.md +++ b/docs/advanced/limitations.md @@ -111,6 +111,67 @@ static bool IsNullOrWhiteSpace(string? s) ``` ::: +## Virtual and Polymorphic Members {#virtual-polymorphic-members} + +Expression-tree expansion happens at **compile time** and works purely from the **static (declared) type** of each receiver. It has no runtime instance to inspect, so it cannot honor C# virtual dispatch. + +If you mark a `virtual`, `abstract`, or `override` member `[Expressive]` (a default interface member counts too -- it is implicitly virtual), the generator reports [EXP0038](../reference/diagnostics#exp0038). When the member is expanded for a query provider (EF Core, MongoDB), the call is resolved against the declared type and the **base** body is always inlined -- an overridden body in a derived type is never used: + +```csharp +public class Animal +{ + public string Name { get; set; } = ""; + + [Expressive] // EXP0038 + public virtual string Describe() => $"Animal: {Name}"; +} + +public class Dog : Animal +{ + [Expressive] // EXP0038 + public override string Describe() => $"Dog: {Name}"; +} + +// The static type is Animal, so expansion inlines the BASE body -- even for Dog rows: +db.Animals.AsExpressive().Select(a => a.Describe()); // => "Animal: {Name}" in SQL +``` + +This is by design: a query provider translates the expression to SQL/MQL and never materializes a CLR object, so there is no runtime type to dispatch on. (Contrast this with compiling the expression to a delegate and invoking it in memory, where the CLR *does* dispatch on the runtime type.) + +### Recommended: test the runtime type explicitly + +Branch on the concrete type so each arm has a statically-typed receiver. Every branch then expands to the correct body and the provider emits a `CASE`: + +```csharp +db.Animals.AsExpressive().Select(a => a switch +{ + Dog d => d.Describe(), // expands Dog.Describe + _ => a.Describe(), // expands Animal.Describe +}); +``` + +### Recommended: use a non-virtual static/extension method + +Move the logic into a single non-virtual `[Expressive]` method that performs the type test itself. This keeps the polymorphic shape in one place and produces no EXP0038: + +```csharp +public static class AnimalExpressions +{ + [Expressive] + public static string Describe(this Animal a) => a switch + { + Dog d => $"Dog: {d.Name}", + _ => $"Animal: {a.Name}", + }; +} + +db.Animals.AsExpressive().Select(a => a.Describe()); +``` + +::: tip +Declaring entity members `virtual` is common in EF Core because it enables lazy-loading proxies. That remains fine for plain navigation and scalar properties -- EXP0038 only concerns members you *also* mark `[Expressive]`. +::: + ## Performance: First-Execution Overhead `ExpandExpressives()` walks the expression tree and substitutes `[Expressive]` member references on every query execution. This adds a small cost to the first execution of each unique query shape. EF Core caches the compiled query afterward, so subsequent executions of the same shape skip the expansion entirely. diff --git a/docs/reference/diagnostics.md b/docs/reference/diagnostics.md index 55ea90d8..9279c8af 100644 --- a/docs/reference/diagnostics.md +++ b/docs/reference/diagnostics.md @@ -39,6 +39,7 @@ See [Troubleshooting](./troubleshooting) for symptom-oriented guidance -- find t | [EXP0034](#exp0034) | Error | `[ExpressiveProperty]` requires an instance stub | -- | | [EXP0035](#exp0035) | Error | `[ExpressiveProperty]` target shadows inherited member | -- | | [EXP0036](#exp0036) | Info | `IExpressiveQueryable` chain dropped to plain `IQueryable` | -- | +| [EXP0038](#exp0038) | Warning | `[Expressive]` member is virtual and will not dispatch polymorphically | -- | | [EXP1001](#exp1001) | Warning | Replace `[Projectable]` with `[Expressive]` | [Replace attribute](#exp1001-fix) | | [EXP1002](#exp1002) | Warning | Replace `UseProjectables()` with `UseExpressives()` | [Replace method call](#exp1002-fix) | | [EXP1003](#exp1003) | Warning | Replace Projectables namespace | [Replace namespace](#exp1003-fix) | @@ -697,6 +698,53 @@ db.Orders.AsExpressiveDbSet() --- +## Virtual Member Diagnostic (EXP0038) + +### EXP0038 -- Virtual member will not dispatch polymorphically {#exp0038} + +**Severity:** Warning +**Category:** Design + +**Message:** +``` +[Expressive] member '{0}' is virtual, abstract, or an override. When it is expanded into an +expression tree (e.g. for EF Core or MongoDB), the call is resolved using the static (declared) +type, so an overridden body in a derived type is never used. Test the runtime type explicitly +(e.g. 'x switch { Derived d => d.Member, _ => x.Member }'), or move the logic into a non-virtual +[Expressive] static/extension method. +``` + +**Cause:** An `[Expressive]` member is declared `virtual`, `abstract`, or `override` (a default interface member counts -- it is implicitly virtual). Expression-tree expansion happens at compile time and only sees the **static (declared) type** of the receiver, so it cannot honor C# virtual dispatch. When the member is expanded for a query provider, the **base** body is always inlined; an overridden body in a derived type is never used. + +This differs from compiling the expression to a delegate and invoking it in memory, where the CLR dispatches on the runtime type. + +**Fix:** Branch on the runtime type so each arm has a statically-typed receiver, or move the logic into a single non-virtual `[Expressive]` static/extension method. See [Limitations: virtual and polymorphic members](../advanced/limitations#virtual-polymorphic-members) for full examples. + +```csharp +// Warning: virtual [Expressive] member +[Expressive] +public virtual string Describe() => $"Animal: {Name}"; + +// Fix 1: test the runtime type explicitly +db.Animals.AsExpressive().Select(a => a switch +{ + Dog d => d.Describe(), + _ => a.Describe(), +}); + +// Fix 2: non-virtual [Expressive] extension method that does the type test once +[Expressive] +public static string Describe(this Animal a) => a switch +{ + Dog d => $"Dog: {d.Name}", + _ => $"Animal: {a.Name}", +}; +``` + +If a virtual member is intentional (you only ever compile it to an in-memory delegate, never translate it through a provider), suppress the warning with `#pragma warning disable EXP0038` or `$(NoWarn);EXP0038`. + +--- + ## Migration Diagnostics (EXP1001--EXP1003) These diagnostics are emitted by the `MigrationAnalyzer` in the `ExpressiveSharp.EntityFrameworkCore.CodeFixers` package. They detect usage of the legacy `EntityFrameworkCore.Projectables` library and offer automated code fixes to migrate to ExpressiveSharp. diff --git a/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs b/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs index 95d8d23d..a817b14a 100644 --- a/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs +++ b/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs @@ -229,6 +229,17 @@ private static void Execute( factoryCandidate.Identifier.Text)); } + // EXP0038: virtual/abstract/override members are expanded using the static (declared) + // type. Once the body is inlined into an expression tree (EF Core, MongoDB, ...), C# + // virtual dispatch is lost, so an overridden body in a derived type is never used. + if (memberSymbol.IsVirtual || memberSymbol.IsAbstract || memberSymbol.IsOverride) + { + context.ReportDiagnostic(Diagnostic.Create( + Infrastructure.Diagnostics.VirtualMemberDispatchedStatically, + memberSymbol.Locations.Length > 0 ? memberSymbol.Locations[0] : Location.None, + memberSymbol.Name)); + } + var generatedClassName = ExpressionClassNameGenerator.GenerateClassName(expressive.ClassNamespace, expressive.NestedInClassNames); var methodSuffix = ExpressionClassNameGenerator.GenerateMethodSuffix(expressive.MemberName, expressive.ParameterTypeNames); var generatedFileName = expressive.ClassTypeParameterList is not null diff --git a/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs b/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs index 7aa7c9d1..e1cab2b4 100644 --- a/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs +++ b/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs @@ -198,4 +198,15 @@ static internal class Diagnostics category: "Design", DiagnosticSeverity.Error, isEnabledByDefault: true); + + // EXP0036/EXP0037 live in WindowFunctionLiteralArgsAnalyzer (EntityFrameworkCore.CodeFixers). + + public readonly static DiagnosticDescriptor VirtualMemberDispatchedStatically = new DiagnosticDescriptor( + id: "EXP0038", + title: "[Expressive] member is virtual and will not dispatch polymorphically", + messageFormat: "[Expressive] member '{0}' is virtual, abstract, or an override. When it is expanded into an expression tree (e.g. for EF Core or MongoDB), the call is resolved using the static (declared) type, so an overridden body in a derived type is never used. Test the runtime type explicitly (e.g. 'x switch {{ Derived d => d.Member, _ => x.Member }}'), or move the logic into a non-virtual [Expressive] static/extension method.", + category: "Design", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Expression-tree expansion happens at compile time and only sees the static (declared) type of the receiver, so it cannot honor C# virtual dispatch. Declaring an [Expressive] member virtual/abstract/override therefore silently expands the base body for query providers. This differs from compiling the expression to a delegate and invoking it in memory, where the CLR dispatches on the runtime type."); } diff --git a/src/ExpressiveSharp/Services/ExpressiveReplacer.cs b/src/ExpressiveSharp/Services/ExpressiveReplacer.cs index 11f1a14f..7b0aa68f 100644 --- a/src/ExpressiveSharp/Services/ExpressiveReplacer.cs +++ b/src/ExpressiveSharp/Services/ExpressiveReplacer.cs @@ -50,9 +50,6 @@ protected bool TryGetReflectedExpression(MemberInfo memberInfo, [NotNullWhen(tru [return: NotNullIfNotNull(nameof(node))] public virtual Expression? Replace(Expression? node) => Visit(node); - private static bool IsPolymorphicallyDispatched(MethodInfo? method) - => method is not null && method.IsVirtual && !method.IsFinal; - protected override Expression VisitMethodCall(MethodCallExpression node) { // Replace MethodGroup arguments with their reflected expressions. @@ -80,8 +77,7 @@ protected override Expression VisitMethodCall(MethodCallExpression node) var methodInfo = node.Object?.Type.GetConcreteMethod(node.Method) ?? node.Method; - if (!IsPolymorphicallyDispatched(methodInfo) && - !_expandingMembers.Contains(methodInfo) && + if (!_expandingMembers.Contains(methodInfo) && TryGetReflectedExpression(methodInfo, out var reflectedExpression)) { _expandingMembers.Add(methodInfo); @@ -167,10 +163,6 @@ PropertyInfo property when nodeExpression is not null _ => node.Member }; - var virtualAccessor = nodeMember is PropertyInfo prop ? prop.GetMethod : null; - if (IsPolymorphicallyDispatched(virtualAccessor)) - return base.VisitMember(node); - if (!_expandingMembers.Contains(nodeMember) && TryGetReflectedExpression(nodeMember, out var reflectedExpression)) { diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Infrastructure/StoreQueryTestBase.cs b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Infrastructure/StoreQueryTestBase.cs index 69d73264..f9ade0a5 100644 --- a/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Infrastructure/StoreQueryTestBase.cs +++ b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Infrastructure/StoreQueryTestBase.cs @@ -190,4 +190,23 @@ public async Task ExpressiveFor_Inside_ExpressiveMember_ComposesCorrectly() // Totals: 240, 1500, 30, 250 → clamped to [0, 200]: 200, 200, 30, 200 CollectionAssert.AreEqual(new[] { 200.0, 200.0, 30.0, 200.0 }, results); } + + [TestMethod] + public async Task VirtualExpressiveMember_FiltersInDatabase() + { + // Regression for the reverted "bad commit": a virtual [Expressive] member must expand + // (using its static/declared body) and translate to SQL. The polymorphism gate used to + // skip expansion, so LineItem.IsExpensive reached the provider untranslated and threw. + // Compare against the database's own non-expressive predicate so the assertion is + // independent of the seed details. + Expression> expr = li => li.IsExpensive; + var expanded = (Expression>)expr.ExpandExpressives(); + + var expected = await Context.Set().Where(li => li.UnitPrice > 40).CountAsync(); + Assert.IsTrue(expected > 0, "Seed should contain at least one expensive line item."); + + var actual = await Context.Set().Where(expanded).CountAsync(); + + Assert.AreEqual(expected, actual); + } } diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/DiagnosticTests.cs b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/DiagnosticTests.cs index 008a8396..a7532ad4 100644 --- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/DiagnosticTests.cs +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/DiagnosticTests.cs @@ -209,6 +209,74 @@ static class Mappings { "Expected EXP0014 for unresolvable target type in [ExpressiveFor]"); } + [TestMethod] + public void VirtualMethod_ReportsEXP0038() + { + var compilation = CreateCompilation( + """ + namespace Foo { + class Animal { + public string Name { get; set; } + + [Expressive] + public virtual string Describe() => "Animal: " + Name; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + var diag = result.Diagnostics.FirstOrDefault(d => d.Id == "EXP0038"); + Assert.IsNotNull(diag, "Expected EXP0038 for a virtual [Expressive] member"); + Assert.AreEqual(DiagnosticSeverity.Warning, diag.Severity); + Assert.IsTrue(result.GeneratedTrees.Length > 0, + "Generator should still produce output alongside the EXP0038 warning"); + } + + [TestMethod] + public void VirtualAndOverrideProperties_BothReportEXP0038() + { + var compilation = CreateCompilation( + """ + namespace Foo { + class Animal { + public string Name { get; set; } + + [Expressive] + public virtual string Label => Name; + } + + class Dog : Animal { + [Expressive] + public override string Label => "Dog: " + Name; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(2, result.Diagnostics.Count(d => d.Id == "EXP0038"), + "Expected EXP0038 for both the virtual base property and its override"); + } + + [TestMethod] + public void NonVirtualMember_DoesNotReportEXP0038() + { + var compilation = CreateCompilation( + """ + namespace Foo { + class Animal { + public string Name { get; set; } + + [Expressive] + public string Describe() => "Animal: " + Name; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.IsFalse(result.Diagnostics.Any(d => d.Id == "EXP0038"), + "A non-virtual [Expressive] member must not report EXP0038"); + } + // NOTE: EXP0010 (InterceptorEmissionFailed) is intentionally not tested. // It is a catch-all for unhandled exceptions during interceptor generation // in PolyfillInterceptorGenerator. No natural user input triggers it — it diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/InterfaceTests.cs b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/InterfaceTests.cs index 38c58cef..68433fc3 100644 --- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/InterfaceTests.cs +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/InterfaceTests.cs @@ -55,7 +55,11 @@ public interface IDefaultBase : IBase var result = RunExpressiveGenerator(compilation); - Assert.AreEqual(0, result.Diagnostics.Length); + // A default interface member is implicitly virtual, so EXP0038 fires: when expanded into + // an expression tree the call resolves against the static (interface) type, not a runtime + // override. The generator still emits the expression for the declared body. + Assert.AreEqual(1, result.Diagnostics.Length); + Assert.AreEqual("EXP0038", result.Diagnostics[0].Id); Assert.AreEqual(1, result.GeneratedTrees.Length); return Verifier.Verify(result.GeneratedTrees[0].ToString()); diff --git a/tests/ExpressiveSharp.IntegrationTests/Scenarios/Store/Models/LineItem.cs b/tests/ExpressiveSharp.IntegrationTests/Scenarios/Store/Models/LineItem.cs index 8fbab5f3..36d39eb6 100644 --- a/tests/ExpressiveSharp.IntegrationTests/Scenarios/Store/Models/LineItem.cs +++ b/tests/ExpressiveSharp.IntegrationTests/Scenarios/Store/Models/LineItem.cs @@ -7,4 +7,13 @@ public class LineItem public string ProductName { get; set; } = ""; public double UnitPrice { get; set; } public int Quantity { get; set; } + + // Virtual [Expressive] member — regression coverage that static-type expansion still reaches + // the query provider as translatable SQL. The reverted "bad commit" gate skipped expansion for + // virtual members, so this would hit EF Core untranslated and throw. EXP0038 is expected here + // by design and suppressed. +#pragma warning disable EXP0038 + [Expressive] + public virtual bool IsExpensive => UnitPrice > 40; +#pragma warning restore EXP0038 } diff --git a/tests/ExpressiveSharp.IntegrationTests/Tests/ExpansionEdgeCasesTests.cs b/tests/ExpressiveSharp.IntegrationTests/Tests/ExpansionEdgeCasesTests.cs index 90a713dd..7f811d41 100644 --- a/tests/ExpressiveSharp.IntegrationTests/Tests/ExpansionEdgeCasesTests.cs +++ b/tests/ExpressiveSharp.IntegrationTests/Tests/ExpansionEdgeCasesTests.cs @@ -7,16 +7,22 @@ namespace ExpressiveSharp.IntegrationTests.Tests; public class ExpansionEdgeCasesTests { [TestMethod] - public void VirtualMethod_ExpansionPreservesPolymorphicDispatch() + public void VirtualMethod_Expansion_UsesStaticDeclaredType() { + // Expression-tree expansion runs at compile time and only sees the *static* (declared) + // type of the receiver — it cannot honor C# virtual dispatch. Expanding `b.Describe()` + // where `b` is statically typed VirtualDispatchBase therefore inlines the BASE body, + // even though the runtime instance is a VirtualDispatchDerived. This is the documented + // behavior EXP0038 warns about: query providers (EF Core, MongoDB) depend on it because + // they translate the tree to SQL/MQL and never see a runtime object. To branch on the + // concrete type, test it explicitly — see docs/advanced/limitations.md. var derived = new VirtualDispatchDerived { Id = 7, Name = "x" }; - var directCall = ((VirtualDispatchBase)derived).Describe(); Expression> expr = b => b.Describe(); var expanded = (Expression>)expr.ExpandExpressives(); var fromExpansion = expanded.Compile()(derived); - Assert.AreEqual(directCall, fromExpansion); + Assert.AreEqual("base#7", fromExpansion); } [TestMethod] @@ -107,6 +113,10 @@ public void Polyfill_StringRangeSlice_ProducesSubstring() } } +// These types deliberately declare a virtual/override [Expressive] member to exercise the +// static-type expansion behavior verified by VirtualMethod_Expansion_UsesStaticDeclaredType. +// EXP0038 fires exactly because of that shape, so it is suppressed here on purpose. +#pragma warning disable EXP0038 public class VirtualDispatchBase { public int Id { get; set; } @@ -122,6 +132,7 @@ public class VirtualDispatchDerived : VirtualDispatchBase [Expressive] public override string Describe() => $"derived#{Id}/{Name}"; } +#pragma warning restore EXP0038 public class RecursiveTree { From f07a9cebd8f7ac28140d6c4f518adcb2c26b1aa8 Mon Sep 17 00:00:00 2001 From: Koen Date: Mon, 1 Jun 2026 02:59:29 +0000 Subject: [PATCH 2/2] redirecting base.virtual calls to the override --- .../Services/ExpressiveReplacer.cs | 6 +- .../Tests/ExpansionEdgeCasesTests.cs | 56 +++++++++++++++---- 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/src/ExpressiveSharp/Services/ExpressiveReplacer.cs b/src/ExpressiveSharp/Services/ExpressiveReplacer.cs index 7b0aa68f..dfd8529e 100644 --- a/src/ExpressiveSharp/Services/ExpressiveReplacer.cs +++ b/src/ExpressiveSharp/Services/ExpressiveReplacer.cs @@ -75,7 +75,9 @@ protected override Expression VisitMethodCall(MethodCallExpression node) VisitMethodCallCore(node); - var methodInfo = node.Object?.Type.GetConcreteMethod(node.Method) ?? node.Method; + var methodInfo = node.Method.DeclaringType?.IsInterface == true + ? (node.Object?.Type.GetConcreteMethod(node.Method) ?? node.Method) + : node.Method; if (!_expandingMembers.Contains(methodInfo) && TryGetReflectedExpression(methodInfo, out var reflectedExpression)) @@ -158,7 +160,7 @@ when type.IsAssignableFrom(operand.Type) _ => node.Expression }; var nodeMember = node.Member switch { - PropertyInfo property when nodeExpression is not null + PropertyInfo property when nodeExpression is not null && property.DeclaringType?.IsInterface == true => nodeExpression.Type.GetConcreteProperty(property), _ => node.Member }; diff --git a/tests/ExpressiveSharp.IntegrationTests/Tests/ExpansionEdgeCasesTests.cs b/tests/ExpressiveSharp.IntegrationTests/Tests/ExpansionEdgeCasesTests.cs index 7f811d41..a41aac89 100644 --- a/tests/ExpressiveSharp.IntegrationTests/Tests/ExpansionEdgeCasesTests.cs +++ b/tests/ExpressiveSharp.IntegrationTests/Tests/ExpansionEdgeCasesTests.cs @@ -9,13 +9,6 @@ public class ExpansionEdgeCasesTests [TestMethod] public void VirtualMethod_Expansion_UsesStaticDeclaredType() { - // Expression-tree expansion runs at compile time and only sees the *static* (declared) - // type of the receiver — it cannot honor C# virtual dispatch. Expanding `b.Describe()` - // where `b` is statically typed VirtualDispatchBase therefore inlines the BASE body, - // even though the runtime instance is a VirtualDispatchDerived. This is the documented - // behavior EXP0038 warns about: query providers (EF Core, MongoDB) depend on it because - // they translate the tree to SQL/MQL and never see a runtime object. To branch on the - // concrete type, test it explicitly — see docs/advanced/limitations.md. var derived = new VirtualDispatchDerived { Id = 7, Name = "x" }; Expression> expr = b => b.Describe(); @@ -25,6 +18,24 @@ public void VirtualMethod_Expansion_UsesStaticDeclaredType() Assert.AreEqual("base#7", fromExpansion); } + [TestMethod] + public void BaseSlotProperty_ExpandsBaseBody_NotOverride() + { + Expression> expr = d => d.Score; + var expanded = (Expression>)expr.ExpandExpressives(); + + Assert.AreEqual(10, expanded.Compile()(new ScoreDerived { Id = 5 })); + } + + [TestMethod] + public void BaseSlotMethod_ExpandsBaseBody_NotOverride() + { + Expression> expr = d => d.Greet(); + var expanded = (Expression>)expr.ExpandExpressives(); + + Assert.AreEqual(30, expanded.Compile()(new GreetDerived { Id = 3 })); + } + [TestMethod] public void Polyfill_TypePatternSwitch_WithNullArm_BuildsExpression() { @@ -113,9 +124,6 @@ public void Polyfill_StringRangeSlice_ProducesSubstring() } } -// These types deliberately declare a virtual/override [Expressive] member to exercise the -// static-type expansion behavior verified by VirtualMethod_Expansion_UsesStaticDeclaredType. -// EXP0038 fires exactly because of that shape, so it is suppressed here on purpose. #pragma warning disable EXP0038 public class VirtualDispatchBase { @@ -132,6 +140,34 @@ public class VirtualDispatchDerived : VirtualDispatchBase [Expressive] public override string Describe() => $"derived#{Id}/{Name}"; } + +public class ScoreBase +{ + public int Id { get; set; } + + [Expressive] + public virtual int Score => Id * 2; +} + +public class ScoreDerived : ScoreBase +{ + [Expressive] + public override int Score => base.Score + 1; +} + +public class GreetBase +{ + public int Id { get; set; } + + [Expressive] + public virtual int Greet() => Id * 10; +} + +public class GreetDerived : GreetBase +{ + [Expressive] + public override int Greet() => base.Greet() + 1; +} #pragma warning restore EXP0038 public class RecursiveTree