Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -3,6 +3,7 @@

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using Microsoft.TypeSpec.Generator.Expressions;
Expand Down Expand Up @@ -535,18 +536,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.");
}
}

if (!isDiscriminator)
{
var derivedProperty = InputDerivedProperties.FirstOrDefault(p => p.Value.ContainsKey(property.Name)).Value?[property.Name];
Expand Down Expand Up @@ -1268,6 +1257,53 @@ _ 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))
{
outputProperty.Type = lastContractPropertyType.ApplyInputSpecProperty(outputProperty.InputProperty);
CodeModelGenerator.Instance.Emitter.Info($"Changed property {Name}.{outputProperty.Name} type to {lastContractPropertyType} to match last contract.");
}
}

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 @@ -579,6 +579,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 @@ -597,10 +598,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 @@ -613,6 +615,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,96 @@ 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_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,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; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,11 @@ public static PublicModel1 PublicModel1(

### Model Properties

The generator attempts to maintain backward compatibility for model property types, particularly for collection types.
The generator preserves the previous property type whenever it differs from the type produced by the current spec. This applies to all public model properties (scalars, enums, models, and collections), so any property type change is non-source-breaking by default. Users who want the new spec's type to take effect can override this behavior with custom code.

#### Scenario: Collection Property Type Changed

**Description:** When a property type changes from a read-only collection to a read-write collection (or vice versa), the generator attempts to preserve the previous property type to avoid breaking changes.
**Description:** When a property type changes from a read-only collection to a read-write collection (or vice versa), the generator preserves the previous property type to avoid breaking changes.

**Example:**

Expand All @@ -138,11 +138,31 @@ public IList<string> Items { get; set; }
public IReadOnlyList<string> Items { get; }
```

**Implementation Details:**
#### Scenario: Scalar/Model Property Type Changed

- The generator compares property types against the `LastContractView`
- For read-write lists and dictionaries, if the previous type was different, the previous type is retained
- A diagnostic message is logged: `"Changed property {ModelName}.{PropertyName} type to {LastContractType} to match last contract."`
**Description:** When the type of a scalar, enum, or model property differs between the last contract and the current spec — whether the change is in nullability, the underlying type, or anything else — the generator preserves the last contract's type.

**Example:**

Previous version:

```csharp
public int? Count { get; set; }
```

Current TypeSpec would generate:

```csharp
public int Count { get; set; }
```

**Result:** The generator detects the type mismatch and preserves the previous nullable type:

```csharp
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."`

### AdditionalProperties Type Preservation

Expand Down
Loading