Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c79daca
Initial plan
Copilot Apr 17, 2026
d6d7c2a
Expand back-compat property type support to all model properties
Copilot Apr 17, 2026
fd9ef6e
Add BuildPropertiesForBackCompatibility API, align docs with main
Copilot Apr 17, 2026
6275b90
Merge main: align with #10319 revert (drop AreNamesEqual on collectio…
Copilot Apr 18, 2026
7c64485
Merge remote-tracking branch 'origin/main' into copilot/expand-back-c…
Apr 18, 2026
86c40d0
Always preserve last contract property type in back-compat scenario
Copilot Apr 18, 2026
d26b620
Merge remote-tracking branch 'origin/main' into copilot/expand-back-c…
Copilot Apr 20, 2026
f4cdb93
Simplify TryGetLastContractPropertyTypeOverride; sync from main
Copilot Apr 20, 2026
4584e8b
Update BuildPropertiesForBackCompatibility XML summary to reflect unc…
Copilot Apr 20, 2026
0f006de
Move BuildPropertiesForBackCompatibility to run after visitors
Copilot Apr 21, 2026
2c0f1f7
Cascade back-compat property type override to cached ParameterProvider
Copilot Apr 24, 2026
ec6fba2
Simplify cascade-parameter comment
Copilot Apr 24, 2026
d323daf
Merge origin/main; rename CollectionPropertyTypePreserved to Property…
Copilot Apr 24, 2026
8b4142e
Merge remote-tracking branch 'origin/main' into copilot/expand-back-c…
Apr 24, 2026
f671e08
Cascade property.Type to AsParameter for every property in back-compa…
Copilot Apr 24, 2026
7cda7ea
Extract parameter-sync logic into SyncCachedParametersWithPropertyTyp…
Copilot Apr 24, 2026
3df9c80
Revert unconditional parameter cascade; reset _inputParameter in Para…
Copilot Apr 24, 2026
666f586
Sync cached parameter type via PropertyProvider.Update instead of helper
Copilot Apr 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ public enum BackCompatibilityChangeCategory
/// <summary>The shape of a model's <c>AdditionalProperties</c> property was preserved from the last contract.</summary>
AdditionalPropertiesShapePreserved,

/// <summary>A collection property type was preserved from the last contract.</summary>
CollectionPropertyTypePreserved,
/// <summary>A property type was preserved from the last contract.</summary>
PropertyTypePreserved,

/// <summary>A constructor modifier (e.g. <c>private protected</c> -&gt; <c>public</c>) was preserved from the last contract.</summary>
ConstructorModifierPreserved,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ public void WriteBufferedMessages()
BackCompatibilityChangeCategory.MethodParameterReordering => "Method Parameter Reordering",
BackCompatibilityChangeCategory.ParameterNamePreserved => "Parameter Name Preserved",
BackCompatibilityChangeCategory.AdditionalPropertiesShapePreserved => "AdditionalProperties Shape Preserved",
BackCompatibilityChangeCategory.CollectionPropertyTypePreserved => "Collection Property Type Preserved",
BackCompatibilityChangeCategory.PropertyTypePreserved => "Property Type Preserved",
BackCompatibilityChangeCategory.ConstructorModifierPreserved => "Constructor Modifier Preserved",
BackCompatibilityChangeCategory.EnumMemberReordering => "Enum Member Reordering",
BackCompatibilityChangeCategory.ApiVersionEnumMemberAdded => "Api Version Enum Member Added From Last Contract",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using Microsoft.TypeSpec.Generator.EmitterRpc;
Expand Down Expand Up @@ -536,20 +537,6 @@ protected internal override PropertyProvider[] BuildProperties()
continue;
}

// Targeted backcompat fix for the case where properties were previously generated as read-only collections
if (outputProperty.Type.IsReadWriteList || outputProperty.Type.IsReadWriteDictionary)
{
if (LastContractPropertiesMap.TryGetValue(outputProperty.Name,
out CSharpType? lastContractPropertyType) &&
!outputProperty.Type.Equals(lastContractPropertyType))
{
outputProperty.Type = lastContractPropertyType.ApplyInputSpecProperty(property);
CodeModelGenerator.Instance.Emitter.Info(
$"Changed property '{Name}.{outputProperty.Name}' type to '{lastContractPropertyType}' to match last contract.",
BackCompatibilityChangeCategory.CollectionPropertyTypePreserved);
}
}

if (!isDiscriminator)
{
var derivedProperty = InputDerivedProperties.FirstOrDefault(p => p.Value.ContainsKey(property.Name)).Value?[property.Name];
Expand Down Expand Up @@ -1279,6 +1266,56 @@ _ when type.Equals(_additionalPropsUnknownType, ignoreNullable: true) => type,
};
}

/// <summary>
/// Rewrites property types so that, whenever a property exists in the last contract
/// with a different type than the one produced by the current spec, the previous
/// contract's type is preserved. This avoids source-breaking changes for consumers
/// of the library for any kind of property change (collection wrapper, nullability,
/// underlying type, etc.). Users can override this behavior with custom code if they
/// want the new spec's type instead.
/// </summary>
protected internal override IReadOnlyList<PropertyProvider> BuildPropertiesForBackCompatibility(IEnumerable<PropertyProvider> originalProperties)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot this needs to run after all visitors have run.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the call from BuildProperties into ProcessTypeForBackCompatibility in 0f006de — it now runs alongside the existing methods/constructors back-compat passes, after all visitors have completed (per CSharpGen.cs line 79–82). Updated the affected ModelProviderTests to trigger ProcessTypeForBackCompatibility explicitly. All 1421 generator tests + 1311 ClientModel tests pass.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Not sure if that is the issue, but seeing a regression where the known azure type property serialization is not applied for nullable AzureLocation.
https://github.com/Azure/azure-sdk-for-net/blob/main/eng/packages/http-client-csharp/generator/Azure.Generator/src/Primitives/KnownAzureTypes.cs

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's very likely caused by the move. Before, back-compat ran inside BuildProperties and the Azure visitor ran after it, re-stamping outputProperty.Type with the AzureLocation TypeProvider's CSharpType (which carries the Implementation/known-Azure-type metadata that MRW serializer codegen keys off of). Now back-compat runs after the visitor and wholesale replaces outputProperty.Type with lastContractPropertyType from LastContractPropertiesMap — that one is built by reflecting over the previous binary, so it's a "raw" framework CSharpType without the TypeProvider association. The serializer then doesn't recognize it as a known Azure type.

A targeted fix: when lastContractPropertyType and outputProperty.Type have the same underlying name/namespace and only differ in nullability (or collection wrapping), keep the visitor's outputProperty.Type and just adjust nullability/wrapping on it (preserving the Implementation). When they differ in the underlying type (the stringint scenario), fall back to wholesale replacement as today.

Want me to go that route, or do you have a different shape in mind? If you can point me at a small failing test (or the exact property/type pairing), I can verify locally before pushing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JoshLove-msft do you know which library was hitting this? I have a new regen pr here Azure/azure-sdk-for-net#58604

{
var properties = originalProperties as IReadOnlyList<PropertyProvider> ?? [.. originalProperties];
if (LastContractPropertiesMap.Count == 0)
{
return properties;
}

foreach (var outputProperty in properties)
{
if (TryGetLastContractPropertyTypeOverride(outputProperty, out var lastContractPropertyType))
{
var newType = lastContractPropertyType.ApplyInputSpecProperty(outputProperty.InputProperty);
outputProperty.Update(type: newType);
CodeModelGenerator.Instance.Emitter.Info(
$"Changed property '{Name}.{outputProperty.Name}' type to '{lastContractPropertyType}' to match last contract.",
BackCompatibilityChangeCategory.PropertyTypePreserved);
}
}

return properties;
}

private bool TryGetLastContractPropertyTypeOverride(
PropertyProvider outputProperty,
[NotNullWhen(true)] out CSharpType? lastContractPropertyType)
{
// Always preserve the last contract's property type when it differs from the
// type produced by the current spec. This prevents source-breaking changes
// for any kind of property change (collection wrapper, nullability, underlying
// type, etc.). Users can override this behavior with custom code if needed.
lastContractPropertyType = null;
if (LastContractPropertiesMap.TryGetValue(outputProperty.Name, out var candidate) &&
!candidate.Equals(outputProperty.Type))
{
lastContractPropertyType = candidate;
return true;
}

return false;
}

/// <summary>
/// Determines whether to use object type for AdditionalProperties based on backward compatibility requirements.
/// Checks if the last contract (previous version) had an AdditionalProperties property of type IDictionary&lt;string, object&gt;.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,10 @@ public void Update(
WireInformation? wireInfo = null,
ParameterValidationType? validation = null)
{
// Reset the cached input variant so that ToPublicInputParameter() recalculates it
// from the updated state rather than returning a stale instance.
_inputParameter = null;

if (name is not null)
{
Name = name;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,10 @@ public void Update(
if (type != null)
{
Type = type;
if (_parameter.IsValueCreated && !_parameter.Value.Type.Equals(type))
{
_parameter.Value.Update(type: type);
}
}
if (name != null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,7 @@ internal void ProcessTypeForBackCompatibility()
{
var hasMethods = LastContractView?.Methods != null && LastContractView.Methods.Count > 0;
var hasConstructors = LastContractView?.Constructors != null && LastContractView.Constructors.Count > 0;
var hasProperties = LastContractView?.Properties != null && LastContractView.Properties.Count > 0;

IEnumerable<FieldProvider>? newFields = null;
if (this is EnumProvider)
Expand All @@ -608,10 +609,11 @@ internal void ProcessTypeForBackCompatibility()

var newMethods = hasMethods ? BuildMethodsForBackCompatibility(Methods) : null;
var newConstructors = hasConstructors ? BuildConstructorsForBackCompatibility(Constructors) : null;
var newProperties = hasProperties ? BuildPropertiesForBackCompatibility(Properties) : null;

if (newFields != null || newMethods != null || newConstructors != null)
if (newFields != null || newMethods != null || newConstructors != null || newProperties != null)
{
Update(fields: newFields, methods: newMethods, constructors: newConstructors);
Update(fields: newFields, methods: newMethods, constructors: newConstructors, properties: newProperties);
}
}

Expand All @@ -624,6 +626,17 @@ protected internal virtual IReadOnlyList<MethodProvider> BuildMethodsForBackComp
protected internal virtual IReadOnlyList<ConstructorProvider> BuildConstructorsForBackCompatibility(IEnumerable<ConstructorProvider> originalConstructors)
=> [.. originalConstructors];

/// <summary>
/// Called from <see cref="ProcessTypeForBackCompatibility"/> to apply backward-compatibility
/// adjustments to the set of properties produced for this type. Runs after all visitors so
/// adjustments reflect the final state of the library. Overrides can replace, reorder, or
/// otherwise rewrite properties based on the <see cref="LastContractView"/>.
/// </summary>
/// <param name="originalProperties">The properties as produced from the current input spec.</param>
/// <returns>The possibly-adjusted list of properties.</returns>
protected internal virtual IReadOnlyList<PropertyProvider> BuildPropertiesForBackCompatibility(IEnumerable<PropertyProvider> originalProperties)
=> [.. originalProperties];

private IReadOnlyList<EnumTypeMember>? _enumValues;

private bool ShouldGenerate(ConstructorProvider constructor)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,7 @@ await MockHelpers.LoadMockGeneratorAsync(

var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders.SingleOrDefault(t => t.Name == "MockInputModel") as ModelProvider;
Assert.IsNotNull(modelProvider);
modelProvider!.ProcessTypeForBackCompatibility();

var itemsProperty = modelProvider!.Properties.FirstOrDefault(p => p.Name == "Items");
Assert.IsNotNull(itemsProperty);
Expand Down Expand Up @@ -1016,6 +1017,7 @@ await MockHelpers.LoadMockGeneratorAsync(

var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders.SingleOrDefault(t => t.Name == "MockInputModel") as ModelProvider;
Assert.IsNotNull(modelProvider);
modelProvider!.ProcessTypeForBackCompatibility();

var elementModelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders.SingleOrDefault(t => t.Name == "ElementModel") as ModelProvider;

Expand Down Expand Up @@ -1050,6 +1052,7 @@ await MockHelpers.LoadMockGeneratorAsync(

var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders.SingleOrDefault(t => t.Name == "MockInputModel") as ModelProvider;
Assert.IsNotNull(modelProvider);
modelProvider!.ProcessTypeForBackCompatibility();

var elementEnumProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders.SingleOrDefault(t => t.Name == "ElementEnum") as EnumProvider;

Expand Down Expand Up @@ -1079,6 +1082,7 @@ await MockHelpers.LoadMockGeneratorAsync(

var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders.SingleOrDefault(t => t.Name == "MockInputModel") as ModelProvider;
Assert.IsNotNull(modelProvider);
modelProvider!.ProcessTypeForBackCompatibility();

var itemsProperty = modelProvider!.Properties.FirstOrDefault(p => p.Name == "Items");
Assert.IsNotNull(itemsProperty);
Expand All @@ -1089,6 +1093,136 @@ await MockHelpers.LoadMockGeneratorAsync(
Assert.IsTrue(moreItemsProperty!.Type.Equals(typeof(IDictionary<string, string>)));
}

[Test]
public async Task BackCompat_NullableScalarPropertyTypeIsRetained()
{
// Regression: when a scalar property was previously generated as nullable
// but the current spec marks it as non-nullable, the previous nullable type
// should be preserved to avoid a source-breaking change.
var inputModel = InputFactory.Model(
"MockInputModel",
properties:
[
InputFactory.Property("count", InputPrimitiveType.Int32, isRequired: true),
]);

await MockHelpers.LoadMockGeneratorAsync(
inputModelTypes: [inputModel],
lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync());

var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders.SingleOrDefault(t => t.Name == "MockInputModel") as ModelProvider;
Assert.IsNotNull(modelProvider);
modelProvider!.ProcessTypeForBackCompatibility();

var countProperty = modelProvider!.Properties.FirstOrDefault(p => p.Name == "Count");
Assert.IsNotNull(countProperty);
// The current spec says non-nullable int, but the last contract had int? – the
// generator should preserve the nullable type for backwards compatibility.
Assert.IsTrue(countProperty!.Type.Equals(new CSharpType(typeof(int), isNullable: true)));
}

[Test]
public async Task BackCompat_ConstructorParameterTypesMatchOverriddenProperty()
{
// Regression: constructor parameters are built from PropertyProvider.AsParameter, which
// lazily materializes a ParameterProvider capturing property.Type on first access. Visitors
// that inspect constructors/methods before ProcessTypeForBackCompatibility runs can
// materialize AsParameter with the pre-override type, so when back-compat later rewrites
// property.Type, the cached ctor/method signatures would go out of sync. Verify that the
// back-compat pass cascades the type override onto the shared ParameterProvider.
var inputModel = InputFactory.Model(
"MockInputModel",
properties:
[
InputFactory.Property("count", InputPrimitiveType.Int32, isRequired: true),
]);

await MockHelpers.LoadMockGeneratorAsync(
inputModelTypes: [inputModel],
lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync());

var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders.SingleOrDefault(t => t.Name == "MockInputModel") as ModelProvider;
Assert.IsNotNull(modelProvider);

// Simulate a visitor materializing the constructor (and therefore the property's
// AsParameter) before the back-compat pass runs.
var countProperty = modelProvider!.Properties.FirstOrDefault(p => p.Name == "Count");
Assert.IsNotNull(countProperty);
_ = countProperty!.AsParameter;

modelProvider.ProcessTypeForBackCompatibility();

// Property type was overridden from int to int? to match the last contract.
var expectedType = new CSharpType(typeof(int), isNullable: true);
Assert.IsTrue(countProperty.Type.Equals(expectedType));
// The shared ParameterProvider (used by any ctor/method signature built from this
// property) must reflect the overridden type too.
Assert.IsTrue(countProperty.AsParameter.Type.Equals(expectedType));
Assert.IsTrue(countProperty.AsParameter.ToPublicInputParameter().Type.Equals(expectedType.InputType));
}

[Test]
public async Task BackCompat_ScalarPropertyTypeOverriddenWhenTypeNameDiffers()
{
// When the property type differs between the last contract and the current spec
// (including a top-level type name change like string vs int), the generator
// preserves the last contract's type to avoid a source-breaking change. Users
// can override this behavior with custom code if needed.
var inputModel = InputFactory.Model(
"MockInputModel",
properties:
[
InputFactory.Property("count", InputPrimitiveType.Int32, isRequired: true),
]);

await MockHelpers.LoadMockGeneratorAsync(
inputModelTypes: [inputModel],
lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync());

var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders.SingleOrDefault(t => t.Name == "MockInputModel") as ModelProvider;
Assert.IsNotNull(modelProvider);
modelProvider!.ProcessTypeForBackCompatibility();

var countProperty = modelProvider!.Properties.FirstOrDefault(p => p.Name == "Count");
Assert.IsNotNull(countProperty);
// Last contract has `string Count { get; set; }` and the new spec says int – the
// generator preserves the last contract's type for backwards compatibility.
Assert.IsTrue(countProperty!.Type.Equals(typeof(string)));
}

[Test]
public async Task BackCompat_EnumPropertyTypeIsRetainedWhenNullabilityDiffers()
{
// A scalar (non-collection) enum property whose nullability changed between the
// last contract and the current spec should retain the last contract's nullability.
var statusEnum = InputFactory.StringEnum(
"StatusEnum",
[("Active", "Active"), ("Inactive", "Inactive")],
isExtensible: true);
var inputModel = InputFactory.Model(
"MockInputModel",
properties:
[
InputFactory.Property("status", statusEnum, isRequired: true),
]);

await MockHelpers.LoadMockGeneratorAsync(
inputModelTypes: [inputModel],
inputEnumTypes: [statusEnum],
lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync());

var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders.SingleOrDefault(t => t.Name == "MockInputModel") as ModelProvider;
Assert.IsNotNull(modelProvider);
modelProvider!.ProcessTypeForBackCompatibility();

var statusProperty = modelProvider!.Properties.FirstOrDefault(p => p.Name == "Status");
Assert.IsNotNull(statusProperty);
// The last contract had StatusEnum? but the spec marks it required/non-nullable –
// the generator should preserve the nullable type to avoid a breaking change.
Assert.IsTrue(statusProperty!.Type.IsNullable);
Assert.AreEqual("StatusEnum", statusProperty.Type.Name);
}

[Test]
public async Task BackCompat_NonAbstractTypeIsRespected()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Sample.Models
{
public partial class MockInputModel
{
public int? Count { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace Sample.Models
{
public partial class MockInputModel
{
public StatusEnum? Status { get; set; }
}

public partial struct StatusEnum
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Sample.Models
{
public partial class MockInputModel
{
public int? Count { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Sample.Models
{
public partial class MockInputModel
{
public string Count { get; set; }
}
}
Loading
Loading