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 c7c8949dda4..83412b4f19d 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 @@ -538,9 +538,17 @@ protected internal override PropertyProvider[] BuildProperties() // Targeted backcompat fix for the case where properties were previously generated as read-only collections if (outputProperty.Type.IsReadWriteList || outputProperty.Type.IsReadWriteDictionary) { + // We compare Arguments by name (not just ElementType) to cover both list element types + // and dictionary key/value types. This ensures we only override the collection wrapper + // (e.g. IReadOnlyList → IList) and not when the element type itself has changed. + // We use AreNamesEqual rather than Equals because the argument types may come from + // different sources (TypeProvider vs compiled assembly) but represent the same logical type. if (LastContractPropertiesMap.TryGetValue(outputProperty.Name, out CSharpType? lastContractPropertyType) && - !outputProperty.Type.Equals(lastContractPropertyType)) + !outputProperty.Type.Equals(lastContractPropertyType) && + outputProperty.Type.Arguments.Count == lastContractPropertyType.Arguments.Count && + outputProperty.Type.Arguments.Zip(lastContractPropertyType.Arguments).All( + pair => pair.First.AreNamesEqual(pair.Second))) { outputProperty.Type = lastContractPropertyType.ApplyInputSpecProperty(property); CodeModelGenerator.Instance.Emitter.Info($"Changed property {Name}.{outputProperty.Name} type to {lastContractPropertyType} to match last contract."); 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 7f6ef7ca815..286239e1663 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 @@ -1062,6 +1062,51 @@ await MockHelpers.LoadMockGeneratorAsync( Assert.IsTrue(moreItemsProperty!.Type.Equals(new CSharpType(typeof(IReadOnlyDictionary<,>), typeof(string), elementEnumProvider.Type))); } + [Test] + public async Task BackCompat_CollectionPropertyTypeNotOverriddenWhenElementTypeChanges() + { + // Simulate the scenario where the element type of a collection property has changed + // (e.g., from Record[] to BulkVMConfiguration[] via @typeChangedFrom). + // The last contract has IList> but the new code model + // produces IList. The override should NOT apply because the element + // type has changed. + var newElementModel = InputFactory.Model( + "NewElementModel", + properties: + [ + InputFactory.Property("name", InputPrimitiveType.String) + ]); + var inputModel = InputFactory.Model( + "MockInputModel", + properties: + [ + InputFactory.Property("items", InputFactory.Array(newElementModel)), + InputFactory.Property("moreItems", InputFactory.Dictionary(newElementModel)) + ]); + + await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [inputModel, newElementModel], + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders.SingleOrDefault(t => t.Name == "MockInputModel") as ModelProvider; + Assert.IsNotNull(modelProvider); + + var newElementModelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders.SingleOrDefault(t => t.Name == "NewElementModel") as ModelProvider; + Assert.IsNotNull(newElementModelProvider); + + // The items property should use the new element type (IList), not be + // overridden to the old type (IList>) from last contract + var itemsProperty = modelProvider!.Properties.FirstOrDefault(p => p.Name == "Items"); + Assert.IsNotNull(itemsProperty); + Assert.IsTrue(itemsProperty!.Type.Equals(new CSharpType(typeof(IList<>), newElementModelProvider!.Type))); + + // The moreItems property should use the new element type (IDictionary), not be + // overridden to the old type (IDictionary>) from last contract + var moreItemsProperty = modelProvider.Properties.FirstOrDefault(p => p.Name == "MoreItems"); + Assert.IsNotNull(moreItemsProperty); + Assert.IsTrue(moreItemsProperty!.Type.Equals(new CSharpType(typeof(IDictionary<,>), typeof(string), newElementModelProvider.Type))); + } + [Test] public async Task BackCompat_InternalTypesAreIgnored() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_CollectionPropertyTypeNotOverriddenWhenElementTypeChanges/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_CollectionPropertyTypeNotOverriddenWhenElementTypeChanges/MockInputModel.cs new file mode 100644 index 00000000000..9ab0d2614ef --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_CollectionPropertyTypeNotOverriddenWhenElementTypeChanges/MockInputModel.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace Sample.Models +{ + public partial class MockInputModel + { + public IList> Items { get; } + public IDictionary> MoreItems { get; } + } +}