Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.TypeSpec.Generator.ClientModel.Providers;
using Microsoft.TypeSpec.Generator.Input;
using Microsoft.TypeSpec.Generator.Input.Extensions;
Expand Down Expand Up @@ -159,5 +160,132 @@ public static IEnumerable<TestCaseData> TestBuildJsonModelWriteCoreTestCases
yield return new TestCaseData(new InputUnionType("union", [InputPrimitiveType.String, InputPrimitiveType.Float64]));
}
}

[Test]
public async Task TestBuildDeserializationMethod_WithObjectAdditionalPropertiesBackwardCompatibility()
{
// Create a model with unknown additional properties
var inputModel = InputFactory.Model(
"TestModel",
properties: [InputFactory.Property("Name", InputPrimitiveType.String, isRequired: true)],
additionalProperties: InputPrimitiveType.Any);

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

var model = ScmCodeModelGenerator.Instance.TypeFactory.CreateModel(inputModel);
Assert.IsNotNull(model, "Model should be created");

var serializations = model!.SerializationProviders.FirstOrDefault() as MrwSerializationTypeDefinition;
Assert.IsNotNull(serializations, "Serialization provider should exist");

var deserializationMethod = serializations!.BuildDeserializationMethod();
Assert.IsNotNull(deserializationMethod, "Deserialization method should be created");

var signature = deserializationMethod?.Signature;
Assert.IsNotNull(signature, "Method signature should exist");
Assert.AreEqual(model.Type, signature?.ReturnType, "Return type should match model type");

var methodBody = deserializationMethod?.BodyStatements;
Assert.IsNotNull(methodBody, "Method body should exist");

var methodBodyString = methodBody!.ToDisplayString();

// Verify variable declaration with object type for backward compatibility
Assert.IsTrue(methodBodyString.Contains("global::System.Collections.Generic.IDictionary<string, object> additionalProperties"),
"Should declare IDictionary<string, object> variable for backward compatibility");

// Verify dictionary initialization with ChangeTrackingDictionary
Assert.IsTrue(methodBodyString.Contains("new global::Sample.ChangeTrackingDictionary<string, object>()"),
"Should initialize ChangeTrackingDictionary with object type");

// Verify foreach loop over JsonElement properties
Assert.IsTrue(methodBodyString.Contains("foreach (var prop in element.EnumerateObject())"),
"Should enumerate over JsonElement properties");

// Verify property name check for known property
Assert.IsTrue(methodBodyString.Contains("prop.NameEquals(\"name\"u8)") || methodBodyString.Contains("prop.NameEquals(\"Name\"u8)"),
"Should check for property name");

// Verify GetString() is used for the Name property
Assert.IsTrue(methodBodyString.Contains("prop.Value.GetString()"),
"Should use GetString() for string property deserialization");

// Verify GetObject() method is used for additional properties deserialization
Assert.IsTrue(methodBodyString.Contains("additionalProperties.Add(prop.Name, prop.Value.GetObject())"),
"Should use GetObject() for deserializing object values into additionalProperties");

// Verify return statement with proper constructor call
Assert.IsTrue(methodBodyString.Contains("return new global::Sample.Models.TestModel(name, additionalProperties)"),
"Return statement should call constructor with name and additionalProperties");

// Verify no additionalBinaryDataProperties parameter in constructor call for object type
Assert.IsFalse(methodBodyString.Contains("return new global::Sample.Models.TestModel(name, additionalProperties, additionalBinaryDataProperties)"),
"Should not include additionalBinaryDataProperties parameter when using object type");
}

[Test]
public async Task TestBuildJsonModelWriteCore_WithObjectAdditionalPropertiesBackwardCompatibility()
{
// Create a model with unknown additional properties
var inputModel = InputFactory.Model(
"TestModel",
properties: [InputFactory.Property("Name", InputPrimitiveType.String, isRequired: true)],
additionalProperties: InputPrimitiveType.Any);

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

var model = ScmCodeModelGenerator.Instance.TypeFactory.CreateModel(inputModel);
Assert.IsNotNull(model, "Model should be created");

var serializations = model!.SerializationProviders.FirstOrDefault() as MrwSerializationTypeDefinition;
Assert.IsNotNull(serializations, "Serialization provider should exist");

var writeCoreMethod = serializations!.BuildJsonModelWriteCoreMethod();
Assert.IsNotNull(writeCoreMethod, "Write method should be created");

var methodBody = writeCoreMethod?.BodyStatements;
Assert.IsNotNull(methodBody, "Method body should exist");

var methodBodyString = methodBody!.ToDisplayString();

// Verify that AdditionalProperties property exists and has object type
var additionalPropertiesProperty = model.Properties.FirstOrDefault(p => p.Name == "AdditionalProperties");
Assert.IsNotNull(additionalPropertiesProperty, "AdditionalProperties property should exist");
Assert.IsTrue(additionalPropertiesProperty!.Type.IsDictionary, "Property should be a dictionary");
Assert.AreEqual(typeof(object), additionalPropertiesProperty.Type.Arguments[1].FrameworkType,
"Value type should be object for backward compatibility");

// Verify format check
Assert.IsTrue(methodBodyString.Contains("options.Format"),
"Should check options.Format");
Assert.IsTrue(methodBodyString.Contains("if ((format != \"J\"))"),
"Should validate JSON format");
Assert.IsTrue(methodBodyString.Contains("throw new global::System.FormatException"),
"Should throw FormatException for unsupported formats");

// Verify serialization of the Name property
Assert.IsTrue(methodBodyString.Contains("writer.WritePropertyName(\"name\"u8)") ||
methodBodyString.Contains("writer.WritePropertyName(\"Name\"u8)"),
"Should write Name property name");
Assert.IsTrue(methodBodyString.Contains("writer.WriteStringValue(Name)"),
"Should write Name property value");

// Verify foreach loop over AdditionalProperties
var expectedForeachStatement = $"foreach (var item in {additionalPropertiesProperty.Name})";
Assert.IsTrue(methodBodyString.Contains(expectedForeachStatement),
"Should iterate over AdditionalProperties");

// Verify property key is written
Assert.IsTrue(methodBodyString.Contains("writer.WritePropertyName(item.Key)"),
"Should write additional property key");

// Verify property value is written with generic WriteObjectValue for object type
Assert.IsTrue(methodBodyString.Contains("writer.WriteObjectValue<object>(item.Value, options)"),
"Should use WriteObjectValue<object> for object-typed additional properties");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;

namespace Sample.Models
{
public partial class TestModel
{
public TestModel(string name, IDictionary<string, object> additionalProperties)
{
Name = name;
AdditionalProperties = additionalProperties;
}

public string Name { get; set; }
public IDictionary<string, object> AdditionalProperties { get; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;

namespace Sample.Models
{
public partial class TestModel
{
public TestModel(string name, IDictionary<string, object> additionalProperties)
{
Name = name;
AdditionalProperties = additionalProperties;
}

public string Name { get; set; }
public IDictionary<string, object> AdditionalProperties { get; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ protected override FormattableString BuildDescription()
private readonly bool _isMultiLevelDiscriminator;

private readonly CSharpType _additionalBinaryDataPropsFieldType = typeof(IDictionary<string, BinaryData>);
private readonly CSharpType _additionalObjectPropsFieldType = typeof(IDictionary<string, object>);
private readonly Type _additionalPropsUnknownType = typeof(BinaryData);
private Lazy<bool> _useObjectAdditionalProperties;
private FieldProvider? _rawDataField;
private List<FieldProvider>? _additionalPropertyFields;
private List<PropertyProvider>? _additionalPropertyProperties;
Expand All @@ -68,6 +70,7 @@ public ModelProvider(InputModelType inputModel) : base(inputModel)
{
_inputModel = inputModel;
_isMultiLevelDiscriminator = ComputeIsMultiLevelDiscriminator();
_useObjectAdditionalProperties = new Lazy<bool>(ShouldUseObjectAdditionalProperties);

if (_inputModel.BaseModel is not null)
{
Expand Down Expand Up @@ -125,7 +128,9 @@ public ModelProvider? BaseModelProvider
protected FieldProvider? RawDataField => _rawDataField ??= BuildRawDataField();
private List<FieldProvider> AdditionalPropertyFields => _additionalPropertyFields ??= BuildAdditionalPropertyFields();
private List<PropertyProvider> AdditionalPropertyProperties => _additionalPropertyProperties ??= BuildAdditionalPropertyProperties();
protected internal bool SupportsBinaryDataAdditionalProperties => AdditionalPropertyProperties.Any(p => p.Type.ElementType.Equals(_additionalPropsUnknownType));
protected internal bool SupportsBinaryDataAdditionalProperties => AdditionalPropertyProperties.Any(p =>
p.Type.ElementType.Equals(_additionalPropsUnknownType) ||
(p.Type.ElementType.IsFrameworkType && p.Type.ElementType.FrameworkType == typeof(object)));
public ConstructorProvider FullConstructor => _fullConstructor ??= BuildFullConstructor();

protected override string BuildNamespace() => string.IsNullOrEmpty(_inputModel.Namespace) ?
Expand Down Expand Up @@ -367,9 +372,13 @@ private List<PropertyProvider> BuildAdditionalPropertyProperties()
var name = !containsAdditionalTypeProperties
? AdditionalPropertiesHelper.DefaultAdditionalPropertiesPropertyName
: RawDataField.Name.ToIdentifierName();

// Use object type if backward compatibility requires it, otherwise use BinaryData type
var propertyType = _useObjectAdditionalProperties.Value ? _additionalObjectPropsFieldType : additionalPropsType;
var type = !_inputModel.Usage.HasFlag(InputModelTypeUsage.Input)
? additionalPropsType.OutputType
: additionalPropsType;
? propertyType.OutputType
: propertyType;

var assignment = type.IsReadOnlyDictionary
? new ExpressionPropertyBody(New.ReadOnlyDictionary(type.Arguments[0], type.ElementType, RawDataField))
: new ExpressionPropertyBody(RawDataField);
Expand Down Expand Up @@ -1146,9 +1155,12 @@ private ValueExpression GetConversion(PropertyProvider? property = default, Fiel
}
modifiers |= FieldModifiers.ReadOnly;

// Use object type for backward compatibility if needed, otherwise use BinaryData
var fieldType = _useObjectAdditionalProperties.Value ? _additionalObjectPropsFieldType : _additionalBinaryDataPropsFieldType;

var rawDataField = new FieldProvider(
modifiers: modifiers,
type: _additionalBinaryDataPropsFieldType,
type: fieldType,
description: FormattableStringHelpers.FromString(AdditionalBinaryDataPropsFieldDescription),
name: AdditionalPropertiesHelper.AdditionalBinaryDataPropsFieldName,
enclosingType: this);
Expand All @@ -1175,6 +1187,45 @@ _ when type.Equals(_additionalPropsUnknownType, ignoreNullable: true) => type,
};
}

/// <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;.
/// </summary>
/// <returns>True if object type should be used for backward compatibility; otherwise false (uses BinaryData).</returns>
private bool ShouldUseObjectAdditionalProperties()
{
if (LastContractView == null || _inputModel.AdditionalProperties == null)
{
return false;
}

// Check if the property exists in the last contract by name
var lastContractProperty = LastContractView.Properties.FirstOrDefault(p =>
p.Name == AdditionalPropertiesHelper.DefaultAdditionalPropertiesPropertyName);

if (lastContractProperty == null)
{
return false;
}

// Check if it's IDictionary<string, object>
var propertyType = lastContractProperty.Type;
if (propertyType.IsDictionary && propertyType.Arguments.Count == 2)
{
var keyType = propertyType.Arguments[0];
var valueType = propertyType.Arguments[1];

// Check if key is string and value is object
if (keyType.IsFrameworkType && keyType.FrameworkType == typeof(string) &&
valueType.IsFrameworkType && valueType.FrameworkType == typeof(object))
{
return true;
}
}

return false;
}

private static string BuildAdditionalTypePropertiesFieldName(CSharpType additionalPropertiesValueType)
{
var name = additionalPropertiesValueType.Name;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1807,5 +1807,37 @@ await MockHelpers.LoadMockGeneratorAsync(
Assert.IsNotNull(publicConstructor, "Constructor modifier should be changed to public for backward compatibility");
Assert.AreEqual("baseProp", publicConstructor!.Signature.Parameters[0].Name);
}

[Test]
public async Task TestBuildProperties_WithObjectAdditionalPropertiesBackwardCompatibility()
{
// Create a model with unknown additional properties (which would normally generate BinaryData)
var inputModel = InputFactory.Model(
"TestModel",
usage: InputModelTypeUsage.Input,
properties: [InputFactory.Property("Name", InputPrimitiveType.String, isRequired: true)],
additionalProperties: InputPrimitiveType.Any);

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

var modelProvider = CodeModelGenerator.Instance.TypeFactory.CreateModel(inputModel);

Assert.IsNotNull(modelProvider);
Assert.IsNotNull(modelProvider!.Properties);

// Verify that AdditionalProperties property exists and has object type for backward compatibility
var additionalPropertiesProperty = modelProvider.Properties.FirstOrDefault(p => p.Name == "AdditionalProperties");
Assert.IsNotNull(additionalPropertiesProperty, "AdditionalProperties property should be generated");
Assert.IsTrue(additionalPropertiesProperty!.IsAdditionalProperties, "Property should be marked as additional properties");

// Verify the type is IDictionary<string, object> for backward compatibility
var propertyType = additionalPropertiesProperty.Type;
Assert.IsTrue(propertyType.IsDictionary, "Property should be a dictionary type");
Assert.AreEqual(2, propertyType.Arguments.Count, "Dictionary should have 2 type arguments");
Assert.AreEqual(typeof(string), propertyType.Arguments[0].FrameworkType, "Key type should be string");
Assert.AreEqual(typeof(object), propertyType.Arguments[1].FrameworkType, "Value type should be object for backward compatibility");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;

namespace Sample.Models
{
public partial class TestModel
{
public TestModel(string name, IDictionary<string, object> additionalProperties)
{
Name = name;
AdditionalProperties = additionalProperties;
}

public string Name { get; set; }
public IDictionary<string, object> AdditionalProperties { get; }
}
}
Loading