diff --git a/src/EFCore/Metadata/IReadOnlyTypeBase.cs b/src/EFCore/Metadata/IReadOnlyTypeBase.cs index 36def55e87e..034cacd0050 100644 --- a/src/EFCore/Metadata/IReadOnlyTypeBase.cs +++ b/src/EFCore/Metadata/IReadOnlyTypeBase.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text; using Microsoft.EntityFrameworkCore.Metadata.Internal; namespace Microsoft.EntityFrameworkCore.Metadata; @@ -86,14 +87,16 @@ string DisplayName(bool omitSharedType) { if (!HasSharedClrType) { - return ClrType.ShortDisplayName(); + return StripFileScopedTypePrefixes(ClrType.ShortDisplayName()); } + var clrTypeDisplayName = StripFileScopedTypePrefixes(ClrType.ShortDisplayName()); + var shortName = Name; var hashIndex = shortName.IndexOf("#", StringComparison.Ordinal); if (hashIndex == -1) { - return Name + " (" + ClrType.ShortDisplayName() + ")"; + return Name + " (" + clrTypeDisplayName + ")"; } var plusIndex = shortName.LastIndexOf("+", StringComparison.Ordinal); @@ -115,7 +118,7 @@ string DisplayName(bool omitSharedType) } return shortName == Name - ? shortName + " (" + ClrType.ShortDisplayName() + ")" + ? shortName + " (" + clrTypeDisplayName + ")" : shortName; } @@ -131,16 +134,20 @@ string ShortName() var name = ClrType.ShortDisplayName(); if (name.StartsWith("<>", StringComparison.Ordinal)) { + // Anonymous and closure types: <>f__AnonymousType0, <>c__DisplayClass0_0, ... name = name[2..]; } - - var lessIndex = name.IndexOf("<", StringComparison.Ordinal); - if (lessIndex == -1) + else { - return name; + // File-scoped types: Roslyn synthesizes the metadata name + // F__UserTypeName for `file class` / `file record` declarations. + // Strip these sentinels wherever they appear (top-level or nested in generic args), + // so e.g. List<F1234__Inner> becomes List. + name = StripFileScopedTypePrefixes(name); } - return name[..lessIndex]; + var lessIndex = name.IndexOf('<', StringComparison.Ordinal); + return lessIndex == -1 ? name : name[..lessIndex]; } var hashIndex = Name.LastIndexOf("#", StringComparison.Ordinal); @@ -161,6 +168,64 @@ string ShortName() return Name[(hashIndex + 1)..]; } + /// + /// Strips Roslyn's synthesized file-scoped type prefix (<FileName>F<hex>__) + /// from a CLR display name, including occurrences nested inside generic argument lists. + /// For example List<<Program>F1234__Inner> becomes List<Inner>. + /// + /// + /// The >F signature distinguishes file-scoped types from other compiler-generated + /// types whose names begin with < (async state machines <Method>d__0, + /// local function host classes <Method>g__Local|0_0, anonymous types + /// <>f__AnonymousType, closure display classes <>c__DisplayClass). + /// Roslyn's synthesized metadata pattern uses the literal <filename>F<hex>__ + /// shape; the filename portion does not contain <, so the closing > of a + /// sentinel is always the next > after the opening < with no + /// intervening <. + /// + private static string StripFileScopedTypePrefixes(string name) + { + if (name.IndexOf('<', StringComparison.Ordinal) == -1) + { + return name; + } + + StringBuilder? sb = null; + var i = 0; + while (i < name.Length) + { + if (name[i] == '<') + { + // Look for the immediately-following `>F__` sentinel: + // - the next `>` must come without any nested `<` in between (filenames have neither) + // - the char right after `>` must be `F` + // - a `__` must follow (the prefix terminator) + var closeAngle = name.IndexOf('>', i + 1); + if (closeAngle != -1 + && closeAngle + 1 < name.Length + && name[closeAngle + 1] == 'F') + { + var nestedLt = name.IndexOf('<', i + 1, closeAngle - i - 1); + if (nestedLt == -1) + { + var separator = name.IndexOf("__", closeAngle + 1, StringComparison.Ordinal); + if (separator != -1) + { + sb ??= new StringBuilder(name.Length).Append(name, 0, i); + i = separator + 2; + continue; + } + } + } + } + + sb?.Append(name[i]); + i++; + } + + return sb?.ToString() ?? name; + } + /// /// Determines whether the current type can be assigned to the specified type, i.e. is derived from or identical to it. /// diff --git a/test/EFCore.Tests/Metadata/Internal/EntityTypeTest.cs b/test/EFCore.Tests/Metadata/Internal/EntityTypeTest.cs index d2669fa1be2..baa296d820e 100644 --- a/test/EFCore.Tests/Metadata/Internal/EntityTypeTest.cs +++ b/test/EFCore.Tests/Metadata/Internal/EntityTypeTest.cs @@ -3032,6 +3032,167 @@ public void ShortName_on_compiler_generated_type3() Assert.Equal("__AnonymousType01Child", entityType.ShortName()); } + [ConditionalTheory] + // file class MyEntity in Program.cs + [InlineData("F1234ABCD__MyEntity", "MyEntity")] + // file class declared in a file whose name itself contains "__" + [InlineData("F1234ABCD__MyEntity", "MyEntity")] + // file class whose user-chosen name contains "__" + [InlineData("F1234ABCD__Foo__Bar", "Foo__Bar")] + // file class with generic type parameters + [InlineData("F1234ABCD__MyEntity", "MyEntity")] + public void ShortName_on_file_scoped_type(string clrName, string expectedShortName) + { + var model = CreateModel(); + + var assemblyName = new AssemblyName("DynamicEntityClrTypeAssembly_FileScoped"); + var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.Run); + var moduleBuilder = assemblyBuilder.DefineDynamicModule("MyModule"); + var typeBuilder = moduleBuilder.DefineType(clrName); + var type = typeBuilder.CreateType(); + + model.AddEntityType(type); + + var entityType = model.FinalizeModel().FindEntityType(clrName); + + Assert.Equal(expectedShortName, entityType.ShortName()); + } + + [ConditionalTheory] + // Regular type — no `<` prefix, no transformation + [InlineData("Foo", "Foo")] + // Type whose user-chosen name contains "__" but is not file-scoped — no `<` prefix, no transformation + [InlineData("Foo__Bar", "Foo__Bar")] + // Generic type — existing logic still strips generics from the tail + [InlineData("MyType", "MyType")] + // Generic type whose name contains "__" + [InlineData("Foo__Bar", "Foo__Bar")] + public void ShortName_unchanged_for_regular_types(string clrName, string expectedShortName) + { + var model = CreateModel(); + + var assemblyName = new AssemblyName("DynamicEntityClrTypeAssembly_Regular"); + var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.Run); + var moduleBuilder = assemblyBuilder.DefineDynamicModule("MyModule"); + var typeBuilder = moduleBuilder.DefineType(clrName); + var type = typeBuilder.CreateType(); + + model.AddEntityType(type); + + var entityType = model.FinalizeModel().FindEntityType(clrName); + + Assert.Equal(expectedShortName, entityType.ShortName()); + } + + [ConditionalTheory] + // file class MyEntity in Program.cs + [InlineData("F1234ABCD__MyEntity", "MyEntity")] + // file class declared in a file whose name itself contains "__" + [InlineData("F1234ABCD__MyEntity", "MyEntity")] + // file class whose user-chosen name contains "__" + [InlineData("F1234ABCD__Foo__Bar", "Foo__Bar")] + // file class with single generic type parameter — DisplayName preserves generics (unlike ShortName) + [InlineData("F1234ABCD__MyEntity", "MyEntity")] + // file class with nested generics + [InlineData("F1234ABCD__Wrapper>", "Wrapper>")] + // file class used as a generic argument inside another type — sentinel must be stripped from the inner position too + [InlineData("List<F1234ABCD__MyFileClass>", "List")] + // generic of generic, with a file-scoped type at the inner-inner position + [InlineData("ListF1234ABCD__Inner>>", "List>")] + // short hex digest (Roslyn varies digest length) + [InlineData("F12__Foo", "Foo")] + // long hex digest + [InlineData("FABCDEF1234567890__Foo", "Foo")] + // file class whose user-chosen name starts with an underscore + [InlineData("F1234ABCD___Underscored", "_Underscored")] + public void DisplayName_on_file_scoped_type(string clrName, string expectedDisplayName) + { + var model = CreateModel(); + + var assemblyName = new AssemblyName("DynamicEntityClrTypeAssembly_FileScopedDisplay"); + var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.Run); + var moduleBuilder = assemblyBuilder.DefineDynamicModule("MyModule"); + var typeBuilder = moduleBuilder.DefineType(clrName); + var type = typeBuilder.CreateType(); + + model.AddEntityType(type); + + var entityType = model.FinalizeModel().FindEntityType(clrName); + + Assert.Equal(expectedDisplayName, entityType.DisplayName()); + } + + [ConditionalTheory] + // Regular type — no `<` prefix, no transformation + [InlineData("Foo", "Foo")] + // Type whose user-chosen name contains "__" but is not file-scoped + [InlineData("Foo__Bar", "Foo__Bar")] + // Generic type — DisplayName preserves generics (unlike ShortName) + [InlineData("MyType", "MyType")] + // Generic type whose name contains "__" + [InlineData("Foo__Bar", "Foo__Bar")] + // Anonymous-style synthesized name (`<>`) — not file-scoped, untouched by file-scoped branch + [InlineData("<>__AnonymousType01Child", "<>__AnonymousType01Child")] + // Closure display class — `<>c` prefix, not `>F`, untouched + [InlineData("<>c__DisplayClass0_0", "<>c__DisplayClass0_0")] + // Async state machine — `>d` signature, not `>F`, untouched + [InlineData("d__0", "d__0")] + // Local function — `>g` signature, not `>F`, untouched + [InlineData("g__Local|0_0", "g__Local|0_0")] + // Lowercase `f` — Roslyn anonymous-type marker, not file-scoped (`F` is uppercase). Untouched. + [InlineData("f1234__NotFileScoped", "f1234__NotFileScoped")] + public void DisplayName_unchanged_for_non_file_scoped_types(string clrName, string expectedDisplayName) + { + var model = CreateModel(); + + var assemblyName = new AssemblyName("DynamicEntityClrTypeAssembly_DisplayRegular"); + var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.Run); + var moduleBuilder = assemblyBuilder.DefineDynamicModule("MyModule"); + var typeBuilder = moduleBuilder.DefineType(clrName); + var type = typeBuilder.CreateType(); + + model.AddEntityType(type); + + var entityType = model.FinalizeModel().FindEntityType(clrName); + + Assert.Equal(expectedDisplayName, entityType.DisplayName()); + } + + [ConditionalTheory] + // Has `>F` signature but no `__` separator — file-scoped sentinel is incomplete, leave alone + [InlineData("F1234ABCD", "F1234ABCD")] + // `<` but no closing `>` — incomplete sentinel, leave alone + [InlineData("F1234ABCD__", "")] + // Just `<>` — bounds check `closeAngle + 1 < name.Length` rejects this; no char follows the `>`. + [InlineData("<>", "<>")] + public void DisplayName_handles_malformed_or_incomplete_file_scoped_inputs_safely(string clrName, string expectedDisplayName) + { + var model = CreateModel(); + + var assemblyName = new AssemblyName("DynamicEntityClrTypeAssembly_DisplayMalformed"); + var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.Run); + var moduleBuilder = assemblyBuilder.DefineDynamicModule("MyModule"); + var typeBuilder = moduleBuilder.DefineType(clrName); + var type = typeBuilder.CreateType(); + + model.AddEntityType(type); + + var entityType = model.FinalizeModel().FindEntityType(clrName); + + Assert.Equal(expectedDisplayName, entityType.DisplayName()); + } + + [ConditionalFact] + public void DisplayName_unchanged_for_well_known_types() + { + Assert.Equal("EntityTypeTest", CreateModel().AddEntityType(typeof(EntityTypeTest)).DisplayName()); + Assert.Equal("Customer", CreateModel().AddEntityType(typeof(Customer)).DisplayName()); + Assert.Equal("List", CreateModel().AddEntityType(typeof(List)).DisplayName()); + } + private readonly IMutableModel _model = BuildModel(); private IMutableEntityType DependentType