Skip to content

Commit d6d7c2a

Browse files
Expand back-compat property type support to all model properties
Agent-Logs-Url: https://github.com/microsoft/typespec/sessions/33d000a2-d811-4f6c-a72b-359b57cee1a2 Co-authored-by: jorgerangel-msft <102122018+jorgerangel-msft@users.noreply.github.com>
1 parent c79daca commit d6d7c2a

6 files changed

Lines changed: 237 additions & 20 deletions

File tree

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs

Lines changed: 90 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System;
55
using System.Collections.Generic;
6+
using System.Diagnostics.CodeAnalysis;
67
using System.IO;
78
using System.Linq;
89
using Microsoft.TypeSpec.Generator.Expressions;
@@ -535,24 +536,13 @@ protected internal override PropertyProvider[] BuildProperties()
535536
continue;
536537
}
537538

538-
// Targeted backcompat fix for the case where properties were previously generated as read-only collections
539-
if (outputProperty.Type.IsReadWriteList || outputProperty.Type.IsReadWriteDictionary)
539+
// Backcompat fix for property types: if a property existed in the last contract
540+
// with a compatible but non-identical type, retain the previous type to avoid
541+
// breaking consumers of the library.
542+
if (TryGetLastContractPropertyTypeOverride(outputProperty, out var lastContractPropertyType))
540543
{
541-
// We compare Arguments by name (not just ElementType) to cover both list element types
542-
// and dictionary key/value types. This ensures we only override the collection wrapper
543-
// (e.g. IReadOnlyList<T> → IList<T>) and not when the element type itself has changed.
544-
// We use AreNamesEqual rather than Equals because the argument types may come from
545-
// different sources (TypeProvider vs compiled assembly) but represent the same logical type.
546-
if (LastContractPropertiesMap.TryGetValue(outputProperty.Name,
547-
out CSharpType? lastContractPropertyType) &&
548-
!outputProperty.Type.Equals(lastContractPropertyType) &&
549-
outputProperty.Type.Arguments.Count == lastContractPropertyType.Arguments.Count &&
550-
outputProperty.Type.Arguments.Zip(lastContractPropertyType.Arguments).All(
551-
pair => pair.First.AreNamesEqual(pair.Second)))
552-
{
553-
outputProperty.Type = lastContractPropertyType.ApplyInputSpecProperty(property);
554-
CodeModelGenerator.Instance.Emitter.Info($"Changed property {Name}.{outputProperty.Name} type to {lastContractPropertyType} to match last contract.");
555-
}
544+
outputProperty.Type = lastContractPropertyType.ApplyInputSpecProperty(property);
545+
CodeModelGenerator.Instance.Emitter.Info($"Changed property {Name}.{outputProperty.Name} type to {lastContractPropertyType} to match last contract.");
556546
}
557547

558548
if (!isDiscriminator)
@@ -1276,6 +1266,89 @@ _ when type.Equals(_additionalPropsUnknownType, ignoreNullable: true) => type,
12761266
};
12771267
}
12781268

1269+
/// <summary>
1270+
/// Determines whether the type of a generated property should be replaced with the type
1271+
/// of the matching property in the last contract to preserve backward compatibility.
1272+
/// </summary>
1273+
/// <remarks>
1274+
/// The override is applied when the generated and last-contract property types differ but
1275+
/// are logically compatible. Two categories are supported:
1276+
/// <list type="bullet">
1277+
/// <item>
1278+
/// <description>
1279+
/// Read-write collection properties (<see cref="CSharpType.IsReadWriteList"/> or
1280+
/// <see cref="CSharpType.IsReadWriteDictionary"/>) whose element/key/value type names
1281+
/// match the last contract. This handles cases such as <c>IReadOnlyList&lt;T&gt;</c>
1282+
/// being regenerated as <c>IList&lt;T&gt;</c>.
1283+
/// </description>
1284+
/// </item>
1285+
/// <item>
1286+
/// <description>
1287+
/// All other public properties whose top-level type name (including any generic
1288+
/// argument names) matches the last contract. This handles cases such as a property
1289+
/// previously generated as a nullable scalar being regenerated as non-nullable, or a
1290+
/// property whose type is sourced from a different assembly but represents the same
1291+
/// logical type.
1292+
/// </description>
1293+
/// </item>
1294+
/// </list>
1295+
/// </remarks>
1296+
/// <param name="outputProperty">The property generated from the current input spec.</param>
1297+
/// <param name="lastContractPropertyType">
1298+
/// When the method returns <c>true</c>, the type from the last contract that should be
1299+
/// used to override the generated property type.
1300+
/// </param>
1301+
/// <returns>
1302+
/// <c>true</c> if the generated property type should be replaced with the last contract's
1303+
/// type; otherwise <c>false</c>.
1304+
/// </returns>
1305+
private bool TryGetLastContractPropertyTypeOverride(
1306+
PropertyProvider outputProperty,
1307+
[NotNullWhen(true)] out CSharpType? lastContractPropertyType)
1308+
{
1309+
lastContractPropertyType = null;
1310+
if (!LastContractPropertiesMap.TryGetValue(outputProperty.Name, out var candidate))
1311+
{
1312+
return false;
1313+
}
1314+
1315+
if (outputProperty.Type.Equals(candidate))
1316+
{
1317+
return false;
1318+
}
1319+
1320+
// Read-write collections: allow the wrapper (e.g. IList<T> vs IReadOnlyList<T>) to
1321+
// change as long as the element/key/value type names still match. We compare
1322+
// Arguments by name (not just ElementType) so this covers both list element types
1323+
// and dictionary key/value types. AreNamesEqual is used rather than Equals because
1324+
// the argument types may come from different sources (TypeProvider vs compiled
1325+
// assembly) but represent the same logical type.
1326+
if (outputProperty.Type.IsReadWriteList || outputProperty.Type.IsReadWriteDictionary)
1327+
{
1328+
if (outputProperty.Type.Arguments.Count == candidate.Arguments.Count &&
1329+
outputProperty.Type.Arguments.Zip(candidate.Arguments).All(
1330+
pair => pair.First.AreNamesEqual(pair.Second)))
1331+
{
1332+
lastContractPropertyType = candidate;
1333+
return true;
1334+
}
1335+
1336+
return false;
1337+
}
1338+
1339+
// Other properties: require the entire type name (top-level plus any generic
1340+
// argument names) to match. This ensures we only override when the types are
1341+
// logically the same (e.g. differ only in nullability) and never when the
1342+
// underlying type has genuinely changed (e.g. string to int).
1343+
if (outputProperty.Type.AreNamesEqual(candidate))
1344+
{
1345+
lastContractPropertyType = candidate;
1346+
return true;
1347+
}
1348+
1349+
return false;
1350+
}
1351+
12791352
/// <summary>
12801353
/// Determines whether to use object type for AdditionalProperties based on backward compatibility requirements.
12811354
/// Checks if the last contract (previous version) had an AdditionalProperties property of type IDictionary&lt;string, object&gt;.

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1134,6 +1134,92 @@ await MockHelpers.LoadMockGeneratorAsync(
11341134
Assert.IsTrue(moreItemsProperty!.Type.Equals(typeof(IDictionary<string, string>)));
11351135
}
11361136

1137+
[Test]
1138+
public async Task BackCompat_NullableScalarPropertyTypeIsRetained()
1139+
{
1140+
// Regression: when a scalar property was previously generated as nullable
1141+
// but the current spec marks it as non-nullable, the previous nullable type
1142+
// should be preserved to avoid a source-breaking change.
1143+
var inputModel = InputFactory.Model(
1144+
"MockInputModel",
1145+
properties:
1146+
[
1147+
InputFactory.Property("count", InputPrimitiveType.Int32, isRequired: true),
1148+
]);
1149+
1150+
await MockHelpers.LoadMockGeneratorAsync(
1151+
inputModelTypes: [inputModel],
1152+
lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync());
1153+
1154+
var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders.SingleOrDefault(t => t.Name == "MockInputModel") as ModelProvider;
1155+
Assert.IsNotNull(modelProvider);
1156+
1157+
var countProperty = modelProvider!.Properties.FirstOrDefault(p => p.Name == "Count");
1158+
Assert.IsNotNull(countProperty);
1159+
// The current spec says non-nullable int, but the last contract had int? – the
1160+
// generator should preserve the nullable type for backwards compatibility.
1161+
Assert.IsTrue(countProperty!.Type.Equals(new CSharpType(typeof(int), isNullable: true)));
1162+
}
1163+
1164+
[Test]
1165+
public async Task BackCompat_ScalarPropertyTypeNotOverriddenWhenTypeNameDiffers()
1166+
{
1167+
// When the top-level property type name differs between the last contract and the
1168+
// current spec (e.g. string vs int), the generator must not silently replace the
1169+
// spec-defined type with the last contract's type.
1170+
var inputModel = InputFactory.Model(
1171+
"MockInputModel",
1172+
properties:
1173+
[
1174+
InputFactory.Property("count", InputPrimitiveType.Int32, isRequired: true),
1175+
]);
1176+
1177+
await MockHelpers.LoadMockGeneratorAsync(
1178+
inputModelTypes: [inputModel],
1179+
lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync());
1180+
1181+
var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders.SingleOrDefault(t => t.Name == "MockInputModel") as ModelProvider;
1182+
Assert.IsNotNull(modelProvider);
1183+
1184+
var countProperty = modelProvider!.Properties.FirstOrDefault(p => p.Name == "Count");
1185+
Assert.IsNotNull(countProperty);
1186+
// Last contract has `string Count { get; set; }` but the new spec says int – the
1187+
// generator must not override the new type since the names differ entirely.
1188+
Assert.IsTrue(countProperty!.Type.Equals(typeof(int)));
1189+
}
1190+
1191+
[Test]
1192+
public async Task BackCompat_EnumPropertyTypeIsRetainedWhenNullabilityDiffers()
1193+
{
1194+
// A scalar (non-collection) enum property whose nullability changed between the
1195+
// last contract and the current spec should retain the last contract's nullability.
1196+
var statusEnum = InputFactory.StringEnum(
1197+
"StatusEnum",
1198+
[("Active", "Active"), ("Inactive", "Inactive")],
1199+
isExtensible: true);
1200+
var inputModel = InputFactory.Model(
1201+
"MockInputModel",
1202+
properties:
1203+
[
1204+
InputFactory.Property("status", statusEnum, isRequired: true),
1205+
]);
1206+
1207+
await MockHelpers.LoadMockGeneratorAsync(
1208+
inputModelTypes: [inputModel],
1209+
inputEnumTypes: [statusEnum],
1210+
lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync());
1211+
1212+
var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders.SingleOrDefault(t => t.Name == "MockInputModel") as ModelProvider;
1213+
Assert.IsNotNull(modelProvider);
1214+
1215+
var statusProperty = modelProvider!.Properties.FirstOrDefault(p => p.Name == "Status");
1216+
Assert.IsNotNull(statusProperty);
1217+
// The last contract had StatusEnum? but the spec marks it required/non-nullable –
1218+
// the generator should preserve the nullable type to avoid a breaking change.
1219+
Assert.IsTrue(statusProperty!.Type.IsNullable);
1220+
Assert.AreEqual("StatusEnum", statusProperty.Type.Name);
1221+
}
1222+
11371223
[Test]
11381224
public async Task BackCompat_NonAbstractTypeIsRespected()
11391225
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace Sample.Models
2+
{
3+
public partial class MockInputModel
4+
{
5+
public StatusEnum? Status { get; set; }
6+
}
7+
8+
public partial struct StatusEnum
9+
{
10+
}
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace Sample.Models
2+
{
3+
public partial class MockInputModel
4+
{
5+
public int? Count { get; set; }
6+
}
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace Sample.Models
2+
{
3+
public partial class MockInputModel
4+
{
5+
public string Count { get; set; }
6+
}
7+
}

packages/http-client-csharp/generator/docs/backward-compatibility.md

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,15 @@ public static PublicModel1 PublicModel1(
110110

111111
### Model Properties
112112

113-
The generator attempts to maintain backward compatibility for model property types, particularly for collection types.
113+
The generator attempts to maintain backward compatibility for all public model property types by preserving the previous property type when it differs from the type that would be generated from the current spec but is still logically compatible.
114+
115+
The following scenarios are supported for any public model property:
116+
117+
- **Collection wrapper change:** A property previously generated as a read-only collection (e.g. `IReadOnlyList<T>` / `IReadOnlyDictionary<TKey, TValue>`) that is now produced as a read-write collection (e.g. `IList<T>` / `IDictionary<TKey, TValue>`), or vice versa. The collection element/key/value type names must still match – the wrapper is preserved but genuine element-type changes are honoured.
118+
- **Nullability change:** A scalar, enum, or model property that was previously generated as nullable (e.g. `int?`, `StatusEnum?`) and is now produced as non-nullable (or vice versa). The last contract's nullability is preserved when the top-level type name (and any generic argument names) still matches.
119+
- **Same-name types from different sources:** Properties whose generated type is logically the same as the last contract's type, but sourced from different assemblies (e.g. a `TypeProvider`-produced type vs. a compiled-assembly type). Equality is evaluated by name rather than identity so these are treated as the same type.
120+
121+
The override is **not** applied when the top-level property type names differ (for example a property that changed from `string` to `int`) – such changes are passed through so that the new spec is honoured.
114122

115123
#### Scenario: Collection Property Type Changed
116124

@@ -136,10 +144,35 @@ public IList<string> Items { get; set; }
136144
public IReadOnlyList<string> Items { get; }
137145
```
138146

147+
#### Scenario: Scalar/Model Property Nullability Changed
148+
149+
**Description:** When the nullability of a scalar, enum, or model property differs between the last contract and the current spec, the generator preserves the last contract's nullability to avoid a source-breaking change.
150+
151+
**Example:**
152+
153+
Previous version:
154+
155+
```csharp
156+
public int? Count { get; set; }
157+
```
158+
159+
Current TypeSpec would generate:
160+
161+
```csharp
162+
public int Count { get; set; }
163+
```
164+
165+
**Result:** The generator detects the nullability mismatch and preserves the previous nullable type:
166+
167+
```csharp
168+
public int? Count { get; set; }
169+
```
170+
139171
**Implementation Details:**
140172

141-
- The generator compares property types against the `LastContractView`
142-
- For read-write lists and dictionaries, if the previous type was different, the previous type is retained
173+
- The generator compares property types against the `LastContractView`.
174+
- For read-write lists and dictionaries, if the previous type was different but the element/key/value type names match, the previous type is retained.
175+
- For all other properties, if the previous type's top-level name (including any generic argument names) matches the new type's top-level name, the previous type is retained – this covers nullability changes and same-name types sourced from different assemblies.
143176
- A diagnostic message is logged: `"Changed property {ModelName}.{PropertyName} type to {LastContractType} to match last contract."`
144177

145178
### AdditionalProperties Type Preservation

0 commit comments

Comments
 (0)