Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<TypeDeclarationSyntax>()
.Any(t => t.Modifiers.Any(SyntaxKind.PartialKeyword));

/// <summary>
/// Gets the nested class path for a given type symbol, recursively including
/// all containing types.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,6 @@ internal class ProjectableDescriptor
public SyntaxList<TypeParameterConstraintClauseSyntax>? ConstraintClauses { get; set; }

public ExpressionSyntax? ExpressionBody { get; set; }

public bool IsDeclaringTypePartial { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +257 to +267
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When projectable.IsDeclaringTypePartial is true, the generator wraps the companion in all containing types and emits each wrapper as partial. This will break compilation if the projectable member is declared in a partial nested type whose outer containing type(s) are not partial (the generated partial class Outer { ... } will conflict with the user’s non-partial class Outer). Consider requiring the entire containing type chain to be partial before nesting (or falling back to the EntityFrameworkCore.Projectables.Generated namespace / emitting a diagnostic when an outer type is not partial).

Copilot uses AI. Check for mistakes.

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("// <auto-generated/>"),
Expand Down Expand Up @@ -294,6 +320,42 @@ static TypeArgumentListSyntax GetLambdaTypeArgumentListSyntax(ProjectableDescrip
}
}

/// <summary>
/// Builds a minimal <c>partial</c> type declaration shell for <paramref name="typeSymbol"/>
/// 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.
/// </summary>
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)));
}

/// <summary>
/// Extracts a <see cref="ProjectionRegistryEntry"/> from a member declaration.
/// Returns null when the member does not have [Projectable], is an extension member,
Expand Down Expand Up @@ -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<TypeDeclarationSyntax>()
.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);

Expand All @@ -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<string> GetRegistryNestedTypePath(INamedTypeSymbol typeSymbol)
{
if (typeSymbol.ContainingType is not null)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
SELECT [p].[Id]
FROM [PartialEntity] AS [p]
WHERE [p].[Id] * 2 + 1 > 5
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
SELECT [p].[Id]
FROM [PartialEntity] AS [p]
WHERE [p].[Id] * 2 + 1 > 5
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
SELECT [p].[Id]
FROM [PartialEntity] AS [p]
WHERE [p].[Id] * 2 + 1 > 5
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SELECT [p].[Id] * 2 + 1
FROM [PartialEntity] AS [p]
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SELECT [p].[Id] * 2 + 1
FROM [PartialEntity] AS [p]
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SELECT [p].[Id] * 2 + 1
FROM [PartialEntity] AS [p]
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System.Linq;
using System.Threading.Tasks;
using EntityFrameworkCore.Projectables.FunctionalTests.Helpers;
using Microsoft.EntityFrameworkCore;
using VerifyXunit;
using Xunit;

Comment on lines +1 to +7
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Projectable] is used in this file but using EntityFrameworkCore.Projectables; is missing, which will fail compilation unless there is a global using (none appears to exist in this project). Add the missing using (or fully-qualify the attribute).

Copilot uses AI. Check for mistakes.
namespace EntityFrameworkCore.Projectables.FunctionalTests
{
/// <summary>
/// An entity that uses the partial class pattern to expose private projectable members.
/// Without the partial keyword, <see cref="PartialEntity.Total"/> could not call the private
/// <see cref="PartialEntity.DoubleId"/> because the generated companion class would be in a
/// separate namespace and lack access to private members.
/// </summary>
public partial record PartialEntity
{
public int Id { get; set; }

/// <summary>
/// Public projectable that delegates to a private projectable.
/// Requires the companion to be nested inside this type so it can call DoubleId.
/// </summary>
[Projectable]
public int Total => DoubleId + 1;

/// <summary>
/// Private projectable — only accessible from within this type.
/// The companion is generated as a nested class inside <see cref="PartialEntity"/> (partial).
/// </summary>
[Projectable]
private int DoubleId => Id * 2;
}

public class PartialClassWithPrivateMembersTests
{
[Fact]
public Task FilterOnPrivateProjectableProperty()
{
using var dbContext = new SampleDbContext<PartialEntity>();

var query = dbContext.Set<PartialEntity>()
.Where(x => x.Total > 5);

return Verifier.Verify(query.ToQueryString());
}

[Fact]
public Task SelectPrivateProjectableProperty()
{
using var dbContext = new SampleDbContext<PartialEntity>();

var query = dbContext.Set<PartialEntity>()
.Select(x => x.Total);

return Verifier.Verify(query.ToQueryString());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// <auto-generated/>
#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<global::System.Func<global::Foo.C, int>> Expression()
{
return (global::Foo.C @this) => 1;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
[
// <auto-generated/>
#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<global::System.Func<global::Foo.C, int, int>> Expression()
{
return (global::Foo.C @this, int x) => x + 1;
}
}
}
}

// <auto-generated/>
#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<global::System.Func<global::Foo.C, string, int>> Expression()
{
return (global::Foo.C @this, string s) => s.Length;
}
}
}
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// <auto-generated/>
#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<nint, LambdaExpression> Build()
{
const BindingFlags allFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
var map = new Dictionary<nint, LambdaExpression>();

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<nint, LambdaExpression> _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<nint, LambdaExpression> 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)!;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// <auto-generated/>
#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<global::System.Func<global::Foo.Outer.Inner, int>> Expression()
{
return (global::Foo.Outer.Inner @this) => 99;
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// <auto-generated/>
#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<global::System.Func<global::Foo.C, int>> Expression()
{
return (global::Foo.C @this) => @this._secret;
}
}
}
}
Loading
Loading