From 375d5de553ab289649ab41306dee3a1cbe230038 Mon Sep 17 00:00:00 2001 From: Ryan Hurey Date: Mon, 6 Jul 2026 15:57:28 -0700 Subject: [PATCH 1/2] Fix runtime deserialization failure for polymorphic models with referenced extensible-enum discriminators --- .../MrwSerializationTypeDefinitionTests.cs | 34 +++++++++++ .../src/Primitives/CSharpType.cs | 33 +++++++++- .../src/TypeFactory.cs | 18 +++++- .../test/TypeFactoryTests.cs | 61 +++++++++++++++++++ 4 files changed, 144 insertions(+), 2 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/MrwSerializationTypeDefinitions/MrwSerializationTypeDefinitionTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/MrwSerializationTypeDefinitions/MrwSerializationTypeDefinitionTests.cs index f0bcaec4c43..9152bd5e077 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/MrwSerializationTypeDefinitions/MrwSerializationTypeDefinitionTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/MrwSerializationTypeDefinitions/MrwSerializationTypeDefinitionTests.cs @@ -662,6 +662,40 @@ public void GetUtf8BytesIsUsedForMrwFallback() Assert.AreEqual(Helpers.GetExpectedFromFile(), methodBody); } + [Test] + public void ReferencedExtensibleEnumUsesInlineConstruction() + { + // A property typed as a referenced extensible enum (a value-type struct) must deserialize via + // inline construction, not the generic ModelReaderWriter.Read fallback (which throws at + // runtime because the struct has no MRW builder). + var externalEnum = InputFactory.StringEnum( + "ExternalKind", + [("value1", "value1"), ("value2", "value2")], + isExtensible: true, + external: new InputExternalTypeMetadata("System.Guid", null, null)); + var property = InputFactory.Property("kind", externalEnum); + + var inputModel = InputFactory.Model("mockInputModel", properties: [property]); + var (_, serialization) = CreateModelAndSerialization(inputModel); + + var deserializationMethod = serialization.Methods.Single(m => m.Signature.Name.StartsWith("Deserialize")); + var methodBody = deserializationMethod.BodyStatements!.ToDisplayString(); + + Assert.IsFalse( + methodBody.Contains("ModelReaderWriter.Read"), + "Referenced extensible enum should not use the ModelReaderWriter.Read fallback."); + Assert.IsTrue( + methodBody.Contains("new global::System.Guid("), + "Referenced extensible enum should be constructed inline from its underlying value."); + + // Serialization must use the enum's underlying value (ToString for a string-backed enum), + // not a model write. + var writeBody = serialization.BuildJsonModelWriteCoreMethod().BodyStatements!.ToDisplayString(); + Assert.IsTrue( + writeBody.Contains("WriteStringValue(") && writeBody.Contains("ToString()"), + "Referenced extensible enum should serialize via its underlying string value."); + } + [Test] public void TestBuildDeserializationMethodNestedSARD() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/CSharpType.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/CSharpType.cs index 77d4f85a882..f86333adeb0 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/CSharpType.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/CSharpType.cs @@ -19,7 +19,7 @@ public class CSharpType { private readonly Type? _type; private object? _literal; - private readonly Type? _underlyingType; + private Type? _underlyingType; private IReadOnlyList? _unionItemTypes; private bool? _isReadOnlyMemory; @@ -549,6 +549,37 @@ public CSharpType WithNullable(bool isNullable) ? new CSharpType(FrameworkType, Arguments, isNullable) : new CSharpType(Name, Namespace, IsValueType, isNullable, DeclaringType, Arguments, IsPublic, IsStruct, BaseType, _underlyingType); + // Preserve explicit enum semantics for framework types (e.g. referenced extensible enums, + // which are structs and are not recognized as enums via reflection). The framework constructor + // recomputes the underlying type from reflection and would otherwise drop it. + if (!ReferenceEquals(type, this) && IsFrameworkType && _underlyingType is not null) + { + type._underlyingType = _underlyingType; + } + + type._literal = _literal; + type._unionItemTypes = _unionItemTypes; + + return type; + } + + /// + /// Returns a framework-backed copy of this that carries explicit enum + /// semantics. This is used for referenced (external) extensible enums, which are implemented as + /// value-type structs and therefore are not recognized as enums via reflection + /// ( is false). Preserving the underlying enum type lets downstream + /// serialization treat the type as an enum (inline construction) instead of falling back to a model read. + /// + /// The underlying value type of the enum (e.g. or ). + internal CSharpType WithUnderlyingEnumType(Type underlyingEnumType) + { + if (!IsFrameworkType) + { + throw new InvalidOperationException("WithUnderlyingEnumType is only valid for framework types."); + } + + var type = new CSharpType(FrameworkType, Arguments, IsNullable); + type._underlyingType = underlyingEnumType; type._literal = _literal; type._unionItemTypes = _unionItemTypes; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/TypeFactory.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/TypeFactory.cs index cd70e60ea9a..92bbd77873d 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/TypeFactory.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/TypeFactory.cs @@ -74,7 +74,23 @@ protected internal TypeFactory() // Check if this type has external type information if (inputType.External != null) { - return CreateExternalType(inputType.External); + var externalType = CreateExternalType(inputType.External); + + // A referenced extensible enum is implemented as a value-type struct, so the resolved + // framework type is not recognized as an enum via reflection and loses its enum semantics. + // Rebuild it preserving the underlying enum type so downstream serialization emits inline + // construction (e.g. new Kind(value)) instead of a broken ModelReaderWriter.Read fallback. + if (externalType is { IsFrameworkType: true } + && inputType is InputEnumType { IsExtensible: true } externalEnum) + { + var underlyingType = CreateCSharpType(externalEnum.ValueType); + if (underlyingType is { IsFrameworkType: true }) + { + externalType = externalType.WithUnderlyingEnumType(underlyingType.FrameworkType); + } + } + + return externalType; } CSharpType? type; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/TypeFactoryTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/TypeFactoryTests.cs index ba996fe3016..252b7353307 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/TypeFactoryTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/TypeFactoryTests.cs @@ -379,6 +379,67 @@ public void CreateCSharpType_ExternalEnum_ResolvesToExternalFrameworkType() Assert.AreEqual(typeof(Uri), type.FrameworkType); } + [Test] + public void CreateCSharpType_ExternalExtensibleStringEnum_PreservesEnumSemantics() + { + // A referenced extensible enum is implemented as a value-type struct, so reflection does not + // report it as an enum. The resolved external type must still carry enum semantics (underlying + // type + struct) so serialization emits inline construction instead of a broken model read. + var input = InputFactory.StringEnum( + "ExternalKind", + [("value1", "value1"), ("value2", "value2")], + usage: InputModelTypeUsage.Input, + isExtensible: true, + external: new InputExternalTypeMetadata("System.Guid", null, null)); + + var type = CodeModelGenerator.Instance.TypeFactory.CreateCSharpType(input); + + Assert.IsNotNull(type); + Assert.IsTrue(type!.IsFrameworkType); + Assert.AreEqual(typeof(Guid), type.FrameworkType); + Assert.IsTrue(type.IsEnum); + Assert.IsTrue(type.IsStruct); + Assert.AreEqual(typeof(string), type.UnderlyingEnumType); + } + + [Test] + public void CreateCSharpType_ExternalExtensibleInt32Enum_PreservesEnumSemantics() + { + var input = InputFactory.Int32Enum( + "ExternalKind", + [("value1", 1), ("value2", 2)], + usage: InputModelTypeUsage.Input, + isExtensible: true, + external: new InputExternalTypeMetadata("System.Guid", null, null)); + + var type = CodeModelGenerator.Instance.TypeFactory.CreateCSharpType(input); + + Assert.IsNotNull(type); + Assert.IsTrue(type!.IsEnum); + Assert.IsTrue(type.IsStruct); + Assert.AreEqual(typeof(int), type.UnderlyingEnumType); + } + + [Test] + public void CreateCSharpType_ExternalFixedEnum_DoesNotForceEnumSemantics() + { + // Fixed (non-extensible) external enums are left untouched; they resolve to their external + // framework type as-is (which, for a real .NET enum, already reports enum semantics). + var input = InputFactory.StringEnum( + "ExternalKind", + [("value1", "value1"), ("value2", "value2")], + usage: InputModelTypeUsage.Input, + isExtensible: false, + external: new InputExternalTypeMetadata("System.Uri", null, null)); + + var type = CodeModelGenerator.Instance.TypeFactory.CreateCSharpType(input); + + Assert.IsNotNull(type); + Assert.IsTrue(type!.IsFrameworkType); + Assert.AreEqual(typeof(Uri), type.FrameworkType); + Assert.IsFalse(type.IsEnum); + } + [Test] public void CreateCSharpType_SelfReferencingModel_DoesNotThrow() { From 112dd3a9796cd16c77a7ec22bbe034c1669a0b85 Mon Sep 17 00:00:00 2001 From: Ryan Hurey Date: Tue, 7 Jul 2026 21:00:34 -0700 Subject: [PATCH 2/2] PR feedback --- .../src/Primitives/CSharpType.cs | 2 +- .../src/TypeFactory.cs | 60 +++++++++---------- .../test/TypeFactoryTests.cs | 1 + 3 files changed, 30 insertions(+), 33 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/CSharpType.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/CSharpType.cs index f86333adeb0..0678df21108 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/CSharpType.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/CSharpType.cs @@ -552,7 +552,7 @@ public CSharpType WithNullable(bool isNullable) // Preserve explicit enum semantics for framework types (e.g. referenced extensible enums, // which are structs and are not recognized as enums via reflection). The framework constructor // recomputes the underlying type from reflection and would otherwise drop it. - if (!ReferenceEquals(type, this) && IsFrameworkType && _underlyingType is not null) + if (IsFrameworkType && _underlyingType is not null) { type._underlyingType = _underlyingType; } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/TypeFactory.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/TypeFactory.cs index 92bbd77873d..07065943b7b 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/TypeFactory.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/TypeFactory.cs @@ -74,23 +74,7 @@ protected internal TypeFactory() // Check if this type has external type information if (inputType.External != null) { - var externalType = CreateExternalType(inputType.External); - - // A referenced extensible enum is implemented as a value-type struct, so the resolved - // framework type is not recognized as an enum via reflection and loses its enum semantics. - // Rebuild it preserving the underlying enum type so downstream serialization emits inline - // construction (e.g. new Kind(value)) instead of a broken ModelReaderWriter.Read fallback. - if (externalType is { IsFrameworkType: true } - && inputType is InputEnumType { IsExtensible: true } externalEnum) - { - var underlyingType = CreateCSharpType(externalEnum.ValueType); - if (underlyingType is { IsFrameworkType: true }) - { - externalType = externalType.WithUnderlyingEnumType(underlyingType.FrameworkType); - } - } - - return externalType; + return CreateExternalType(inputType.External, inputType); } CSharpType? type; @@ -338,30 +322,42 @@ protected virtual ModelFactoryProvider CreateModelFactoryCore(IEnumerable based on external type properties. /// /// The to convert. + /// The originating , when available, used to preserve + /// semantics (such as extensible-enum backing) that reflection alone cannot recover from the resolved type. /// A representing the external type, or null if the type cannot be resolved. - private CSharpType? CreateExternalType(InputExternalTypeMetadata externalProperties) + private CSharpType? CreateExternalType(InputExternalTypeMetadata externalProperties, InputType? inputType = null) { - // 1. Try to create a framework type from the fully qualified name. This stays as the - // first attempt because it's free (no I/O) and is the source of truth for BCL types. - var frameworkType = CreateFrameworkType(externalProperties.Identity); - if (frameworkType != null) - { - return new CSharpType(frameworkType); + // Resolve the type: first as a framework type from the fully qualified name (free, no I/O, and the + // source of truth for BCL types), then, on a miss, dynamically from the NuGet package named in the + // metadata. ExternalTypeReferenceResolver consults a process-wide cache populated by the eager + // pre-walk in CSharpGen.ExecuteAsync, resolving on-demand when the cache misses. + var resolvedType = CreateFrameworkType(externalProperties.Identity); + if (resolvedType == null && !string.IsNullOrEmpty(externalProperties.Package)) + { + resolvedType = ExternalTypeReferenceResolver.TryResolve(externalProperties); } - // 2. Fallback: dynamically resolve the type from the NuGet package named in the metadata. - // ExternalTypeReferenceResolver consults a process-wide cache populated by the eager - // pre-walk in CSharpGen.ExecuteAsync; on a miss it resolves on-demand. - if (!string.IsNullOrEmpty(externalProperties.Package)) + if (resolvedType != null) { - var resolvedType = ExternalTypeReferenceResolver.TryResolve(externalProperties); - if (resolvedType != null) + var externalType = new CSharpType(resolvedType); + + // A referenced extensible enum is implemented as a value-type struct, so the resolved + // framework type is not recognized as an enum via reflection and loses its enum semantics. + // Rebuild it preserving the underlying enum type so downstream serialization emits inline + // construction (e.g. new Kind(value)) instead of a broken ModelReaderWriter.Read fallback. + if (inputType is InputEnumType { IsExtensible: true } externalEnum) { - return new CSharpType(resolvedType); + var underlyingType = CreateCSharpType(externalEnum.ValueType); + if (underlyingType is { IsFrameworkType: true }) + { + externalType = externalType.WithUnderlyingEnumType(underlyingType.FrameworkType); + } } + + return externalType; } - // 3. Neither path worked — emit a diagnostic that explains what was attempted. + // Neither path resolved the type — emit a diagnostic that explains what was attempted. // Each branch is a self-contained sentence so the final message reads naturally and // doesn't repeat "could not be resolved". var details = string.IsNullOrEmpty(externalProperties.Package) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/TypeFactoryTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/TypeFactoryTests.cs index 252b7353307..1a57a7029a3 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/TypeFactoryTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/TypeFactoryTests.cs @@ -438,6 +438,7 @@ public void CreateCSharpType_ExternalFixedEnum_DoesNotForceEnumSemantics() Assert.IsTrue(type!.IsFrameworkType); Assert.AreEqual(typeof(Uri), type.FrameworkType); Assert.IsFalse(type.IsEnum); + Assert.Throws(() => _ = type.UnderlyingEnumType); } [Test]