From f9c6e85b3548abcdf4a491d1bef4afaecd8ef29d Mon Sep 17 00:00:00 2001 From: Koen Date: Sat, 21 Mar 2026 01:23:24 +0000 Subject: [PATCH] Implement partial class support for projectable members, enabling access to private fields and methods in generated companions --- .../Interpretation/ProjectableInterpreter.cs | 10 +- .../Models/ProjectableDescriptor.cs | 2 + .../ProjectionExpressionGenerator.cs | 96 ++++++++- ...rojectableProperty.DotNet10_0.verified.txt | 3 + ...ProjectableProperty.DotNet8_0.verified.txt | 3 + ...ProjectableProperty.DotNet9_0.verified.txt | 3 + ...rojectableProperty.DotNet10_0.verified.txt | 2 + ...ProjectableProperty.DotNet8_0.verified.txt | 2 + ...ProjectableProperty.DotNet9_0.verified.txt | 2 + .../PartialClassWithPrivateMembersTests.cs | 59 ++++++ ...sts.NonPartialClass_Unchanged.verified.txt | 16 ++ ...queCompanionInsidePartialType.verified.txt | 41 ++++ ...thEntriesUseClrNestedTypeName.verified.txt | 49 +++++ ...PartialType_TwoLevelShellWrap.verified.txt | 22 ++ ...ompanionCanAccessPrivateField.verified.txt | 19 ++ ...CompanionCanCallPrivateMethod.verified.txt | 19 ++ ...egistry_UsesClrNestedTypeName.verified.txt | 48 +++++ ...thod_GeneratesNestedCompanion.verified.txt | 19 ++ .../PartialClassTests.cs | 190 ++++++++++++++++++ ...essionPropertyInDifferentFile.verified.txt | 13 +- ...essionPropertyInDifferentFile.verified.txt | 13 +- 21 files changed, 616 insertions(+), 15 deletions(-) create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.FilterOnPrivateProjectableProperty.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.FilterOnPrivateProjectableProperty.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.FilterOnPrivateProjectableProperty.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.SelectPrivateProjectableProperty.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.SelectPrivateProjectableProperty.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.SelectPrivateProjectableProperty.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.cs create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.NonPartialClass_Unchanged.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_MethodOverloads_EachGetsUniqueCompanionInsidePartialType.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_MethodOverloads_Registry_BothEntriesUseClrNestedTypeName.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_NestedPartialType_TwoLevelShellWrap.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_PrivateFieldAccess_CompanionCanAccessPrivateField.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_PrivateMethodCall_CompanionCanCallPrivateMethod.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_Registry_UsesClrNestedTypeName.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_SimpleMethod_GeneratesNestedCompanion.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.cs diff --git a/src/EntityFrameworkCore.Projectables.Generator/Interpretation/ProjectableInterpreter.cs b/src/EntityFrameworkCore.Projectables.Generator/Interpretation/ProjectableInterpreter.cs index ba8f327..3664ba8 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/Interpretation/ProjectableInterpreter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/Interpretation/ProjectableInterpreter.cs @@ -196,9 +196,17 @@ private static ProjectableDescriptor BuildBaseDescriptor( descriptor.TargetNestedInClassNames = descriptor.NestedInClassNames; } + descriptor.IsDeclaringTypePartial = IsTypePartial(memberSymbol.ContainingType); + return descriptor; } - + + private static bool IsTypePartial(INamedTypeSymbol typeSymbol) => + typeSymbol.DeclaringSyntaxReferences + .Select(r => r.GetSyntax()) + .OfType() + .Any(t => t.Modifiers.Any(SyntaxKind.PartialKeyword)); + /// /// Gets the nested class path for a given type symbol, recursively including /// all containing types. diff --git a/src/EntityFrameworkCore.Projectables.Generator/Models/ProjectableDescriptor.cs b/src/EntityFrameworkCore.Projectables.Generator/Models/ProjectableDescriptor.cs index e653a01..90cf75d 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/Models/ProjectableDescriptor.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/Models/ProjectableDescriptor.cs @@ -34,4 +34,6 @@ internal class ProjectableDescriptor public SyntaxList? ConstraintClauses { get; set; } public ExpressionSyntax? ExpressionBody { get; set; } + + public bool IsDeclaringTypePartial { get; set; } } \ No newline at end of file diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectionExpressionGenerator.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectionExpressionGenerator.cs index 38821b1..2810768 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ProjectionExpressionGenerator.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectionExpressionGenerator.cs @@ -254,12 +254,38 @@ private static void Execute( ); } - compilationUnit = compilationUnit - .AddMembers( + if (projectable.IsDeclaringTypePartial) + { + // Nest the companion inside the user's partial type chain so it can access + // private/protected members of the enclosing type (C# nested-class access rules). + MemberDeclarationSyntax wrapped = classSyntax; + var currentType = memberSymbol.ContainingType; + while (currentType is not null) + { + wrapped = BuildPartialTypeShell(currentType).AddMembers(wrapped); + currentType = currentType.ContainingType; + } + + var ns = memberSymbol.ContainingType.ContainingNamespace.IsGlobalNamespace + ? null + : memberSymbol.ContainingType.ContainingNamespace.ToDisplayString(); + + compilationUnit = compilationUnit.AddMembers( + ns is not null + ? NamespaceDeclaration(ParseName(ns)).AddMembers(wrapped) + : wrapped + ); + } + else + { + compilationUnit = compilationUnit.AddMembers( NamespaceDeclaration( ParseName("EntityFrameworkCore.Projectables.Generated") ).AddMembers(classSyntax) - ) + ); + } + + compilationUnit = compilationUnit .WithLeadingTrivia( TriviaList( Comment("// "), @@ -294,6 +320,42 @@ static TypeArgumentListSyntax GetLambdaTypeArgumentListSyntax(ProjectableDescrip } } + /// + /// Builds a minimal partial type declaration shell for + /// suitable for wrapping a companion class. Uses the correct keyword for the type kind + /// (class, struct, record class, record struct, interface) and includes type parameters + /// when the type is generic. + /// + private static TypeDeclarationSyntax BuildPartialTypeShell(INamedTypeSymbol typeSymbol) + { + var name = typeSymbol.Name; + + TypeDeclarationSyntax shell = typeSymbol switch + { + { IsRecord: true, TypeKind: TypeKind.Struct } => + RecordDeclaration(Token(SyntaxKind.RecordKeyword), Identifier(name)) + .WithClassOrStructKeyword(Token(SyntaxKind.StructKeyword)) + .WithOpenBraceToken(Token(SyntaxKind.OpenBraceToken)) + .WithCloseBraceToken(Token(SyntaxKind.CloseBraceToken)), + { IsRecord: true } => + RecordDeclaration(Token(SyntaxKind.RecordKeyword), Identifier(name)) + .WithOpenBraceToken(Token(SyntaxKind.OpenBraceToken)) + .WithCloseBraceToken(Token(SyntaxKind.CloseBraceToken)), + { TypeKind: TypeKind.Struct } => StructDeclaration(name), + { TypeKind: TypeKind.Interface } => InterfaceDeclaration(name), + _ => ClassDeclaration(name) + }; + + if (typeSymbol.TypeParameters.Length > 0) + { + shell = shell.WithTypeParameterList( + TypeParameterList(SeparatedList( + typeSymbol.TypeParameters.Select(tp => TypeParameter(tp.Name))))); + } + + return shell.WithModifiers(TokenList(Token(SyntaxKind.PartialKeyword))); + } + /// /// Extracts a from a member declaration. /// Returns null when the member does not have [Projectable], is an extension member, @@ -362,7 +424,26 @@ static TypeArgumentListSyntax GetLambdaTypeArgumentListSyntax(ProjectableDescrip memberLookupName, parameterTypeNames.IsEmpty ? null : parameterTypeNames); - var generatedClassFullName = "EntityFrameworkCore.Projectables.Generated." + generatedClassName; + // When the declaring type is partial, the companion class is generated as a nested type + // inside the user's own type. Assembly.GetType uses '+' as the nested-type separator. + bool isPartial = containingType.DeclaringSyntaxReferences + .Select(r => r.GetSyntax()) + .OfType() + .Any(t => t.Modifiers.Any(SyntaxKind.PartialKeyword)); + + string generatedClassFullName; + if (isPartial) + { + var ns = containingType.ContainingNamespace.IsGlobalNamespace + ? null + : containingType.ContainingNamespace.ToDisplayString(); + var clrPath = BuildClrNestedTypePath(containingType) + "+" + generatedClassName; + generatedClassFullName = ns is not null ? $"{ns}.{clrPath}" : clrPath; + } + else + { + generatedClassFullName = "EntityFrameworkCore.Projectables.Generated." + generatedClassName; + } var declaringTypeFullName = containingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); @@ -374,6 +455,13 @@ static TypeArgumentListSyntax GetLambdaTypeArgumentListSyntax(ProjectableDescrip ParameterTypeNames: parameterTypeNames); } + private static string BuildClrNestedTypePath(INamedTypeSymbol typeSymbol) + { + if (typeSymbol.ContainingType is null) + return typeSymbol.Name; + return BuildClrNestedTypePath(typeSymbol.ContainingType) + "+" + typeSymbol.Name; + } + private static IEnumerable GetRegistryNestedTypePath(INamedTypeSymbol typeSymbol) { if (typeSymbol.ContainingType is not null) diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.FilterOnPrivateProjectableProperty.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.FilterOnPrivateProjectableProperty.DotNet10_0.verified.txt new file mode 100644 index 0000000..a40b888 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.FilterOnPrivateProjectableProperty.DotNet10_0.verified.txt @@ -0,0 +1,3 @@ +SELECT [p].[Id] +FROM [PartialEntity] AS [p] +WHERE [p].[Id] * 2 + 1 > 5 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.FilterOnPrivateProjectableProperty.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.FilterOnPrivateProjectableProperty.DotNet8_0.verified.txt new file mode 100644 index 0000000..a40b888 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.FilterOnPrivateProjectableProperty.DotNet8_0.verified.txt @@ -0,0 +1,3 @@ +SELECT [p].[Id] +FROM [PartialEntity] AS [p] +WHERE [p].[Id] * 2 + 1 > 5 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.FilterOnPrivateProjectableProperty.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.FilterOnPrivateProjectableProperty.DotNet9_0.verified.txt new file mode 100644 index 0000000..a40b888 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.FilterOnPrivateProjectableProperty.DotNet9_0.verified.txt @@ -0,0 +1,3 @@ +SELECT [p].[Id] +FROM [PartialEntity] AS [p] +WHERE [p].[Id] * 2 + 1 > 5 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.SelectPrivateProjectableProperty.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.SelectPrivateProjectableProperty.DotNet10_0.verified.txt new file mode 100644 index 0000000..ec51724 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.SelectPrivateProjectableProperty.DotNet10_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[Id] * 2 + 1 +FROM [PartialEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.SelectPrivateProjectableProperty.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.SelectPrivateProjectableProperty.DotNet8_0.verified.txt new file mode 100644 index 0000000..ec51724 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.SelectPrivateProjectableProperty.DotNet8_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[Id] * 2 + 1 +FROM [PartialEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.SelectPrivateProjectableProperty.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.SelectPrivateProjectableProperty.DotNet9_0.verified.txt new file mode 100644 index 0000000..ec51724 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.SelectPrivateProjectableProperty.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[Id] * 2 + 1 +FROM [PartialEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.cs b/tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.cs new file mode 100644 index 0000000..b6dae10 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/PartialClassWithPrivateMembersTests.cs @@ -0,0 +1,59 @@ +using System.Linq; +using System.Threading.Tasks; +using EntityFrameworkCore.Projectables.FunctionalTests.Helpers; +using Microsoft.EntityFrameworkCore; +using VerifyXunit; +using Xunit; + +namespace EntityFrameworkCore.Projectables.FunctionalTests +{ + /// + /// An entity that uses the partial class pattern to expose private projectable members. + /// Without the partial keyword, could not call the private + /// because the generated companion class would be in a + /// separate namespace and lack access to private members. + /// + public partial record PartialEntity + { + public int Id { get; set; } + + /// + /// Public projectable that delegates to a private projectable. + /// Requires the companion to be nested inside this type so it can call DoubleId. + /// + [Projectable] + public int Total => DoubleId + 1; + + /// + /// Private projectable — only accessible from within this type. + /// The companion is generated as a nested class inside (partial). + /// + [Projectable] + private int DoubleId => Id * 2; + } + + public class PartialClassWithPrivateMembersTests + { + [Fact] + public Task FilterOnPrivateProjectableProperty() + { + using var dbContext = new SampleDbContext(); + + var query = dbContext.Set() + .Where(x => x.Total > 5); + + return Verifier.Verify(query.ToQueryString()); + } + + [Fact] + public Task SelectPrivateProjectableProperty() + { + using var dbContext = new SampleDbContext(); + + var query = dbContext.Set() + .Select(x => x.Total); + + return Verifier.Verify(query.ToQueryString()); + } + } +} diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.NonPartialClass_Unchanged.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.NonPartialClass_Unchanged.verified.txt new file mode 100644 index 0000000..a473451 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.NonPartialClass_Unchanged.verified.txt @@ -0,0 +1,16 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_C_Foo + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.C @this) => 1; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_MethodOverloads_EachGetsUniqueCompanionInsidePartialType.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_MethodOverloads_EachGetsUniqueCompanionInsidePartialType.verified.txt new file mode 100644 index 0000000..2ee9608 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_MethodOverloads_EachGetsUniqueCompanionInsidePartialType.verified.txt @@ -0,0 +1,41 @@ +[ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace Foo +{ + partial class C + { + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_C_Compute_P0_int + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.C @this, int x) => x + 1; + } + } + } +} + +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace Foo +{ + partial class C + { + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_C_Compute_P0_string + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.C @this, string s) => s.Length; + } + } + } +} +] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_MethodOverloads_Registry_BothEntriesUseClrNestedTypeName.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_MethodOverloads_Registry_BothEntriesUseClrNestedTypeName.verified.txt new file mode 100644 index 0000000..001755f --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_MethodOverloads_Registry_BothEntriesUseClrNestedTypeName.verified.txt @@ -0,0 +1,49 @@ +// +#nullable disable + +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Reflection; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal static class ProjectionRegistry + { + private static Dictionary Build() + { + const BindingFlags allFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; + var map = new Dictionary(); + + Register(map, typeof(global::Foo.C).GetMethod("Compute", allFlags, null, new global::System.Type[] { typeof(int) }, null), "Foo.C+Foo_C_Compute_P0_int"); + Register(map, typeof(global::Foo.C).GetMethod("Compute", allFlags, null, new global::System.Type[] { typeof(string) }, null), "Foo.C+Foo_C_Compute_P0_string"); + + return map; + } + + private static readonly Dictionary _map = Build(); + + public static LambdaExpression TryGet(MemberInfo member) + { + var handle = member switch + { + MethodInfo m => (nint?)m.MethodHandle.Value, + PropertyInfo p => p.GetMethod?.MethodHandle.Value, + ConstructorInfo c => (nint?)c.MethodHandle.Value, + _ => null + }; + + return handle.HasValue && _map.TryGetValue(handle.Value, out var expr) ? expr : null; + } + + private static void Register(Dictionary map, MethodBase m, string exprClass) + { + if (m is null) return; + var exprType = m.DeclaringType?.Assembly.GetType(exprClass); + var exprMethod = exprType?.GetMethod("Expression", BindingFlags.Static | BindingFlags.NonPublic); + if (exprMethod is not null) + map[m.MethodHandle.Value] = (LambdaExpression)exprMethod.Invoke(null, null)!; + } + } +} diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_NestedPartialType_TwoLevelShellWrap.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_NestedPartialType_TwoLevelShellWrap.verified.txt new file mode 100644 index 0000000..142a95f --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_NestedPartialType_TwoLevelShellWrap.verified.txt @@ -0,0 +1,22 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace Foo +{ + partial class Outer + { + partial class Inner + { + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_Outer_Inner_Value + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Outer.Inner @this) => 99; + } + } + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_PrivateFieldAccess_CompanionCanAccessPrivateField.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_PrivateFieldAccess_CompanionCanAccessPrivateField.verified.txt new file mode 100644 index 0000000..db62ed3 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_PrivateFieldAccess_CompanionCanAccessPrivateField.verified.txt @@ -0,0 +1,19 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace Foo +{ + partial class C + { + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_C_GetSecret + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.C @this) => @this._secret; + } + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_PrivateMethodCall_CompanionCanCallPrivateMethod.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_PrivateMethodCall_CompanionCanCallPrivateMethod.verified.txt new file mode 100644 index 0000000..4a8c9af --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_PrivateMethodCall_CompanionCanCallPrivateMethod.verified.txt @@ -0,0 +1,19 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace Foo +{ + partial class C + { + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_C_Compute + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.C @this) => @this.Helper() + 1; + } + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_Registry_UsesClrNestedTypeName.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_Registry_UsesClrNestedTypeName.verified.txt new file mode 100644 index 0000000..a5ec959 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_Registry_UsesClrNestedTypeName.verified.txt @@ -0,0 +1,48 @@ +// +#nullable disable + +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Reflection; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal static class ProjectionRegistry + { + private static Dictionary Build() + { + const BindingFlags allFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; + var map = new Dictionary(); + + Register(map, typeof(global::Foo.C).GetProperty("IdPlus1", allFlags)?.GetMethod, "Foo.C+Foo_C_IdPlus1"); + + return map; + } + + private static readonly Dictionary _map = Build(); + + public static LambdaExpression TryGet(MemberInfo member) + { + var handle = member switch + { + MethodInfo m => (nint?)m.MethodHandle.Value, + PropertyInfo p => p.GetMethod?.MethodHandle.Value, + ConstructorInfo c => (nint?)c.MethodHandle.Value, + _ => null + }; + + return handle.HasValue && _map.TryGetValue(handle.Value, out var expr) ? expr : null; + } + + private static void Register(Dictionary map, MethodBase m, string exprClass) + { + if (m is null) return; + var exprType = m.DeclaringType?.Assembly.GetType(exprClass); + var exprMethod = exprType?.GetMethod("Expression", BindingFlags.Static | BindingFlags.NonPublic); + if (exprMethod is not null) + map[m.MethodHandle.Value] = (LambdaExpression)exprMethod.Invoke(null, null)!; + } + } +} diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_SimpleMethod_GeneratesNestedCompanion.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_SimpleMethod_GeneratesNestedCompanion.verified.txt new file mode 100644 index 0000000..ae367b8 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.PartialClass_SimpleMethod_GeneratesNestedCompanion.verified.txt @@ -0,0 +1,19 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace Foo +{ + partial class C + { + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_C_Foo + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.C @this) => 1; + } + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.cs b/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.cs new file mode 100644 index 0000000..356ccd7 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/PartialClassTests.cs @@ -0,0 +1,190 @@ +using System.Linq; +using System.Threading.Tasks; +using VerifyXunit; +using Xunit; + +namespace EntityFrameworkCore.Projectables.Generator.Tests; + +public class PartialClassTests : ProjectionExpressionGeneratorTestsBase +{ + public PartialClassTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { } + + [Fact] + public Task PartialClass_SimpleMethod_GeneratesNestedCompanion() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; +namespace Foo { + public partial class C { + [Projectable] + public int Foo() => 1; + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task PartialClass_PrivateFieldAccess_CompanionCanAccessPrivateField() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; +namespace Foo { + public partial class C { + private int _secret = 42; + + [Projectable] + public int GetSecret() => _secret; + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task PartialClass_PrivateMethodCall_CompanionCanCallPrivateMethod() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; +namespace Foo { + public partial class C { + private int Helper() => 10; + + [Projectable] + public int Compute() => Helper() + 1; + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task PartialClass_NestedPartialType_TwoLevelShellWrap() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; +namespace Foo { + public partial class Outer { + public partial class Inner { + [Projectable] + public int Value() => 99; + } + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task PartialClass_Registry_UsesClrNestedTypeName() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; +namespace Foo { + public partial class C { + public int Id { get; set; } + [Projectable] + public int IdPlus1 => Id + 1; + } +} +"); + + var result = RunGenerator(compilation); + + Assert.NotNull(result.RegistryTree); + return Verifier.Verify(result.RegistryTree!.GetText(TestContext.Current.CancellationToken).ToString()); + } + + [Fact] + public Task NonPartialClass_Unchanged() + { + // Non-partial class should still produce companion in Generated namespace + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; +namespace Foo { + class C { + [Projectable] + public int Foo() => 1; + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task PartialClass_MethodOverloads_EachGetsUniqueCompanionInsidePartialType() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; +namespace Foo { + public partial class C { + [Projectable] + public int Compute(int x) => x + 1; + + [Projectable] + public int Compute(string s) => s.Length; + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Equal(2, result.GeneratedTrees.Length); + + // Both companions should be nested inside partial class C + return Verifier.Verify(result.GeneratedTrees.Select(t => t.ToString())); + } + + [Fact] + public Task PartialClass_MethodOverloads_Registry_BothEntriesUseClrNestedTypeName() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; +namespace Foo { + public partial class C { + [Projectable] + public int Compute(int x) => x + 1; + + [Projectable] + public int Compute(string s) => s.Length; + } +} +"); + + var result = RunGenerator(compilation); + + Assert.NotNull(result.RegistryTree); + return Verifier.Verify(result.RegistryTree!.GetText(TestContext.Current.CancellationToken).ToString()); + } +} diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_ExpressionPropertyInDifferentFile.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_ExpressionPropertyInDifferentFile.verified.txt index e7f84a8..ea4ac1f 100644 --- a/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_ExpressionPropertyInDifferentFile.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_ExpressionPropertyInDifferentFile.verified.txt @@ -5,14 +5,17 @@ using System.Linq.Expressions; using EntityFrameworkCore.Projectables; using Foo; -namespace EntityFrameworkCore.Projectables.Generated +namespace Foo { - [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] - static class Foo_C_IsPositive + partial class C { - static global::System.Linq.Expressions.Expression> Expression() + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_C_IsPositive { - return (global::Foo.C @this) => @this.Value > 0; + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.C @this) => @this.Value > 0; + } } } } \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Property_UsesExpressionPropertyBody_ExpressionPropertyInDifferentFile.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Property_UsesExpressionPropertyBody_ExpressionPropertyInDifferentFile.verified.txt index 34f67c5..bfa963f 100644 --- a/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Property_UsesExpressionPropertyBody_ExpressionPropertyInDifferentFile.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Property_UsesExpressionPropertyBody_ExpressionPropertyInDifferentFile.verified.txt @@ -5,14 +5,17 @@ using System.Linq.Expressions; using EntityFrameworkCore.Projectables; using Foo; -namespace EntityFrameworkCore.Projectables.Generated +namespace Foo { - [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] - static class Foo_C_Computed + partial class C { - static global::System.Linq.Expressions.Expression> Expression() + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_C_Computed { - return (global::Foo.C @this) => @this.Id * 2; + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.C @this) => @this.Id * 2; + } } } } \ No newline at end of file