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 @@ -48,5 +48,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,
}
}
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.EmitterRpc;
Expand Down Expand Up @@ -774,6 +775,221 @@ protected internal override ConstructorProvider[] BuildConstructors()
return [.. constructors];
}

/// <summary>
/// Restores previously-published public constructors that the current generation would otherwise
/// drop. The primary scenario is a previously required property becoming optional: the corresponding
/// parameter is removed from the initialization constructor, which is a source-breaking change for
/// callers that construct the model positionally. When the previous public constructor can be safely
/// reconstructed - i.e. every one of its extra parameters still maps to a settable property whose
/// name and type are unchanged (or a property renamed via a codegen customization but keeping the
/// same type) - a back-compat overload is added that chains to the current public constructor and
/// assigns the extra properties.
/// </summary>
protected internal override IReadOnlyList<ConstructorProvider> BuildConstructorsForBackCompatibility(IEnumerable<ConstructorProvider> originalConstructors)
{
if (LastContractView?.Constructors is not { Count: > 0 } previousConstructors)
Comment thread
jorgerangel-msft marked this conversation as resolved.
{
return base.BuildConstructorsForBackCompatibility(originalConstructors);
}

var constructors = new List<ConstructorProvider>(base.BuildConstructorsForBackCompatibility(originalConstructors));
var restorablePropertyLookup = BuildRestorablePropertyLookup();

foreach (var previousConstructor in previousConstructors)
Comment thread
jorgerangel-msft marked this conversation as resolved.
{
if (!IsPublicApi(previousConstructor.Signature.Modifiers))
{
continue;
}

var previousParameters = previousConstructor.Signature.Parameters;

if (previousParameters.Count == 0)
{
continue;
}

if (BackCompatHelper.IsConstructorRemovalAcceptedInBaseline(this, previousConstructor.Signature))
{
continue;
}

// If a constructor with the same parameters already exists - either still generated or
// supplied by custom code (which lives in the canonical view) - there is nothing to restore.
if (constructors.Any(c => BackCompatHelper.ParametersMatch(c.Signature.Parameters, previousParameters))
|| CanonicalView.Constructors.Any(c => BackCompatHelper.ParametersMatch(c.Signature.Parameters, previousParameters)))
{
continue;
}

if (TryBuildRestoredConstructor(previousConstructor, constructors, restorablePropertyLookup, out var restoredConstructor))
{
constructors.Add(restoredConstructor);
CodeModelGenerator.Instance.Emitter.Info(
$"Restored constructor '{Name}({string.Join(", ", previousParameters.Select(p => p.Type.Name))})' to match last contract.",
BackCompatibilityChangeCategory.ConstructorAddedFromLastContract);
}
else
{
CodeModelGenerator.Instance.Emitter.Info(
$"Could not restore constructor '{Name}({string.Join(", ", previousParameters.Select(p => p.Type.Name))})' from the last contract; a property name or type has changed.",
BackCompatibilityChangeCategory.ConstructorAddedFromLastContractSkipped);
}
}

return constructors;
}

private bool TryBuildRestoredConstructor(
ConstructorProvider previousConstructor,
IReadOnlyList<ConstructorProvider> currentConstructors,
Dictionary<string, PropertyProvider> restorablePropertyLookup,
[NotNullWhen(true)] out ConstructorProvider? restoredConstructor)
{
restoredConstructor = null;
var previousParameters = previousConstructor.Signature.Parameters;

// Find the public constructor to chain to: its parameters must form an in-order subsequence of
// the previous constructor's parameters. Prefer the closest one.
ConstructorProvider? targetConstructor = null;
foreach (var candidate in currentConstructors)
{
if (!IsPublicApi(candidate.Signature.Modifiers)
|| candidate.Signature.Parameters.Count >= previousParameters.Count)
Comment thread
jorgerangel-msft marked this conversation as resolved.
{
continue;
}

// Check whether this candidate would improve on the current target before performing the
// more expensive subsequence lookup.
if ((targetConstructor == null
|| candidate.Signature.Parameters.Count > targetConstructor.Signature.Parameters.Count)
&& IsParameterSubsequence(candidate.Signature.Parameters, previousParameters))
{
targetConstructor = candidate;
}
}

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

var targetParameters = targetConstructor.Signature.Parameters;
var restoredParameters = new List<ParameterProvider>(previousParameters.Count);
var initializerArguments = new List<ParameterProvider>(targetParameters.Count);
var extraAssignments = new List<(PropertyProvider Property, ParameterProvider Parameter)>();
int targetIndex = 0;

foreach (var previousParameter in previousParameters)
{
if (targetIndex < targetParameters.Count
&& targetParameters[targetIndex].Equals(previousParameter))
Comment thread
jorgerangel-msft marked this conversation as resolved.
{
var keptParameter = PartialMethodCustomization.CloneParameterWithName(
targetParameters[targetIndex],
previousParameter.Name,
removeDefault: false,
validation: ParameterValidationType.None);
restoredParameters.Add(keptParameter);
initializerArguments.Add(keptParameter);
targetIndex++;
continue;
}

if (!restorablePropertyLookup.TryGetValue(previousParameter.Name, out var property)
|| !property.Type.AreNamesEqual(previousParameter.Type))
{
return false;
}

var restoredParameter = PartialMethodCustomization.CloneParameterWithName(
property.AsParameter,
previousParameter.Name,
removeDefault: true);

restoredParameters.Add(restoredParameter);
extraAssignments.Add((property, restoredParameter));
}

// Every target parameter must be consumed and at least one extra property must be assigned,
// otherwise the restored constructor would be redundant or would produce an invalid chained call.
if (targetIndex != targetParameters.Count || extraAssignments.Count == 0)
{
return false;
}

var bodyStatements = new List<MethodBodyStatement>(extraAssignments.Count);
foreach (var (property, parameter) in extraAssignments)
{
ValueExpression assignee = property.BackingField is null ? property : property.BackingField;
ValueExpression value = parameter;
if (CSharpType.RequiresToList(parameter.Type, property.Type))
{
value = parameter.Type.IsNullable ? value.NullConditional().ToList() : value.ToList();
}

bodyStatements.Add(assignee.Assign(value).Terminate());
}

var signature = new ConstructorSignature(
Type,
$"Initializes a new instance of {Type:C}",
previousConstructor.Signature.Modifiers,
restoredParameters,
initializer: new ConstructorInitializer(false, initializerArguments));

restoredConstructor = new ConstructorProvider(signature, bodyStatements, this);
return true;
}

private static bool IsParameterSubsequence(
IReadOnlyList<ParameterProvider> subset,
IReadOnlyList<ParameterProvider> full)
{
if (subset.Count > full.Count)
{
return false;
}

int matched = 0;
foreach (var parameter in full)
{
if (matched == subset.Count)
{
break;
}

if (subset[matched].Equals(parameter))
{
matched++;
}
}

return matched == subset.Count;
}

private Dictionary<string, PropertyProvider> BuildRestorablePropertyLookup()
{
var lookup = new Dictionary<string, PropertyProvider>();
foreach (var property in CanonicalView.Properties)
{
if (!IsPublicApi(property.Modifiers) || !property.Body.HasSetter || property.WireInfo == null)
{
continue;
}

lookup[property.AsParameter.Name] = property;

if (property.OriginalName != null)
{
lookup.TryAdd(property.OriginalName.ToVariableName(), property);
}
}

return lookup;
Comment thread
jorgerangel-msft marked this conversation as resolved.
}

/// <summary>
/// Determines if this model should have a dual constructor pattern.
/// This is needed when the model shares the same discriminator property name as its base model
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,12 +213,15 @@ public static MethodSignature BuildPartialSignature(
// Clones a ParameterProvider with a new name (and optionally without its default value)
// while preserving all generator metadata. Returns the source unchanged when no change is
// needed.
private static ParameterProvider CloneParameterWithName(
internal static ParameterProvider CloneParameterWithName(
ParameterProvider source,
string newName,
bool removeDefault)
bool removeDefault,
ParameterValidationType? validation = null)
{
if (source.Name == newName && !(removeDefault && source.DefaultValue != null))
if (source.Name == newName
&& !(removeDefault && source.DefaultValue != null)
&& (validation == null || validation == source.Validation))
{
return source;
}
Expand All @@ -238,7 +241,7 @@ private static ParameterProvider CloneParameterWithName(
initializationValue: source.InitializationValue,
location: source.Location,
wireInfo: source.WireInfo,
validation: source.Validation,
validation: validation ?? source.Validation,
inputParameter: source.InputParameter)
{
SpreadSource = source.SpreadSource,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,35 @@ public static bool IsMethodRemovalAcceptedInBaseline(TypeProvider enclosingType,
return true;
}

/// <summary>
/// Returns true when the removal of a previously-published constructor — identified by the
/// enclosing type's fully-qualified name and the exact parameter types — has been accepted in the
/// ApiCompat baseline, in which case back compatibility must not restore it. Constructors are
/// recorded in the baseline as the <c>.ctor</c> member of their declaring type. Emits an
/// informational log entry when a suppression is honored.
/// </summary>
public static bool IsConstructorRemovalAcceptedInBaseline(TypeProvider enclosingType, ConstructorSignature previousSignature)
Comment thread
jorgerangel-msft marked this conversation as resolved.
{
var parameterTypes = new CSharpType[previousSignature.Parameters.Count];
for (int i = 0; i < parameterTypes.Length; i++)
{
parameterTypes[i] = previousSignature.Parameters[i].Type;
}

if (CodeModelGenerator.Instance.SourceInputModel?.ApiCompatBaseline.IsMethodRemovalSuppressed(
enclosingType.Type.FullyQualifiedName,
".ctor",
parameterTypes) != true)
{
return false;
}

CodeModelGenerator.Instance.Emitter.Info(
$"Skipping back-compat for '{enclosingType.Type.FullyQualifiedName}..ctor'; removal is accepted in the ApiCompat baseline.",
BackCompatibilityChangeCategory.BaselineAcceptedRemovalSkipped);
return true;
}

/// <summary>
/// Finds the current method that has the same parameter set as <paramref name="previousSignature"/>
/// (matched by name and return type) but in a different order, or null when there is none.
Expand Down
Loading
Loading