Hierarchy implementation and fixes#215
Conversation
|
I have some doubts about I would replace it with a cast like this So I would generate two expressions for each member in a hierarchy: one for the public invocations, and one for invocations from derived classes to avoid loops. |
|
Also closes #213 I have rewritten I am now generating 2 expressions for hierarchies: one "complete" which has all the typecasts and derived classes invocations, and one "base" which is used when replacing The "base" expressions currently do not have an assembly registry generated like regular expressions. |
|
thanks for this contribution! I'm hesitant to merge it in its current form and reasons for why we have not yet implemented this thus far:
Instead of doing this at compile time, we could do this at runtime instead which solves all 3 problems, at the cost of a slightly higher initial lookup cost when using this feature. If we wanted to merge this then I recommend:
|
|
Some feedback that Claude came up with (haven't verified): Crashes / correctness bugs Partial classes throw. Line 220 uses .Members.First(m => …isOverride…). For a partial class, each DeclaringSyntaxReference is one part; the part that doesn't declare the override makes .First throw InvalidOperationException. Should be .Where(...).Any() / FirstOrDefault. Property base-detection is fragile and inconsistent with the method path. The method path uses UnwrapUnaryConvert (line 216), but the property path (lines 358–359) requires u.Type == u.Operand.Type.BaseType and p.Name == "@this" — i.e. exactly one cast level off a parameter literally named @this. After argument substitution the receiver may be a nested expression or a multi-level cast ((A)(B1)@this in a 3-level hierarchy), so base detection silently fails there and falls through to the dispatch version — risking wrong dispatch or recursion. The two paths should share one detection helper. Derived overrides aren't required to be [Projectable]. The derivedTypes filter checks only IsOverride && Kind && Name, not for the [Projectable] attribute. A non-projectable override still gets emitted as ((Derived)@this).Member; at runtime the replacer can't expand it, leaving a raw CLR member access that EF may fail to translate (if it's a computed, non-mapped member). Either require [Projectable] on the override or emit a diagnostic. Open generics explicitly unhandled — two // DEV: handle generic types TODOs in HierarchyMembersConverter. Generic derived types will produce wrong/invalid casts. Should be a guarded diagnostic until supported, not silent. |
| // Check if we are rewriting a base property ((BaseType)@this).MyProp | ||
| var isBase = (node.Expression is UnaryExpression u && u.NodeType == ExpressionType.Convert && | ||
| u.Type == u.Operand.Type.BaseType && u.Operand is ParameterExpression p && p.Name == "@this"); | ||
|
|
There was a problem hiding this comment.
Not relevant because we are only interested in replacing base expressions inside other Projectable expressions, where the lambda parameter is normalized as "@this"
| var attributeSymbol = compilation.GetSemanticModel(aa.SyntaxTree).GetSymbolInfo(aa).Symbol; | ||
|
|
||
| INamedTypeSymbol attributeTypeSymbol; | ||
| if (attributeSymbol is IMethodSymbol methodSymbol) | ||
| { | ||
| attributeTypeSymbol = methodSymbol.ContainingType; | ||
| } | ||
| else | ||
| { | ||
| attributeTypeSymbol = ((INamedTypeSymbol)attributeSymbol!); | ||
| } | ||
|
|
||
| return attributeTypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == | ||
| "global::EntityFrameworkCore.Projectables.ProjectableAttribute"; | ||
| }))))).ToList(); |
| var types = GetAllTypes(compilation.GlobalNamespace) | ||
| .Where(t => IsDerivedFrom(t, symbol.ContainingType) && | ||
| t.DeclaringSyntaxReferences.Any(s => ((ClassDeclarationSyntax)s.GetSyntax()).Members.Any(m => { | ||
| var ss = compilation.GetSemanticModel(m.SyntaxTree).GetDeclaredSymbol(m); | ||
| return (ss != null && ss.IsOverride && ss.Kind == symbol.Kind && ss.Name == symbol.Name); |
| [Fact] | ||
| public void VisitMember_HierarchyBaseProperty() | ||
| { | ||
| Expression<Func<Foo, int>> input = x => x.VirtualProperty; | ||
| Expression<Func<Foo, int>> expectedFooBase = x => 1; | ||
| Expression<Func<Bar, int>> expectedBar = x => true ? 2 : ((Foo)x).VirtualProperty; | ||
| Expression<Func<Foo, int>> expectedFoo = x => x is Bar ? true ? 2 : 1 : 1; | ||
|
|
||
| var resolver = new ProjectableExpressionResolverStubBase( | ||
| (x, a) => x.DeclaringType == typeof(Foo) ? expectedFoo : expectedBar, | ||
| (x, a) => expectedFooBase | ||
| ); | ||
| var subject = new ProjectableExpressionReplacer(resolver); | ||
|
|
||
| var actual = subject.Replace(input); | ||
|
|
||
| Assert.Equal(expectedFoo.ToString(), actual.ToString()); | ||
| } |
| [Fact] | ||
| public void VisitMember_HierarchyBaseMethod() | ||
| { | ||
| Expression<Func<Foo, int>> input = x => x.VirtualMethod(); | ||
| Expression<Func<Foo, int>> expectedFooBase = x => 1; | ||
| Expression<Func<Bar, int>> expectedBar = x => true ? 2 : ((Foo)x).VirtualMethod(); | ||
| Expression<Func<Foo, int>> expectedFoo = x => x is Bar ? true ? 2 : 1 : 1; | ||
|
|
||
| var resolver = new ProjectableExpressionResolverStubBase( | ||
| (x, a) => x.DeclaringType == typeof(Foo) ? expectedFoo : expectedBar, | ||
| (x, a) => expectedFooBase | ||
| ); | ||
| var subject = new ProjectableExpressionReplacer(resolver); | ||
|
|
||
| var actual = subject.Replace(input); | ||
|
|
||
| Assert.Equal(expectedFoo.ToString(), actual.ToString()); | ||
| } |
| namespace EntityFrameworkCore.Projectables.Generator.SyntaxRewriters | ||
| { | ||
| /// <summary> | ||
| /// Converts methods/properties bodies of hierarchies of classes into typed expressions. | ||
| /// </summary> | ||
| internal class HierarchyMembersConverter |
| public ProjectableExpressionReplacer(IProjectionExpressionResolver projectionExpressionResolver, bool trackByDefault = false): | ||
| this(projectionExpressionResolver, null!, trackByDefault) { } |
|
Thanks for the feedback! Yeah, from the beginning this seemed a bit "forced" to scan types from a source generator while it sounds more a runtime task. So if it sounds good I would add a property to the Would you prefer if I submit a different PR or keep working on this one? |
|
I changed the implementation to be in the replacer rather than the generator. Waiting for feedbacks on this one. |
Closes #74 and #213
I have added support for hierarchies where overwritten members are all added in the parent body with type casts, this is to match the runtime behaviour for generated expressions.
I have also allowed members tagged with [Projectable] to not have any body at all (abstract method/property) if they have at least one implemented member in derived classes.
I have added some tests, maybe not 100% of cases are covered, currently I'm not sure if any sub-hierarchies would be better flattened or not, eg:
A hierarchy like this:
will generate a final Expression like this (the nested B1 expression gets replaced later):
And I'm not sure if it would be better to generate a flat expression like this instead (by associating the condition for B1 to the last else), or if this is something already handled by EF Core:
I am open to any coding-style changes which might be required, also if everything seems good so far I could edit the docs to reflect the new changes.