Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
bffe617
Initial plan
Copilot Jul 30, 2026
4e374c7
Add back-compat restore for dropped model constructors
Copilot Jul 30, 2026
9a3839d
Add tests and validation-stripping fix for restored back-compat const…
Copilot Jul 30, 2026
9c940c4
Address review feedback on back-compat constructor restoration
Copilot Jul 30, 2026
a7950c8
Add robust TestData-based tests for back-compat constructor restoration
Copilot Jul 30, 2026
364600b
Address review: baseline/custom-code ctor skip, reuse CloneParameterW…
Copilot Jul 31, 2026
04eb55b
Address review: trim comments, reorder baseline check, property looku…
Copilot Jul 31, 2026
040979e
Address review: simplify log, merge property lookup loop, inline Find…
Copilot Jul 31, 2026
49c1b45
fix(csharp): restore ctor validation semantics in back-compat overloads
Copilot Jul 31, 2026
c58af0e
Follow current property requiredness for restored back-compat ctor pa…
jorgerangel-msft Jul 31, 2026
93729fe
Clone restored back-compat ctor param from the current property
jorgerangel-msft Jul 31, 2026
cfcad57
Use TryAdd for the rename fallback in BuildRestorablePropertyLookup
jorgerangel-msft Jul 31, 2026
038e1ce
Pass through constructors in serialization partial back-compat
jorgerangel-msft Aug 3, 2026
34901ff
fix: include custom ctors
jorgerangel-msft Aug 4, 2026
88060f1
docs: describe back-compat model constructor restoration
Copilot Aug 4, 2026
f4de5be
docs: describe parameterless constructor compatibility
Copilot Aug 4, 2026
d98e70b
docs(csharp): use protected constructor back-compat example
Copilot Aug 4, 2026
4779678
skip structs
jorgerangel-msft Aug 4, 2026
f491e15
Merge branch 'main' into copilot/lastcontractview-back-compat-ctors
jorgerangel-msft Aug 5, 2026
93bfb97
Fix build: use MethodSignatureHelper.IsPublicApi after main moved the…
jorgerangel-msft Aug 5, 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 @@ -125,6 +125,9 @@ public MrwSerializationTypeDefinition(InputModelType inputModel, ModelProvider m
protected override IReadOnlyList<MethodProvider> BuildMethodsForBackCompatibility(IEnumerable<MethodProvider> originalMethods)
=> [.. originalMethods];

protected override IReadOnlyList<ConstructorProvider> BuildConstructorsForBackCompatibility(IEnumerable<ConstructorProvider> originalConstructors)
=> [.. originalConstructors];

private ConstructorProvider SerializationConstructor => _serializationConstructor ??= _model.FullConstructor;
private PropertyProvider[] AdditionalProperties => _additionalProperties.Value;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,75 @@ await MockHelpers.LoadMockGeneratorAsync(
Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content);
}

[Test]
public async Task BackCompat_ParameterlessConstructorRestoredRemovesMockingConstructor()
{
// The last contract published a parameterless `protected BaseModel()`. The current generation
// makes the discriminator required, so the abstract base's initialization constructor now takes a
// parameter and the parameterless constructor is dropped. It is restored, and the generated
// parameterless mocking constructor on the serialization partial is removed to avoid a duplicate.
var derivedInputModel = InputFactory.Model(
"derivedModel",
discriminatedKind: "one",
properties:
[
InputFactory.Property("kind", InputPrimitiveType.String, isRequired: true, isDiscriminator: true)
]);
var inputModel = InputFactory.Model(
"baseModel",
properties:
[
InputFactory.Property("kind", InputPrimitiveType.String, isRequired: true, isDiscriminator: true)
],
discriminatedModels: new Dictionary<string, InputModelType>() { { "one", derivedInputModel } });

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

var model = ScmCodeModelGenerator.Instance.OutputLibrary.TypeProviders
.OfType<ScmModel>().Single(t => t.Name == "BaseModel");

model.ProcessTypeForBackCompatibility();

// The model gains the restored standalone parameterless constructor.
var modelContent = new TypeProviderWriter(model).Write().Content;
Assert.AreEqual(Helpers.GetExpectedFromFile("Model"), modelContent);

// The serialization partial no longer carries the parameterless mocking constructor (avoids CS0111).
var serializationContent = new TypeProviderWriter(model.SerializationProviders.Single()).Write().Content;
Assert.AreEqual(Helpers.GetExpectedFromFile("Serialization"), serializationContent);
}

[Test]
public async Task BackCompat_StructParameterlessConstructorNotMovedFromSerialization()
{
// A struct always exposes a public parameterless constructor via its serialization (mocking)
// constructor, so the last contract's parameterless constructor is already present. It must not
// be moved onto the model partial, which would be pointless churn with no public API change.
var inputModel = InputFactory.Model(
"structModel",
modelAsStruct: true,
properties:
[
InputFactory.Property("prop", InputPrimitiveType.String, isRequired: true)
]);

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

var model = ScmCodeModelGenerator.Instance.OutputLibrary.TypeProviders
.OfType<ScmModel>().Single(t => t.Name == "StructModel");

model.ProcessTypeForBackCompatibility();

Assert.IsFalse(model.Constructors.Any(c => c.Signature.Parameters.Count == 0),
"Struct model must not gain a parameterless constructor on the model partial.");
Assert.IsTrue(model.SerializationProviders.Single().Constructors.Any(c => c.Signature.Parameters.Count == 0),
"Struct serialization partial must retain its parameterless constructor.");
}

[Test]
public void TestDynamicModelWithUnionAdditionalProps()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// <auto-generated/>

#nullable disable

using System;
using System.Collections.Generic;

namespace Sample.Models
{
public abstract partial class BaseModel
{
private protected readonly global::System.Collections.Generic.IDictionary<string, global::System.BinaryData> _additionalBinaryDataProperties;

private protected BaseModel(string kind)
{
Kind = kind;
}

internal BaseModel(string kind, global::System.Collections.Generic.IDictionary<string, global::System.BinaryData> additionalBinaryDataProperties)
{
Kind = kind;
_additionalBinaryDataProperties = additionalBinaryDataProperties;
}

protected BaseModel() : this(default)
{
}

internal string Kind { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// <auto-generated/>

#nullable disable

using System;
using System.ClientModel.Primitives;
using System.Text.Json;
using Sample;

namespace Sample.Models
{
[global::System.ClientModel.Primitives.PersistableModelProxyAttribute(typeof(global::Sample.Models.UnknownBaseModel))]
public abstract partial class BaseModel : global::System.ClientModel.Primitives.IJsonModel<global::Sample.Models.BaseModel>
{
protected virtual global::Sample.Models.BaseModel PersistableModelCreateCore(global::System.BinaryData data, global::System.ClientModel.Primitives.ModelReaderWriterOptions options)
{
string format = (options.Format == "W") ? ((global::System.ClientModel.Primitives.IPersistableModel<global::Sample.Models.BaseModel>)this).GetFormatFromOptions(options) : options.Format;
switch (format)
{
case "J":
using (global::System.Text.Json.JsonDocument document = global::System.Text.Json.JsonDocument.Parse(data, global::Sample.ModelSerializationExtensions.JsonDocumentOptions))
{
return global::Sample.Models.BaseModel.DeserializeBaseModel(document.RootElement, options);
}
default:
throw new global::System.FormatException($"The model {nameof(global::Sample.Models.BaseModel)} does not support reading '{options.Format}' format.");
}
}

protected virtual global::System.BinaryData PersistableModelWriteCore(global::System.ClientModel.Primitives.ModelReaderWriterOptions options)
{
string format = (options.Format == "W") ? ((global::System.ClientModel.Primitives.IPersistableModel<global::Sample.Models.BaseModel>)this).GetFormatFromOptions(options) : options.Format;
switch (format)
{
case "J":
return global::System.ClientModel.Primitives.ModelReaderWriter.Write(this, options, global::Sample.SampleContext.Default);
default:
throw new global::System.FormatException($"The model {nameof(global::Sample.Models.BaseModel)} does not support writing '{options.Format}' format.");
}
}

global::System.BinaryData global::System.ClientModel.Primitives.IPersistableModel<global::Sample.Models.BaseModel>.Write(global::System.ClientModel.Primitives.ModelReaderWriterOptions options) => this.PersistableModelWriteCore(options);

global::Sample.Models.BaseModel global::System.ClientModel.Primitives.IPersistableModel<global::Sample.Models.BaseModel>.Create(global::System.BinaryData data, global::System.ClientModel.Primitives.ModelReaderWriterOptions options) => this.PersistableModelCreateCore(data, options);

string global::System.ClientModel.Primitives.IPersistableModel<global::Sample.Models.BaseModel>.GetFormatFromOptions(global::System.ClientModel.Primitives.ModelReaderWriterOptions options) => "J";

void global::System.ClientModel.Primitives.IJsonModel<global::Sample.Models.BaseModel>.Write(global::System.Text.Json.Utf8JsonWriter writer, global::System.ClientModel.Primitives.ModelReaderWriterOptions options)
{
writer.WriteStartObject();
this.JsonModelWriteCore(writer, options);
writer.WriteEndObject();
}

protected virtual void JsonModelWriteCore(global::System.Text.Json.Utf8JsonWriter writer, global::System.ClientModel.Primitives.ModelReaderWriterOptions options)
{
string format = (options.Format == "W") ? ((global::System.ClientModel.Primitives.IPersistableModel<global::Sample.Models.BaseModel>)this).GetFormatFromOptions(options) : options.Format;
if ((format != "J"))
{
throw new global::System.FormatException($"The model {nameof(global::Sample.Models.BaseModel)} does not support writing '{format}' format.");
}
writer.WritePropertyName("kind"u8);
writer.WriteStringValue(Kind);
if (((options.Format != "W") && (_additionalBinaryDataProperties != null)))
{
foreach (var item in _additionalBinaryDataProperties)
{
writer.WritePropertyName(item.Key);
#if NET6_0_OR_GREATER
writer.WriteRawValue(item.Value);
#else
using (global::System.Text.Json.JsonDocument document = global::System.Text.Json.JsonDocument.Parse(item.Value))
{
global::System.Text.Json.JsonSerializer.Serialize(writer, document.RootElement);
}
#endif
}
}
}

global::Sample.Models.BaseModel global::System.ClientModel.Primitives.IJsonModel<global::Sample.Models.BaseModel>.Create(ref global::System.Text.Json.Utf8JsonReader reader, global::System.ClientModel.Primitives.ModelReaderWriterOptions options) => this.JsonModelCreateCore(ref reader, options);

protected virtual global::Sample.Models.BaseModel JsonModelCreateCore(ref global::System.Text.Json.Utf8JsonReader reader, global::System.ClientModel.Primitives.ModelReaderWriterOptions options)
{
string format = (options.Format == "W") ? ((global::System.ClientModel.Primitives.IPersistableModel<global::Sample.Models.BaseModel>)this).GetFormatFromOptions(options) : options.Format;
if ((format != "J"))
{
throw new global::System.FormatException($"The model {nameof(global::Sample.Models.BaseModel)} does not support reading '{format}' format.");
}
using global::System.Text.Json.JsonDocument document = global::System.Text.Json.JsonDocument.ParseValue(ref reader);
return global::Sample.Models.BaseModel.DeserializeBaseModel(document.RootElement, options);
}

internal static global::Sample.Models.BaseModel DeserializeBaseModel(global::System.Text.Json.JsonElement element, global::System.ClientModel.Primitives.ModelReaderWriterOptions options)
{
if ((element.ValueKind == global::System.Text.Json.JsonValueKind.Null))
{
return null;
}
if (element.TryGetProperty("kind"u8, out global::System.Text.Json.JsonElement discriminator))
{
switch (discriminator.GetString())
{
case "one":
return global::Sample.Models.DerivedModel.DeserializeDerivedModel(element, options);
}
}
return global::Sample.Models.UnknownBaseModel.DeserializeUnknownBaseModel(element, options);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Sample.Models
{
public abstract partial class BaseModel
{
/// <summary> Initializes a new instance of BaseModel. </summary>
protected BaseModel()
{
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Sample.Models
{
public partial struct StructModel
{
/// <summary> Initializes a new instance of StructModel. </summary>
public StructModel()
{
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,11 @@ public enum BackCompatibilityChangeCategory

/// <summary>A fixed enum member was re-added to preserve a member that existed in the last contract but is no longer produced by the current spec.</summary>
EnumMemberAddedFromLastContract,

/// <summary>A back-compat model constructor was re-added to preserve a public constructor that existed in the last contract but is no longer produced by the current spec.</summary>
ConstructorAddedFromLastContract,

/// <summary>A back-compat model constructor could not be reconstructed from the last contract and was skipped.</summary>
ConstructorAddedFromLastContractSkipped,
}
}
Loading
Loading