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..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 @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // 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; @@ -9,6 +11,46 @@ namespace Microsoft.TypeSpec.Generator.Utilities { internal static class CSharpTypeExtensions { + internal 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; + } + + // 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; + } + + 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; + } + + // 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) { 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..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 @@ -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,138 @@ 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], + includeXmlDocs: true, + 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 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] + 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], + includeXmlDocs: true, + 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 file = new TypeProviderWriter(modelProvider).Write(); + Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); + } + + [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], + includeXmlDocs: true, + 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); + + var file = new TypeProviderWriter(modelProvider).Write(); + Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content); + } + [Test] public async Task BackCompat_ScalarPropertyTypeOverriddenWhenTypeNameDiffers() { 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_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 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_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.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/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; } + } +}