Skip to content
Merged
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 @@ -64,6 +64,15 @@ private static bool TryApplyMethodBody(
ApplyParameterList(methodDeclarationSyntax.ParameterList, declarationSyntaxRewriter, descriptor);
ApplyTypeParameters(methodDeclarationSyntax, declarationSyntaxRewriter, descriptor);

// For C# 14 generic extension blocks (e.g. extension<T>(Wrapper<T> w)), the block-level
// type parameter T is on the extension type, not on the method declaration syntax.
// ApplyTypeParameters() therefore finds nothing; promote the extension-block type
// parameters to method-level type parameters when no syntax-level ones were found.
if (descriptor.TypeParameterList is null)
{
ApplyExtensionBlockTypeParameters(memberSymbol, descriptor);
}

return true;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,90 @@ private static void ApplyTypeParameters(
}
}

/// <summary>
/// For C# 14 generic extension blocks (e.g. <c>extension&lt;T&gt;(Wrapper&lt;T&gt; w)</c>),
/// the block-level type parameter <c>T</c> is owned by the extension type, not by the
/// method declaration syntax. <see cref="ApplyTypeParameters"/> therefore finds no
/// <c>TypeParameterList</c> on the method and produces nothing.
/// <para>
/// This helper promotes those extension-block type parameters to method-level type
/// parameters on <paramref name="descriptor"/> so the generated
/// <c>Expression&lt;T&gt;()</c> factory method is correctly generic.
/// It is a no-op when the containing type is not a generic extension block.
/// </para>
/// </summary>
private static void ApplyExtensionBlockTypeParameters(
ISymbol memberSymbol,
ProjectableDescriptor descriptor)
{
if (memberSymbol.ContainingType is not { IsExtension: true } extensionType
|| extensionType.TypeParameters.IsDefaultOrEmpty)
{
return;
}

descriptor.TypeParameterList = SyntaxFactory.TypeParameterList();

foreach (var tp in extensionType.TypeParameters)
{
descriptor.TypeParameterList = descriptor.TypeParameterList.AddParameters(
SyntaxFactory.TypeParameter(tp.Name));

// Build the constraint clause when any constraint is present.
var hasAnyConstraint =
tp.HasReferenceTypeConstraint
|| tp.HasValueTypeConstraint
|| tp.HasNotNullConstraint
|| !tp.ConstraintTypes.IsDefaultOrEmpty
|| tp.HasConstructorConstraint;

if (!hasAnyConstraint)
{
continue;
}

descriptor.ConstraintClauses ??= SyntaxFactory.List<TypeParameterConstraintClauseSyntax>();
descriptor.ConstraintClauses = descriptor.ConstraintClauses.Value.Add(BuildConstraintClause(tp));
}
}

/// <summary>
/// Builds a <see cref="TypeParameterConstraintClauseSyntax"/> for <paramref name="tp"/>
/// by collecting all of its constraints in canonical order:
/// <c>class</c> / <c>struct</c> / <c>notnull</c>, explicit type constraints, then <c>new()</c>.
/// </summary>
private static TypeParameterConstraintClauseSyntax BuildConstraintClause(ITypeParameterSymbol tp)
{
var constraints = new List<TypeConstraintSyntax>();

if (tp.HasReferenceTypeConstraint)
{
constraints.Add(MakeTypeConstraint("class"));
}

if (tp.HasValueTypeConstraint)
{
constraints.Add(MakeTypeConstraint("struct"));
}

if (tp.HasNotNullConstraint)
{
constraints.Add(MakeTypeConstraint("notnull"));
}

constraints.AddRange(tp.ConstraintTypes
.Select(c => MakeTypeConstraint(c.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))));

if (tp.HasConstructorConstraint)
{
constraints.Add(MakeTypeConstraint("new()"));
}

return SyntaxFactory.TypeParameterConstraintClause(
SyntaxFactory.IdentifierName(tp.Name),
SyntaxFactory.SeparatedList<TypeParameterConstraintSyntax>(constraints));
}

/// <summary>
/// Returns the readable getter expression from a property declaration, trying in order:
/// the property-level expression-body, the getter's expression-body, then the first
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,36 +261,7 @@ private static void SetupGenericTypeParameters(ProjectableDescriptor descriptor,
}

descriptor.ClassConstraintClauses ??= SyntaxFactory.List<TypeParameterConstraintClauseSyntax>();

var constraints = new List<TypeConstraintSyntax>();

if (tp.HasReferenceTypeConstraint)
{
constraints.Add(MakeTypeConstraint("class"));
}

if (tp.HasValueTypeConstraint)
{
constraints.Add(MakeTypeConstraint("struct"));
}

if (tp.HasNotNullConstraint)
{
constraints.Add(MakeTypeConstraint("notnull"));
}

constraints.AddRange(tp.ConstraintTypes
.Select(c => MakeTypeConstraint(c.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))));

if (tp.HasConstructorConstraint)
{
constraints.Add(MakeTypeConstraint("new()"));
}

descriptor.ClassConstraintClauses = descriptor.ClassConstraintClauses.Value.Add(
SyntaxFactory.TypeParameterConstraintClause(
SyntaxFactory.IdentifierName(tp.Name),
SyntaxFactory.SeparatedList<TypeParameterConstraintSyntax>(constraints)));
descriptor.ClassConstraintClauses = descriptor.ClassConstraintClauses.Value.Add(BuildConstraintClause(tp));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,19 +112,37 @@ static string GenerateNameImpl(StringBuilder stringBuilder, string? namespaceNam
}

/// <summary>
/// Appends <paramref name="typeName"/> to <paramref name="sb"/>, stripping the
/// <c>global::</c> prefix and replacing every character that is invalid in a C# identifier
/// with <c>'_'</c> — all in a single pass with no intermediate string allocations.
/// Appends <paramref name="typeName"/> to <paramref name="sb"/>, stripping every
/// <c>global::</c> occurrence (leading and those inside generic type argument lists)
/// and replacing every character that is invalid in a C# identifier with <c>'_'</c>.
/// <para>
/// The multi-occurrence stripping is necessary so that fully-qualified generic types
/// such as <c>global::Foo.Wrapper&lt;global::Foo.Entity&gt;</c> — produced by Roslyn's
/// <c>FullyQualifiedFormat</c> — yield the same sanitised name as the runtime resolver,
/// which never includes <c>global::</c>.
/// </para>
/// </summary>
private static void AppendSanitizedTypeName(StringBuilder sb, string typeName)
{
const string GlobalPrefix = "global::";
var start = typeName.StartsWith(GlobalPrefix, StringComparison.Ordinal) ? GlobalPrefix.Length : 0;
const int PrefixLength = 8; // "global::".Length

for (var i = start; i < typeName.Length; i++)
var i = 0;
while (i < typeName.Length)
{
// Skip every "global::" occurrence — both the leading prefix and any that
// appear inside generic type argument lists (e.g. "Wrapper<global::Inner>").
if (typeName[i] == 'g'
&& i + PrefixLength <= typeName.Length
&& string.CompareOrdinal(typeName, i, GlobalPrefix, 0, PrefixLength) == 0)
{
i += PrefixLength;
continue;
}

var c = typeName[i];
sb.Append(IsInvalidIdentifierChar(c) ? '_' : c);
i++;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,35 @@ public static class EntityExtensions
}
}

/// <summary>
/// Extension on a closed generic receiver type: <c>extension(GenericWrapper&lt;Entity&gt; w)</c>.
/// Tests the fix for the bug where <c>global::</c> inside generic type arguments caused a
/// name mismatch between the generated class and the runtime resolver.
/// </summary>
public static class ClosedGenericWrapperExtensions
{
extension(GenericWrapper<Entity> w)
{
[Projectable]
public int DoubleId() => w.Id * 2;
}
}

/// <summary>
/// Extension on an open generic receiver type: <c>extension&lt;T&gt;(GenericWrapper&lt;T&gt; w)</c>.
/// The block-level type parameter <c>T</c> becomes a method-level type parameter on the
/// generated <c>Expression&lt;T&gt;()</c> factory, resolved at runtime via generic method
/// reflection.
/// </summary>
public static class OpenGenericWrapperExtensions
{
extension<T>(GenericWrapper<T> w) where T : class
{
[Projectable]
public int TripleId() => w.Id * 3;
}
}

public static class IntExtensions
{
extension(int i)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SELECT [e].[Id] * 2
FROM [Entity] AS [e]
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SELECT [e].[Id] * 3
FROM [Entity] AS [e]
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,43 @@ public Task ExtensionMemberMethodWithParameterOnEntity()

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

/// <summary>
/// Regression test: extension member on a <em>closed</em> generic receiver type
/// (e.g. <c>extension(GenericWrapper&lt;Entity&gt; w)</c>) previously threw
/// "Unable to resolve generated expression" because <c>global::</c> inside generic
/// type arguments caused a naming mismatch between the generator and the resolver.
/// </summary>
[Fact]
public Task ExtensionMemberMethodOnClosedGenericReceiverType()
{
using var dbContext = new SampleDbContext<Entity>();

var query = dbContext.Set<Entity>()
.Select(x => new GenericWrapper<Entity> { Id = x.Id })
.Select(x => x.DoubleId());

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

/// <summary>
/// Exercises support for extension members on an <em>open</em> generic receiver type
/// (e.g. <c>extension&lt;T&gt;(GenericWrapper&lt;T&gt; w)</c>).
/// The block-level type parameter <c>T</c> must be promoted to a method-level type
/// parameter on the generated <c>Expression&lt;T&gt;()</c> factory so the runtime
/// resolver can construct the correct closed-generic expression.
/// </summary>
[Fact]
public Task ExtensionMemberMethodOnOpenGenericReceiverType()
{
using var dbContext = new SampleDbContext<Entity>();

var query = dbContext.Set<Entity>()
.Select(x => new GenericWrapper<Entity> { Id = x.Id })
.Select(x => x.TripleId());

return Verifier.Verify(query.ToQueryString());
}
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#if NET10_0_OR_GREATER
namespace EntityFrameworkCore.Projectables.FunctionalTests.ExtensionMembers
{
public class GenericWrapper<T>
{
public int Id { get; set; }
}
}
#endif

Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// <auto-generated/>
#nullable disable
using System;
using EntityFrameworkCore.Projectables;
using Foo;

namespace EntityFrameworkCore.Projectables.Generated
{
[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
static class Foo_WrapperExtensions_DoubleId_P0_Foo_Wrapper_Foo_Entity_
{
static global::System.Linq.Expressions.Expression<global::System.Func<global::Foo.Wrapper<global::Foo.Entity>, int>> Expression()
{
return (global::Foo.Wrapper<global::Foo.Entity> @this) => @this.Value.Id * 2;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// <auto-generated/>
#nullable disable
using System;
using EntityFrameworkCore.Projectables;
using Foo;

namespace EntityFrameworkCore.Projectables.Generated
{
[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
static class Foo_WrapperExtensions_DoubleId_P0_Foo_Wrapper_T_
{
static global::System.Linq.Expressions.Expression<global::System.Func<global::Foo.Wrapper<T>, int>> Expression<T>()
{
return (global::Foo.Wrapper<T> @this) => @this.Id * 2;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// <auto-generated/>
#nullable disable
using System;
using EntityFrameworkCore.Projectables;
using Foo;

namespace EntityFrameworkCore.Projectables.Generated
{
[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
static class Foo_WrapperExtensions_TripleId_P0_Foo_Wrapper_T_
{
static global::System.Linq.Expressions.Expression<global::System.Func<global::Foo.Wrapper<T>, int>> Expression<T>()
where T : class
{
return (global::Foo.Wrapper<T> @this) => @this.Id * 3;
}
}
}
Loading
Loading