Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +786 to +788
}

protected internal override ConstructorProvider[] BuildConstructors()
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Comment thread
jorgerangel-msft marked this conversation as resolved.
}

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<TElement>`
// and `IDictionary<TKey, TElement>`. 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<CSharpType>(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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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);
Comment on lines +1470 to +1471
}

[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()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// <auto-generated/>

#nullable disable

using System;
using System.Collections.Generic;
using System.Text.Json;
using Sample;

namespace Sample.Models
{
/// <summary> MockInputModel description. </summary>
public partial class MockInputModel
{
/// <summary> Keeps track of any properties unknown to the library. </summary>
private protected readonly global::System.Collections.Generic.IDictionary<string, global::System.BinaryData> _additionalBinaryDataProperties;

/// <summary> Initializes a new instance of <see cref="global::Sample.Models.MockInputModel"/>. </summary>
public MockInputModel()
{
NestedItems = new global::Sample.ChangeTrackingList<global::System.Collections.Generic.IReadOnlyList<global::System.BinaryData>>();
}

/// <summary> Initializes a new instance of <see cref="global::Sample.Models.MockInputModel"/>. </summary>
/// <param name="nestedItems"> Description for nestedItems. </param>
/// <param name="additionalBinaryDataProperties"> Keeps track of any properties unknown to the library. </param>
internal MockInputModel(global::System.Collections.Generic.IReadOnlyList<global::System.Collections.Generic.IReadOnlyList<global::System.BinaryData>> nestedItems, global::System.Collections.Generic.IDictionary<string, global::System.BinaryData> additionalBinaryDataProperties)
{
NestedItems = nestedItems;
_additionalBinaryDataProperties = additionalBinaryDataProperties;
}

/// <summary>
/// Description for nestedItems
/// <para> To assign an object to the element of this property use <see cref="global::System.BinaryData.FromObjectAsJson{T}(T, global::System.Text.Json.JsonSerializerOptions?)"/>. </para>
/// <para> To assign an already formatted json string to this property use <see cref="global::System.BinaryData.FromString(string)"/>. </para>
/// <para>
/// <remarks>
/// Supported types:
/// <list type="bullet">
/// <item>
/// <description> <see cref="string"/>. </description>
/// </item>
/// <item>
/// <description> <see cref="global::Sample.Models.VariantModel"/>. </description>
/// </item>
/// </list>
/// </remarks>
/// </para>
/// <para>
/// Examples:
/// <list type="bullet">
/// <item>
/// <term> BinaryData.FromObjectAsJson("foo"). </term>
/// <description> Creates a payload of "foo". </description>
/// </item>
/// <item>
/// <term> BinaryData.FromString("\"foo\""). </term>
/// <description> Creates a payload of "foo". </description>
/// </item>
/// <item>
/// <term> BinaryData.FromObjectAsJson(new { key = "value" }). </term>
/// <description> Creates a payload of { "key": "value" }. </description>
/// </item>
/// <item>
/// <term> BinaryData.FromString("{\"key\": \"value\"}"). </term>
/// <description> Creates a payload of { "key": "value" }. </description>
/// </item>
/// </list>
/// </para>
/// </summary>
public global::System.Collections.Generic.IReadOnlyList<global::System.Collections.Generic.IReadOnlyList<global::System.BinaryData>> NestedItems { get; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System;
using System.Collections.Generic;

namespace Sample.Models
{
public partial class MockInputModel
{
public IReadOnlyList<IReadOnlyList<BinaryData>> NestedItems { get; }
}
}
Loading
Loading