From 07e3047effc5d7a1a6bfb5e94a17f3b242dd2d93 Mon Sep 17 00:00:00 2001 From: Jorge Rangel Date: Wed, 2 Sep 2026 14:49:53 -0500 Subject: [PATCH 1/6] fix(http-client-csharp): preserve union metadata when back compat preserves a property type Last contract types are Roslyn-backed and carry no TypeSpec union metadata, so preserving one dropped the union item types of the current type. That made the union variant models look unreferenced, so they were removed from the output and their doc references degraded to plain text. Restore the union item types from the current type onto the preserved type, including through list and dictionary element types. The emitted C# is unchanged because a union is always written as BinaryData. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5c237fe4-3c18-45b7-82b3-21b41cb1a714 --- .../src/Providers/ModelProvider.cs | 11 ++- .../src/Utilities/CSharpTypeExtensions.cs | 49 ++++++++++ .../ModelProviders/ModelProviderTests.cs | 90 +++++++++++++++++++ .../MockInputModel.cs | 11 +++ .../MockInputModel.cs | 7 ++ .../generator/docs/backward-compatibility.md | 8 ++ 6 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_UnionCollectionPropertiesRetainUnionItemTypes/MockInputModel.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_UnionPropertyReplacedWithNonBinaryDataTypeDropsUnionItemTypes/MockInputModel.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs index 413fa243eb2..a83cb383199 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs @@ -778,9 +778,14 @@ private static CSharpType GetPropertyTypeForBackCompatibility( InputProperty inputProperty) { var compatibleType = lastContractType.ApplyInputSpecProperty(inputProperty); - return !compatibleType.IsValueType && currentType.IsNullable - ? compatibleType.WithNullable(true) - : compatibleType; + if (!compatibleType.IsValueType && currentType.IsNullable) + { + compatibleType = compatibleType.WithNullable(true); + } + + // Last-contract types are Roslyn-backed and carry no union metadata. Restore it from the current + // TypeSpec type so the union variant models stay referenced and are not removed as unused. + return compatibleType.RestoreUnionItemTypes(currentType); } protected internal override ConstructorProvider[] BuildConstructors() diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs index ae2f51a6d14..38390cbdb0d 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System; using System.Diagnostics.CodeAnalysis; using Microsoft.TypeSpec.Generator.Input; using Microsoft.TypeSpec.Generator.Primitives; @@ -9,6 +10,54 @@ namespace Microsoft.TypeSpec.Generator.Utilities { internal static class CSharpTypeExtensions { + /// + /// Restores the union item metadata carried by onto . + /// + /// + /// Last-contract types are built from Roslyn symbols, which have no notion of a TypeSpec union: a union + /// property is just in metadata. When a last-contract type is preserved for + /// back compatibility, the union item types of the current TypeSpec type would otherwise be dropped. + /// That makes the union variant models appear unreferenced, so they get removed (or internalized) from + /// the output and their doc references degrade to plain text. Restoring the metadata keeps the emitted + /// C# identical while keeping the variant models reachable. + /// + public static CSharpType RestoreUnionItemTypes(this CSharpType type, CSharpType source) + { + if (type.IsUnion) + { + return type; + } + + if (source.IsUnion) + { + // A union is always represented as BinaryData, so the metadata can only be restored onto a + // preserved type that is also BinaryData. + return type.IsFrameworkType && type.FrameworkType == typeof(BinaryData) + ? CSharpType.FromUnion(source.UnionItemTypes, type.IsNullable, source.UnionItemTypeReferenceKind) + : type; + } + + if (!type.IsCollection || !source.IsCollection) + { + return type; + } + + var elementType = type.ElementType.RestoreUnionItemTypes(source.ElementType); + if (!elementType.IsUnion) + { + return type; + } + + if (type.IsList) + { + return new CSharpType(type.FrameworkType, [elementType], type.IsNullable); + } + + return type.IsDictionary + ? new CSharpType(type.FrameworkType, [type.Arguments[0], elementType], type.IsNullable) + : type; + } + public static CSharpType ApplyInputSpecProperty(this CSharpType type, InputProperty? specProperty) { if (type.IsCollection) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs index dba462ca6be..7b732826e86 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs @@ -22,6 +22,11 @@ namespace Microsoft.TypeSpec.Generator.Tests.Providers.ModelProviders { public class ModelProviderTests { + // BinaryData lives outside corlib, so the last-contract compilation needs an explicit reference + // for `BinaryData` in test assets to resolve to the framework type. + private static readonly Microsoft.CodeAnalysis.MetadataReference BinaryDataMetadataReference = + Microsoft.CodeAnalysis.MetadataReference.CreateFromFile(typeof(BinaryData).Assembly.Location); + [SetUp] public void Setup() { @@ -1412,6 +1417,91 @@ await MockHelpers.LoadMockGeneratorAsync( Assert.IsTrue(generatedCode.Contains("Items = items?.ToList();")); } + [Test] + public async Task BackCompat_UnionCollectionPropertiesRetainUnionItemTypes() + { + // The last contract types are Roslyn-backed and have no union metadata (a union is just + // BinaryData in metadata). When those types are preserved for back compatibility, the union + // item types from the current spec must be restored, otherwise the union variant models look + // unreferenced and get removed from the output. + var variantModel = InputFactory.Model( + "VariantModel", + properties: [InputFactory.Property("name", InputPrimitiveType.String)]); + var union = InputFactory.Union([InputPrimitiveType.String, variantModel]); + var inputModel = InputFactory.Model( + "MockInputModel", + properties: + [ + InputFactory.Property("items", InputFactory.Array(union)), + InputFactory.Property("moreItems", InputFactory.Dictionary(union)) + ]); + + await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [inputModel, variantModel], + additionalMetadataReferences: [BinaryDataMetadataReference], + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders.SingleOrDefault(t => t.Name == "MockInputModel") as ModelProvider; + Assert.IsNotNull(modelProvider); + + var itemsProperty = modelProvider!.Properties.FirstOrDefault(p => p.Name == "Items"); + Assert.IsNotNull(itemsProperty); + // The last contract shape is preserved. + Assert.AreEqual(typeof(IReadOnlyList<>), itemsProperty!.Type.FrameworkType); + Assert.AreEqual(typeof(BinaryData), itemsProperty.Type.ElementType.FrameworkType); + // The union metadata from the spec is restored onto the preserved type. + Assert.IsTrue(itemsProperty.Type.ElementType.IsUnion); + CollectionAssert.AreEquivalent( + new[] { "String", "VariantModel" }, + itemsProperty.Type.ElementType.UnionItemTypes.Select(t => t.Name).ToArray()); + + var moreItemsProperty = modelProvider.Properties.FirstOrDefault(p => p.Name == "MoreItems"); + Assert.IsNotNull(moreItemsProperty); + Assert.AreEqual(typeof(IReadOnlyDictionary<,>), moreItemsProperty!.Type.FrameworkType); + Assert.AreEqual(typeof(string), moreItemsProperty.Type.Arguments[0].FrameworkType); + Assert.IsTrue(moreItemsProperty.Type.ElementType.IsUnion); + CollectionAssert.AreEquivalent( + new[] { "String", "VariantModel" }, + moreItemsProperty.Type.ElementType.UnionItemTypes.Select(t => t.Name).ToArray()); + + // Restoring the union metadata must not change the emitted C#. + var generatedCode = new TypeProviderWriter(modelProvider).Write().Content; + Assert.IsTrue(generatedCode.Contains("IReadOnlyList Items")); + Assert.IsTrue(generatedCode.Contains("IReadOnlyDictionary MoreItems")); + } + + [Test] + public async Task BackCompat_UnionPropertyReplacedWithNonBinaryDataTypeDropsUnionItemTypes() + { + // A union is always represented as BinaryData, so when the preserved last contract type is + // something else the union metadata cannot be carried over. + var variantModel = InputFactory.Model( + "VariantModel", + properties: [InputFactory.Property("name", InputPrimitiveType.String)]); + var inputModel = InputFactory.Model( + "MockInputModel", + properties: + [ + InputFactory.Property( + "data", + InputFactory.Union([InputPrimitiveType.String, variantModel]), + isRequired: true) + ]); + + await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [inputModel, variantModel], + additionalMetadataReferences: [BinaryDataMetadataReference], + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders.SingleOrDefault(t => t.Name == "MockInputModel") as ModelProvider; + Assert.IsNotNull(modelProvider); + + var dataProperty = modelProvider!.Properties.FirstOrDefault(p => p.Name == "Data"); + Assert.IsNotNull(dataProperty); + Assert.IsTrue(dataProperty!.Type.Equals(typeof(object))); + Assert.IsFalse(dataProperty.Type.IsUnion); + } + [Test] public async Task BackCompat_ScalarPropertyTypeOverriddenWhenTypeNameDiffers() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_UnionCollectionPropertiesRetainUnionItemTypes/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_UnionCollectionPropertiesRetainUnionItemTypes/MockInputModel.cs new file mode 100644 index 00000000000..bcc495ba2fb --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_UnionCollectionPropertiesRetainUnionItemTypes/MockInputModel.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; + +namespace Sample.Models +{ + public partial class MockInputModel + { + public IReadOnlyList Items { get; } + public IReadOnlyDictionary MoreItems { get; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_UnionPropertyReplacedWithNonBinaryDataTypeDropsUnionItemTypes/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_UnionPropertyReplacedWithNonBinaryDataTypeDropsUnionItemTypes/MockInputModel.cs new file mode 100644 index 00000000000..1b33bde8623 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_UnionPropertyReplacedWithNonBinaryDataTypeDropsUnionItemTypes/MockInputModel.cs @@ -0,0 +1,7 @@ +namespace Sample.Models +{ + public partial class MockInputModel + { + public object Data { get; set; } + } +} diff --git a/packages/http-client-csharp/generator/docs/backward-compatibility.md b/packages/http-client-csharp/generator/docs/backward-compatibility.md index b339b05c226..e71a338a201 100644 --- a/packages/http-client-csharp/generator/docs/backward-compatibility.md +++ b/packages/http-client-csharp/generator/docs/backward-compatibility.md @@ -296,6 +296,14 @@ public int? Count { get; set; } A diagnostic message is logged for every overridden property: `"Changed property {ModelName}.{PropertyName} type to {LastContractType} to match last contract."` +#### Union Metadata Preservation + +Last contract types are read from Roslyn symbols, which have no notion of a TypeSpec union — a union property is just `BinaryData` in metadata. When a last contract type is preserved, the generator restores the union item types from the current TypeSpec type onto the preserved type (including through list and dictionary element types). + +This does not change the emitted C#, because a union is always written as `BinaryData`. It matters because the union item types are what keep the union variant models referenced. Without them the variant models look unused, so they are removed (or internalized) from the output and their documentation references degrade from `` to plain text. + +If the preserved type is not `BinaryData` — for example when the last contract used `object` — the union metadata cannot be carried over and the variant models are no longer referenced by that property. + ### AdditionalProperties Type Preservation The generator maintains backward compatibility for the `AdditionalProperties` property type on models that extend or use `Record`. From ca9fc481b5742419a0d5ef70a3eecb2c1ec4da4c Mon Sep 17 00:00:00 2001 From: Jorge Rangel Date: Wed, 2 Sep 2026 14:58:58 -0500 Subject: [PATCH 2/6] fix(http-client-csharp): rebuild containers whenever nested union metadata is restored Checking the immediate element for IsUnion dropped restored metadata for nested collections such as IReadOnlyList>, since the outer element is a collection rather than a union. Use reference identity to detect that anything below the container changed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5c237fe4-3c18-45b7-82b3-21b41cb1a714 --- .../src/Utilities/CSharpTypeExtensions.cs | 3 +- .../ModelProviders/ModelProviderTests.cs | 41 +++++++++++++++++++ .../MockInputModel.cs | 10 +++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_NestedUnionCollectionPropertyRetainsUnionItemTypes/MockInputModel.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs index 38390cbdb0d..8f8d4fa0a4b 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs @@ -43,8 +43,9 @@ public static CSharpType RestoreUnionItemTypes(this CSharpType type, CSharpType } var elementType = type.ElementType.RestoreUnionItemTypes(source.ElementType); - if (!elementType.IsUnion) + if (ReferenceEquals(elementType, type.ElementType)) { + // Nothing was restored anywhere in the element type, so the container is unchanged. return type; } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs index 7b732826e86..60b8702e650 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs @@ -1470,6 +1470,47 @@ await MockHelpers.LoadMockGeneratorAsync( Assert.IsTrue(generatedCode.Contains("IReadOnlyDictionary MoreItems")); } + [Test] + public async Task BackCompat_NestedUnionCollectionPropertyRetainsUnionItemTypes() + { + // Restoration must survive nesting: the outer element type is a collection, not a union, so the + // container has to be rebuilt whenever anything below it changed. + var variantModel = InputFactory.Model( + "VariantModel", + properties: [InputFactory.Property("name", InputPrimitiveType.String)]); + var union = InputFactory.Union([InputPrimitiveType.String, variantModel]); + var inputModel = InputFactory.Model( + "MockInputModel", + properties: + [ + InputFactory.Property("nestedItems", InputFactory.Array(InputFactory.Array(union))) + ]); + + await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [inputModel, variantModel], + additionalMetadataReferences: [BinaryDataMetadataReference], + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders.SingleOrDefault(t => t.Name == "MockInputModel") as ModelProvider; + Assert.IsNotNull(modelProvider); + + var nestedItemsProperty = modelProvider!.Properties.FirstOrDefault(p => p.Name == "NestedItems"); + Assert.IsNotNull(nestedItemsProperty); + // The last contract shape is preserved at both levels. + Assert.AreEqual(typeof(IReadOnlyList<>), nestedItemsProperty!.Type.FrameworkType); + Assert.AreEqual(typeof(IReadOnlyList<>), nestedItemsProperty.Type.ElementType.FrameworkType); + + var innerElementType = nestedItemsProperty.Type.ElementType.ElementType; + Assert.AreEqual(typeof(BinaryData), innerElementType.FrameworkType); + Assert.IsTrue(innerElementType.IsUnion); + CollectionAssert.AreEquivalent( + new[] { "String", "VariantModel" }, + innerElementType.UnionItemTypes.Select(t => t.Name).ToArray()); + + var generatedCode = new TypeProviderWriter(modelProvider).Write().Content; + Assert.IsTrue(generatedCode.Contains("IReadOnlyList> NestedItems")); + } + [Test] public async Task BackCompat_UnionPropertyReplacedWithNonBinaryDataTypeDropsUnionItemTypes() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_NestedUnionCollectionPropertyRetainsUnionItemTypes/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_NestedUnionCollectionPropertyRetainsUnionItemTypes/MockInputModel.cs new file mode 100644 index 00000000000..eb0658a2fff --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_NestedUnionCollectionPropertyRetainsUnionItemTypes/MockInputModel.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; + +namespace Sample.Models +{ + public partial class MockInputModel + { + public IReadOnlyList> NestedItems { get; } + } +} \ No newline at end of file From cc69f6812135d17b6f1b900a524d5490592a2894 Mon Sep 17 00:00:00 2001 From: Jorge Rangel Date: Wed, 2 Sep 2026 15:29:44 -0500 Subject: [PATCH 3/6] test(http-client-csharp): validate generated code from TestData and scope helper to internal - revert the backward-compatibility.md additions - make RestoreUnionItemTypes internal - compare generated output against TestData expected files with XML docs enabled, which captures the restored union item crefs directly Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5c237fe4-3c18-45b7-82b3-21b41cb1a714 --- .../src/Utilities/CSharpTypeExtensions.cs | 7 +- .../ModelProviders/ModelProviderTests.cs | 18 ++- ...CollectionPropertyRetainsUnionItemTypes.cs | 74 +++++++++++ ...ollectionPropertiesRetainUnionItemTypes.cs | 118 ++++++++++++++++++ ...ithNonBinaryDataTypeDropsUnionItemTypes.cs | 78 ++++++++++++ .../generator/docs/backward-compatibility.md | 8 -- 6 files changed, 286 insertions(+), 17 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_NestedUnionCollectionPropertyRetainsUnionItemTypes.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_UnionCollectionPropertiesRetainUnionItemTypes.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_UnionPropertyReplacedWithNonBinaryDataTypeDropsUnionItemTypes.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs index 8f8d4fa0a4b..e40216ef5ee 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs @@ -21,7 +21,7 @@ internal static class CSharpTypeExtensions /// the output and their doc references degrade to plain text. Restoring the metadata keeps the emitted /// C# identical while keeping the variant models reachable. /// - public static CSharpType RestoreUnionItemTypes(this CSharpType type, CSharpType source) + internal static CSharpType RestoreUnionItemTypes(this CSharpType type, CSharpType source) { if (type.IsUnion) { @@ -42,8 +42,9 @@ public static CSharpType RestoreUnionItemTypes(this CSharpType type, CSharpType return type; } - var elementType = type.ElementType.RestoreUnionItemTypes(source.ElementType); - if (ReferenceEquals(elementType, type.ElementType)) + var currentElementType = type.ElementType; + var elementType = currentElementType.RestoreUnionItemTypes(source.ElementType); + if (ReferenceEquals(elementType, currentElementType)) { // Nothing was restored anywhere in the element type, so the container is unchanged. return type; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs index 60b8702e650..51390181ad5 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs @@ -1439,6 +1439,7 @@ public async Task BackCompat_UnionCollectionPropertiesRetainUnionItemTypes() await MockHelpers.LoadMockGeneratorAsync( inputModelTypes: [inputModel, variantModel], additionalMetadataReferences: [BinaryDataMetadataReference], + includeXmlDocs: true, lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders.SingleOrDefault(t => t.Name == "MockInputModel") as ModelProvider; @@ -1464,10 +1465,10 @@ await MockHelpers.LoadMockGeneratorAsync( new[] { "String", "VariantModel" }, moreItemsProperty.Type.ElementType.UnionItemTypes.Select(t => t.Name).ToArray()); - // Restoring the union metadata must not change the emitted C#. - var generatedCode = new TypeProviderWriter(modelProvider).Write().Content; - Assert.IsTrue(generatedCode.Contains("IReadOnlyList Items")); - Assert.IsTrue(generatedCode.Contains("IReadOnlyDictionary MoreItems")); + // Restoring the union metadata leaves the emitted types unchanged and keeps the union item + // documentation pointing at the variant models. + var file = new TypeProviderWriter(modelProvider).Write(); + Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); } [Test] @@ -1489,6 +1490,7 @@ public async Task BackCompat_NestedUnionCollectionPropertyRetainsUnionItemTypes( await MockHelpers.LoadMockGeneratorAsync( inputModelTypes: [inputModel, variantModel], additionalMetadataReferences: [BinaryDataMetadataReference], + includeXmlDocs: true, lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders.SingleOrDefault(t => t.Name == "MockInputModel") as ModelProvider; @@ -1507,8 +1509,8 @@ await MockHelpers.LoadMockGeneratorAsync( new[] { "String", "VariantModel" }, innerElementType.UnionItemTypes.Select(t => t.Name).ToArray()); - var generatedCode = new TypeProviderWriter(modelProvider).Write().Content; - Assert.IsTrue(generatedCode.Contains("IReadOnlyList> NestedItems")); + var file = new TypeProviderWriter(modelProvider).Write(); + Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); } [Test] @@ -1532,6 +1534,7 @@ public async Task BackCompat_UnionPropertyReplacedWithNonBinaryDataTypeDropsUnio await MockHelpers.LoadMockGeneratorAsync( inputModelTypes: [inputModel, variantModel], additionalMetadataReferences: [BinaryDataMetadataReference], + includeXmlDocs: true, lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders.SingleOrDefault(t => t.Name == "MockInputModel") as ModelProvider; @@ -1541,6 +1544,9 @@ await MockHelpers.LoadMockGeneratorAsync( Assert.IsNotNull(dataProperty); Assert.IsTrue(dataProperty!.Type.Equals(typeof(object))); Assert.IsFalse(dataProperty.Type.IsUnion); + + var file = new TypeProviderWriter(modelProvider).Write(); + Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); } [Test] diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_NestedUnionCollectionPropertyRetainsUnionItemTypes.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_NestedUnionCollectionPropertyRetainsUnionItemTypes.cs new file mode 100644 index 00000000000..21ed2e0c544 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_NestedUnionCollectionPropertyRetainsUnionItemTypes.cs @@ -0,0 +1,74 @@ +// + +#nullable disable + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Sample; + +namespace Sample.Models +{ + /// MockInputModel description. + public partial class MockInputModel + { + /// Keeps track of any properties unknown to the library. + private protected readonly global::System.Collections.Generic.IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + public MockInputModel() + { + NestedItems = new global::Sample.ChangeTrackingList>(); + } + + /// Initializes a new instance of . + /// Description for nestedItems. + /// Keeps track of any properties unknown to the library. + internal MockInputModel(global::System.Collections.Generic.IReadOnlyList> nestedItems, global::System.Collections.Generic.IDictionary additionalBinaryDataProperties) + { + NestedItems = nestedItems; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// + /// Description for nestedItems + /// To assign an object to the element of this property use . + /// To assign an already formatted json string to this property use . + /// + /// + /// Supported types: + /// + /// + /// . + /// + /// + /// . + /// + /// + /// + /// + /// + /// Examples: + /// + /// + /// BinaryData.FromObjectAsJson("foo"). + /// Creates a payload of "foo". + /// + /// + /// BinaryData.FromString("\"foo\""). + /// Creates a payload of "foo". + /// + /// + /// BinaryData.FromObjectAsJson(new { key = "value" }). + /// Creates a payload of { "key": "value" }. + /// + /// + /// BinaryData.FromString("{\"key\": \"value\"}"). + /// Creates a payload of { "key": "value" }. + /// + /// + /// + /// + public global::System.Collections.Generic.IReadOnlyList> NestedItems { get; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_UnionCollectionPropertiesRetainUnionItemTypes.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_UnionCollectionPropertiesRetainUnionItemTypes.cs new file mode 100644 index 00000000000..7df92e59569 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_UnionCollectionPropertiesRetainUnionItemTypes.cs @@ -0,0 +1,118 @@ +// + +#nullable disable + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Sample; + +namespace Sample.Models +{ + /// MockInputModel description. + public partial class MockInputModel + { + /// Keeps track of any properties unknown to the library. + private protected readonly global::System.Collections.Generic.IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + public MockInputModel() + { + Items = new global::Sample.ChangeTrackingList(); + MoreItems = new global::Sample.ChangeTrackingDictionary(); + } + + /// Initializes a new instance of . + /// Description for items. + /// Description for moreItems. + /// Keeps track of any properties unknown to the library. + internal MockInputModel(global::System.Collections.Generic.IReadOnlyList items, global::System.Collections.Generic.IReadOnlyDictionary moreItems, global::System.Collections.Generic.IDictionary additionalBinaryDataProperties) + { + Items = items; + MoreItems = moreItems; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// + /// Description for items + /// To assign an object to the element of this property use . + /// To assign an already formatted json string to this property use . + /// + /// + /// Supported types: + /// + /// + /// . + /// + /// + /// . + /// + /// + /// + /// + /// + /// Examples: + /// + /// + /// BinaryData.FromObjectAsJson("foo"). + /// Creates a payload of "foo". + /// + /// + /// BinaryData.FromString("\"foo\""). + /// Creates a payload of "foo". + /// + /// + /// BinaryData.FromObjectAsJson(new { key = "value" }). + /// Creates a payload of { "key": "value" }. + /// + /// + /// BinaryData.FromString("{\"key\": \"value\"}"). + /// Creates a payload of { "key": "value" }. + /// + /// + /// + /// + public global::System.Collections.Generic.IReadOnlyList Items { get; } + + /// + /// Description for moreItems + /// To assign an object to the value of this property use . + /// To assign an already formatted json string to this property use . + /// + /// + /// Supported types: + /// + /// + /// . + /// + /// + /// . + /// + /// + /// + /// + /// + /// Examples: + /// + /// + /// BinaryData.FromObjectAsJson("foo"). + /// Creates a payload of "foo". + /// + /// + /// BinaryData.FromString("\"foo\""). + /// Creates a payload of "foo". + /// + /// + /// BinaryData.FromObjectAsJson(new { key = "value" }). + /// Creates a payload of { "key": "value" }. + /// + /// + /// BinaryData.FromString("{\"key\": \"value\"}"). + /// Creates a payload of { "key": "value" }. + /// + /// + /// + /// + public global::System.Collections.Generic.IReadOnlyDictionary MoreItems { get; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_UnionPropertyReplacedWithNonBinaryDataTypeDropsUnionItemTypes.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_UnionPropertyReplacedWithNonBinaryDataTypeDropsUnionItemTypes.cs new file mode 100644 index 00000000000..9e8ee7187f2 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_UnionPropertyReplacedWithNonBinaryDataTypeDropsUnionItemTypes.cs @@ -0,0 +1,78 @@ +// + +#nullable disable + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Sample; + +namespace Sample.Models +{ + /// MockInputModel description. + public partial class MockInputModel + { + /// Keeps track of any properties unknown to the library. + private protected readonly global::System.Collections.Generic.IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// Description for data. + /// is null. + public MockInputModel(object data) + { + global::Sample.Argument.AssertNotNull(data, nameof(data)); + + Data = data; + } + + /// Initializes a new instance of . + /// Description for data. + /// Keeps track of any properties unknown to the library. + internal MockInputModel(object data, global::System.Collections.Generic.IDictionary additionalBinaryDataProperties) + { + Data = data; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// + /// Description for data + /// To assign an object to this property use . + /// To assign an already formatted json string to this property use . + /// + /// + /// Supported types: + /// + /// + /// . + /// + /// + /// . + /// + /// + /// + /// + /// + /// Examples: + /// + /// + /// BinaryData.FromObjectAsJson("foo"). + /// Creates a payload of "foo". + /// + /// + /// BinaryData.FromString("\"foo\""). + /// Creates a payload of "foo". + /// + /// + /// BinaryData.FromObjectAsJson(new { key = "value" }). + /// Creates a payload of { "key": "value" }. + /// + /// + /// BinaryData.FromString("{\"key\": \"value\"}"). + /// Creates a payload of { "key": "value" }. + /// + /// + /// + /// + public object Data { get; set; } + } +} diff --git a/packages/http-client-csharp/generator/docs/backward-compatibility.md b/packages/http-client-csharp/generator/docs/backward-compatibility.md index e71a338a201..b339b05c226 100644 --- a/packages/http-client-csharp/generator/docs/backward-compatibility.md +++ b/packages/http-client-csharp/generator/docs/backward-compatibility.md @@ -296,14 +296,6 @@ public int? Count { get; set; } A diagnostic message is logged for every overridden property: `"Changed property {ModelName}.{PropertyName} type to {LastContractType} to match last contract."` -#### Union Metadata Preservation - -Last contract types are read from Roslyn symbols, which have no notion of a TypeSpec union — a union property is just `BinaryData` in metadata. When a last contract type is preserved, the generator restores the union item types from the current TypeSpec type onto the preserved type (including through list and dictionary element types). - -This does not change the emitted C#, because a union is always written as `BinaryData`. It matters because the union item types are what keep the union variant models referenced. Without them the variant models look unused, so they are removed (or internalized) from the output and their documentation references degrade from `` to plain text. - -If the preserved type is not `BinaryData` — for example when the last contract used `object` — the union metadata cannot be carried over and the variant models are no longer referenced by that property. - ### AdditionalProperties Type Preservation The generator maintains backward compatibility for the `AdditionalProperties` property type on models that extend or use `Record`. From b1cfa1f9c1cb9229cd43383501dd74da11a86959 Mon Sep 17 00:00:00 2001 From: Jorge Rangel Date: Wed, 2 Sep 2026 15:34:55 -0500 Subject: [PATCH 4/6] refactor(http-client-csharp): rebuild collections by replacing the trailing generic argument Drop the explicit list/dictionary branches and the Arguments[0] index in favor of replacing the trailing generic argument, which is the element for both shapes and matches how CSharpType.ElementType resolves it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5c237fe4-3c18-45b7-82b3-21b41cb1a714 --- .../src/Utilities/CSharpTypeExtensions.cs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs index e40216ef5ee..e57f0d91fea 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using Microsoft.TypeSpec.Generator.Input; using Microsoft.TypeSpec.Generator.Primitives; @@ -50,14 +51,12 @@ internal static CSharpType RestoreUnionItemTypes(this CSharpType type, CSharpTyp return type; } - if (type.IsList) - { - return new CSharpType(type.FrameworkType, [elementType], type.IsNullable); - } - - return type.IsDictionary - ? new CSharpType(type.FrameworkType, [type.Arguments[0], elementType], type.IsNullable) - : type; + // The element is always the trailing generic argument of a collection: `IReadOnlyList` + // and `IDictionary`. This mirrors how CSharpType.ElementType resolves it, and the + // successful ElementType access above proves there is at least one argument to replace. + var arguments = new List(type.Arguments); + arguments[^1] = elementType; + return new CSharpType(type.FrameworkType, arguments, type.IsNullable); } public static CSharpType ApplyInputSpecProperty(this CSharpType type, InputProperty? specProperty) From f566a23d19d2019d21f813924c43a605ff470ba4 Mon Sep 17 00:00:00 2001 From: Jorge Rangel Date: Wed, 2 Sep 2026 15:44:21 -0500 Subject: [PATCH 5/6] docs(http-client-csharp): document why arrays are out of scope for union restoration Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5c237fe4-3c18-45b7-82b3-21b41cb1a714 --- .../src/Utilities/CSharpTypeExtensions.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs index e57f0d91fea..cf4e54d1938 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs @@ -38,6 +38,9 @@ internal static CSharpType RestoreUnionItemTypes(this CSharpType type, CSharpTyp : type; } + // Arrays are intentionally out of scope: CSharpType.IsCollection covers only lists and + // dictionaries, and an array's ElementType is reconstructed from reflection on every access + // (CSharpType.GetElementType), so a decorated element cannot survive on an array anyway. if (!type.IsCollection || !source.IsCollection) { return type; From b8fb82d240f67ce8cb73fe0ceef032a7c1367000 Mon Sep 17 00:00:00 2001 From: Jorge Rangel Date: Wed, 2 Sep 2026 16:40:24 -0500 Subject: [PATCH 6/6] docs(http-client-csharp): drop the xml doc from RestoreUnionItemTypes The remarks justified the change partly by union doc references, which is a separate concern. The inline comments and the call site cover the rationale. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5c237fe4-3c18-45b7-82b3-21b41cb1a714 --- .../src/Utilities/CSharpTypeExtensions.cs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs index cf4e54d1938..37c4b34b09e 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs @@ -11,17 +11,6 @@ namespace Microsoft.TypeSpec.Generator.Utilities { internal static class CSharpTypeExtensions { - /// - /// Restores the union item metadata carried by onto . - /// - /// - /// Last-contract types are built from Roslyn symbols, which have no notion of a TypeSpec union: a union - /// property is just in metadata. When a last-contract type is preserved for - /// back compatibility, the union item types of the current TypeSpec type would otherwise be dropped. - /// That makes the union variant models appear unreferenced, so they get removed (or internalized) from - /// the output and their doc references degrade to plain text. Restoring the metadata keeps the emitted - /// C# identical while keeping the variant models reachable. - /// internal static CSharpType RestoreUnionItemTypes(this CSharpType type, CSharpType source) { if (type.IsUnion)