Skip to content

Commit d51439f

Browse files
authored
Support file-scoped types as entity types (#38215)
For `file class` / `file record` declarations, the C# compiler synthesizes a metadata name of the form `<FileName>F<hex>__UserTypeName`. The existing implementation of `IReadOnlyTypeBase.ShortName` looks for the first `<` in the type's short display name to strip generic type arguments. For file-scoped types that `<` is at index 0, so the truncation produced an empty string. Consumers such as `NavigationExpandingExpressionVisitor.CreateNavigationExpansionExpression` then crashed with `IndexOutOfRangeException` when indexing the result. Detect the synthesized prefix via the `<...>F` signature (`F` distinguishes file-scoped types from async state machines `<...>d__N` and local function host classes `<...>g__Local|N_N`) and skip past the `__` separator that appears after `>F<hex>`. The search for `__` is bounded to start after the closing `>`, so file names containing `__` are not misparsed; user type names containing `__` are preserved because the search uses the first occurrence within the suffix only. Fixes #32323
1 parent 35d9542 commit d51439f

2 files changed

Lines changed: 234 additions & 8 deletions

File tree

src/EFCore/Metadata/IReadOnlyTypeBase.cs

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Text;
45
using Microsoft.EntityFrameworkCore.Metadata.Internal;
56

67
namespace Microsoft.EntityFrameworkCore.Metadata;
@@ -86,14 +87,16 @@ string DisplayName(bool omitSharedType)
8687
{
8788
if (!HasSharedClrType)
8889
{
89-
return ClrType.ShortDisplayName();
90+
return StripFileScopedTypePrefixes(ClrType.ShortDisplayName());
9091
}
9192

93+
var clrTypeDisplayName = StripFileScopedTypePrefixes(ClrType.ShortDisplayName());
94+
9295
var shortName = Name;
9396
var hashIndex = shortName.IndexOf("#", StringComparison.Ordinal);
9497
if (hashIndex == -1)
9598
{
96-
return Name + " (" + ClrType.ShortDisplayName() + ")";
99+
return Name + " (" + clrTypeDisplayName + ")";
97100
}
98101

99102
var plusIndex = shortName.LastIndexOf("+", StringComparison.Ordinal);
@@ -115,7 +118,7 @@ string DisplayName(bool omitSharedType)
115118
}
116119

117120
return shortName == Name
118-
? shortName + " (" + ClrType.ShortDisplayName() + ")"
121+
? shortName + " (" + clrTypeDisplayName + ")"
119122
: shortName;
120123
}
121124

@@ -131,16 +134,20 @@ string ShortName()
131134
var name = ClrType.ShortDisplayName();
132135
if (name.StartsWith("<>", StringComparison.Ordinal))
133136
{
137+
// Anonymous and closure types: <>f__AnonymousType0, <>c__DisplayClass0_0, ...
134138
name = name[2..];
135139
}
136-
137-
var lessIndex = name.IndexOf("<", StringComparison.Ordinal);
138-
if (lessIndex == -1)
140+
else
139141
{
140-
return name;
142+
// File-scoped types: Roslyn synthesizes the metadata name
143+
// <FileName>F<hex>__UserTypeName for `file class` / `file record` declarations.
144+
// Strip these sentinels wherever they appear (top-level or nested in generic args),
145+
// so e.g. List<<File>F1234__Inner> becomes List<Inner>.
146+
name = StripFileScopedTypePrefixes(name);
141147
}
142148

143-
return name[..lessIndex];
149+
var lessIndex = name.IndexOf('<', StringComparison.Ordinal);
150+
return lessIndex == -1 ? name : name[..lessIndex];
144151
}
145152

146153
var hashIndex = Name.LastIndexOf("#", StringComparison.Ordinal);
@@ -161,6 +168,64 @@ string ShortName()
161168
return Name[(hashIndex + 1)..];
162169
}
163170

171+
/// <summary>
172+
/// Strips Roslyn's synthesized file-scoped type prefix (<c>&lt;FileName&gt;F&lt;hex&gt;__</c>)
173+
/// from a CLR display name, including occurrences nested inside generic argument lists.
174+
/// For example <c>List&lt;&lt;Program&gt;F1234__Inner&gt;</c> becomes <c>List&lt;Inner&gt;</c>.
175+
/// </summary>
176+
/// <remarks>
177+
/// The <c>&gt;F</c> signature distinguishes file-scoped types from other compiler-generated
178+
/// types whose names begin with <c>&lt;</c> (async state machines <c>&lt;Method&gt;d__0</c>,
179+
/// local function host classes <c>&lt;Method&gt;g__Local|0_0</c>, anonymous types
180+
/// <c>&lt;&gt;f__AnonymousType</c>, closure display classes <c>&lt;&gt;c__DisplayClass</c>).
181+
/// Roslyn's synthesized metadata pattern uses the literal <c>&lt;filename&gt;F&lt;hex&gt;__</c>
182+
/// shape; the filename portion does not contain <c>&lt;</c>, so the closing <c>&gt;</c> of a
183+
/// sentinel is always the next <c>&gt;</c> after the opening <c>&lt;</c> with no
184+
/// intervening <c>&lt;</c>.
185+
/// </remarks>
186+
private static string StripFileScopedTypePrefixes(string name)
187+
{
188+
if (name.IndexOf('<', StringComparison.Ordinal) == -1)
189+
{
190+
return name;
191+
}
192+
193+
StringBuilder? sb = null;
194+
var i = 0;
195+
while (i < name.Length)
196+
{
197+
if (name[i] == '<')
198+
{
199+
// Look for the immediately-following `>F<hex>__` sentinel:
200+
// - the next `>` must come without any nested `<` in between (filenames have neither)
201+
// - the char right after `>` must be `F`
202+
// - a `__` must follow (the prefix terminator)
203+
var closeAngle = name.IndexOf('>', i + 1);
204+
if (closeAngle != -1
205+
&& closeAngle + 1 < name.Length
206+
&& name[closeAngle + 1] == 'F')
207+
{
208+
var nestedLt = name.IndexOf('<', i + 1, closeAngle - i - 1);
209+
if (nestedLt == -1)
210+
{
211+
var separator = name.IndexOf("__", closeAngle + 1, StringComparison.Ordinal);
212+
if (separator != -1)
213+
{
214+
sb ??= new StringBuilder(name.Length).Append(name, 0, i);
215+
i = separator + 2;
216+
continue;
217+
}
218+
}
219+
}
220+
}
221+
222+
sb?.Append(name[i]);
223+
i++;
224+
}
225+
226+
return sb?.ToString() ?? name;
227+
}
228+
164229
/// <summary>
165230
/// Determines whether the current type can be assigned to the specified type, i.e. is derived from or identical to it.
166231
/// </summary>

test/EFCore.Tests/Metadata/Internal/EntityTypeTest.cs

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3032,6 +3032,167 @@ public void ShortName_on_compiler_generated_type3()
30323032
Assert.Equal("__AnonymousType01Child", entityType.ShortName());
30333033
}
30343034

3035+
[ConditionalTheory]
3036+
// file class MyEntity in Program.cs
3037+
[InlineData("<Program>F1234ABCD__MyEntity", "MyEntity")]
3038+
// file class declared in a file whose name itself contains "__"
3039+
[InlineData("<My__File>F1234ABCD__MyEntity", "MyEntity")]
3040+
// file class whose user-chosen name contains "__"
3041+
[InlineData("<Program>F1234ABCD__Foo__Bar", "Foo__Bar")]
3042+
// file class with generic type parameters
3043+
[InlineData("<Program>F1234ABCD__MyEntity<int>", "MyEntity")]
3044+
public void ShortName_on_file_scoped_type(string clrName, string expectedShortName)
3045+
{
3046+
var model = CreateModel();
3047+
3048+
var assemblyName = new AssemblyName("DynamicEntityClrTypeAssembly_FileScoped");
3049+
var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.Run);
3050+
var moduleBuilder = assemblyBuilder.DefineDynamicModule("MyModule");
3051+
var typeBuilder = moduleBuilder.DefineType(clrName);
3052+
var type = typeBuilder.CreateType();
3053+
3054+
model.AddEntityType(type);
3055+
3056+
var entityType = model.FinalizeModel().FindEntityType(clrName);
3057+
3058+
Assert.Equal(expectedShortName, entityType.ShortName());
3059+
}
3060+
3061+
[ConditionalTheory]
3062+
// Regular type — no `<` prefix, no transformation
3063+
[InlineData("Foo", "Foo")]
3064+
// Type whose user-chosen name contains "__" but is not file-scoped — no `<` prefix, no transformation
3065+
[InlineData("Foo__Bar", "Foo__Bar")]
3066+
// Generic type — existing logic still strips generics from the tail
3067+
[InlineData("MyType<int>", "MyType")]
3068+
// Generic type whose name contains "__"
3069+
[InlineData("Foo__Bar<int>", "Foo__Bar")]
3070+
public void ShortName_unchanged_for_regular_types(string clrName, string expectedShortName)
3071+
{
3072+
var model = CreateModel();
3073+
3074+
var assemblyName = new AssemblyName("DynamicEntityClrTypeAssembly_Regular");
3075+
var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.Run);
3076+
var moduleBuilder = assemblyBuilder.DefineDynamicModule("MyModule");
3077+
var typeBuilder = moduleBuilder.DefineType(clrName);
3078+
var type = typeBuilder.CreateType();
3079+
3080+
model.AddEntityType(type);
3081+
3082+
var entityType = model.FinalizeModel().FindEntityType(clrName);
3083+
3084+
Assert.Equal(expectedShortName, entityType.ShortName());
3085+
}
3086+
3087+
[ConditionalTheory]
3088+
// file class MyEntity in Program.cs
3089+
[InlineData("<Program>F1234ABCD__MyEntity", "MyEntity")]
3090+
// file class declared in a file whose name itself contains "__"
3091+
[InlineData("<My__File>F1234ABCD__MyEntity", "MyEntity")]
3092+
// file class whose user-chosen name contains "__"
3093+
[InlineData("<Program>F1234ABCD__Foo__Bar", "Foo__Bar")]
3094+
// file class with single generic type parameter — DisplayName preserves generics (unlike ShortName)
3095+
[InlineData("<Program>F1234ABCD__MyEntity<int>", "MyEntity<int>")]
3096+
// file class with nested generics
3097+
[InlineData("<Program>F1234ABCD__Wrapper<List<int>>", "Wrapper<List<int>>")]
3098+
// file class used as a generic argument inside another type — sentinel must be stripped from the inner position too
3099+
[InlineData("List<<Program>F1234ABCD__MyFileClass>", "List<MyFileClass>")]
3100+
// generic of generic, with a file-scoped type at the inner-inner position
3101+
[InlineData("List<List<<Program>F1234ABCD__Inner>>", "List<List<Inner>>")]
3102+
// short hex digest (Roslyn varies digest length)
3103+
[InlineData("<Program>F12__Foo", "Foo")]
3104+
// long hex digest
3105+
[InlineData("<Program>FABCDEF1234567890__Foo", "Foo")]
3106+
// file class whose user-chosen name starts with an underscore
3107+
[InlineData("<Program>F1234ABCD___Underscored", "_Underscored")]
3108+
public void DisplayName_on_file_scoped_type(string clrName, string expectedDisplayName)
3109+
{
3110+
var model = CreateModel();
3111+
3112+
var assemblyName = new AssemblyName("DynamicEntityClrTypeAssembly_FileScopedDisplay");
3113+
var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.Run);
3114+
var moduleBuilder = assemblyBuilder.DefineDynamicModule("MyModule");
3115+
var typeBuilder = moduleBuilder.DefineType(clrName);
3116+
var type = typeBuilder.CreateType();
3117+
3118+
model.AddEntityType(type);
3119+
3120+
var entityType = model.FinalizeModel().FindEntityType(clrName);
3121+
3122+
Assert.Equal(expectedDisplayName, entityType.DisplayName());
3123+
}
3124+
3125+
[ConditionalTheory]
3126+
// Regular type — no `<` prefix, no transformation
3127+
[InlineData("Foo", "Foo")]
3128+
// Type whose user-chosen name contains "__" but is not file-scoped
3129+
[InlineData("Foo__Bar", "Foo__Bar")]
3130+
// Generic type — DisplayName preserves generics (unlike ShortName)
3131+
[InlineData("MyType<int>", "MyType<int>")]
3132+
// Generic type whose name contains "__"
3133+
[InlineData("Foo__Bar<int>", "Foo__Bar<int>")]
3134+
// Anonymous-style synthesized name (`<>`) — not file-scoped, untouched by file-scoped branch
3135+
[InlineData("<>__AnonymousType01Child", "<>__AnonymousType01Child")]
3136+
// Closure display class — `<>c` prefix, not `>F`, untouched
3137+
[InlineData("<>c__DisplayClass0_0", "<>c__DisplayClass0_0")]
3138+
// Async state machine — `>d` signature, not `>F`, untouched
3139+
[InlineData("<MyMethod>d__0", "<MyMethod>d__0")]
3140+
// Local function — `>g` signature, not `>F`, untouched
3141+
[InlineData("<MyMethod>g__Local|0_0", "<MyMethod>g__Local|0_0")]
3142+
// Lowercase `f` — Roslyn anonymous-type marker, not file-scoped (`F` is uppercase). Untouched.
3143+
[InlineData("<Program>f1234__NotFileScoped", "<Program>f1234__NotFileScoped")]
3144+
public void DisplayName_unchanged_for_non_file_scoped_types(string clrName, string expectedDisplayName)
3145+
{
3146+
var model = CreateModel();
3147+
3148+
var assemblyName = new AssemblyName("DynamicEntityClrTypeAssembly_DisplayRegular");
3149+
var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.Run);
3150+
var moduleBuilder = assemblyBuilder.DefineDynamicModule("MyModule");
3151+
var typeBuilder = moduleBuilder.DefineType(clrName);
3152+
var type = typeBuilder.CreateType();
3153+
3154+
model.AddEntityType(type);
3155+
3156+
var entityType = model.FinalizeModel().FindEntityType(clrName);
3157+
3158+
Assert.Equal(expectedDisplayName, entityType.DisplayName());
3159+
}
3160+
3161+
[ConditionalTheory]
3162+
// Has `>F` signature but no `__` separator — file-scoped sentinel is incomplete, leave alone
3163+
[InlineData("<Program>F1234ABCD", "<Program>F1234ABCD")]
3164+
// `<` but no closing `>` — incomplete sentinel, leave alone
3165+
[InlineData("<NoClose", "<NoClose")]
3166+
// Empty user portion after `__` — malformed Roslyn output, but ensure we don't crash;
3167+
// current behavior strips to empty (matches ShortName behavior; harmless edge case)
3168+
[InlineData("<Program>F1234ABCD__", "")]
3169+
// Just `<>` — bounds check `closeAngle + 1 < name.Length` rejects this; no char follows the `>`.
3170+
[InlineData("<>", "<>")]
3171+
public void DisplayName_handles_malformed_or_incomplete_file_scoped_inputs_safely(string clrName, string expectedDisplayName)
3172+
{
3173+
var model = CreateModel();
3174+
3175+
var assemblyName = new AssemblyName("DynamicEntityClrTypeAssembly_DisplayMalformed");
3176+
var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.Run);
3177+
var moduleBuilder = assemblyBuilder.DefineDynamicModule("MyModule");
3178+
var typeBuilder = moduleBuilder.DefineType(clrName);
3179+
var type = typeBuilder.CreateType();
3180+
3181+
model.AddEntityType(type);
3182+
3183+
var entityType = model.FinalizeModel().FindEntityType(clrName);
3184+
3185+
Assert.Equal(expectedDisplayName, entityType.DisplayName());
3186+
}
3187+
3188+
[ConditionalFact]
3189+
public void DisplayName_unchanged_for_well_known_types()
3190+
{
3191+
Assert.Equal("EntityTypeTest", CreateModel().AddEntityType(typeof(EntityTypeTest)).DisplayName());
3192+
Assert.Equal("Customer", CreateModel().AddEntityType(typeof(Customer)).DisplayName());
3193+
Assert.Equal("List<Customer>", CreateModel().AddEntityType(typeof(List<Customer>)).DisplayName());
3194+
}
3195+
30353196
private readonly IMutableModel _model = BuildModel();
30363197

30373198
private IMutableEntityType DependentType

0 commit comments

Comments
 (0)