Skip to content

Commit 72a95c2

Browse files
Generate back-compat overload when a service method gains a new optional non-body parameter (microsoft#10532)
Adding an optional parameter to a service method is non-breaking in TypeSpec but breaks generated C# clients (callers binding to the old method signature fail to resolve). The generator now mirrors the [Service-Driven Evolution](https://github.com/Azure/azure-sdk-for-net/blob/main/doc/DataPlaneCodeGeneration/ServiceDrivenEvolution.md#a-method-gets-a-new-optional-parameter) guidance and emits a hidden overload matching the previous contract's signature. ### Changes - **`ClientProvider.BuildMethodsForBackCompatibility`** — after parameter reordering, scans `LastContractView.Methods` for previous signatures whose parameters are a same-order subset of a current method, where every extra current parameter is optional and **non-body**. Emits a hidden `[EditorBrowsable(Never)]` `ScmMethodProvider` overload (same `ScmMethodKind` as the current method) that delegates to the current method via the `This.Invoke(string methodName, IReadOnlyList<ValueExpression> arguments)` snippet, passing `default` for each new parameter. Async overloads delegate without `await` so the back-compat method itself remains non-async. The reordering and new-optional-parameter passes are factored into private helpers (`ProcessBackCompatForParameterReordering` and `ProcessBackCompatForNewOptionalParameters`) which share a single `currentMethodSignatures` dictionary and mutate the same `materializedMethods` list. The new-optional-parameter pass skips `ScmMethodProvider`s with `Kind == CreateRequest` and only generates overloads for matched candidates whose `Kind` is `Convenience` or `Protocol`, inheriting that `Kind`/`ServiceMethod` from the current method. The candidate match collapses the `ScmMethodProvider` / `Convenience|Protocol` filter into the candidates loop's pattern match, and `currentMethodSignatures.TryAdd` is reused both for dedup and to make new overloads visible to subsequent iterations (no auxiliary lists or sets). - **Body parameters are intentionally excluded** per the linked guidance, since adding a body parameter typically reflects a schema change handled elsewhere. - **`BackCompatibilityChangeCategory.SvcMethodNewOptionalParameterOverloadAdded`** — new category surfaced in the emitter end-of-run summary. - **Tests** — `BackCompatibility_NewOptionalNonBodyParameterAdded`, `BackCompatibility_MultipleNewOptionalNonBodyParametersAdded`, `BackCompatibility_NewOptionalNonBodyParameterAddedWithModelBody` (covers an `InputModelType` request body), `BackCompatibility_NewOptionalNonBodyParameterAddedWithPathAndHeaderParameters` (covers a method whose previous contract mixes a path parameter with a required query and a required header), `BackCompatibility_NewOptionalBodyParameterDoesNotAddBackCompatOverload`, `BackCompatibility_NewRequiredParameterDoesNotAddBackCompatOverload`. All six tests run `TypeProviderWriter.Write()` on the affected client wrapped in a `FilteredMethodsTypeProvider` so the expected `.cs` fixtures under `TestData/ClientProviderTests/` only contain the affected operation's protocol + convenience + back-compat methods. - **Docs** — new "Client Methods" section in `backward-compatibility.md` showing the full generated client (current sync/async then back-compat sync/async) with one-sentence per-overload comments, the optional default shown as `default`, the `async` modifier on the current async method, and the back-compat delegating call using named arguments. Includes a note verifying [Azure SDK parameter ordering](https://azure.github.io/azure-sdk/dotnet_implementation.html#parameter-presence-and-ordering) and a note explaining why the async back-compat overload returns the `Task` directly without `await`. ### Example Previous contract: ```csharp public virtual ClientResult GetData(int p1, BinaryContent body, RequestOptions options = null); public virtual Task<ClientResult> GetDataAsync(int p1, BinaryContent body, RequestOptions options = null); ``` Current TypeSpec adds an optional `@query p2?: boolean`. Generated client: ```csharp // Current sync method generated from the updated TypeSpec. public virtual ClientResult GetData(int p1, BinaryContent body, bool? p2 = default, RequestOptions options = null) { /* ... */ } // Current async method generated from the updated TypeSpec. public virtual async Task<ClientResult> GetDataAsync(int p1, BinaryContent body, bool? p2 = default, RequestOptions options = null) { /* ... */ } // Back-compat sync overload matching the previous contract's signature. [EditorBrowsable(EditorBrowsableState.Never)] public virtual ClientResult GetData(int p1, BinaryContent body, RequestOptions options) => this.GetData(p1: p1, body: body, p2: default, options: options); // Back-compat async overload matching the previous contract's signature. [EditorBrowsable(EditorBrowsableState.Never)] public virtual Task<ClientResult> GetDataAsync(int p1, BinaryContent body, RequestOptions options) => this.GetDataAsync(p1: p1, body: body, p2: default, options: options); ``` Defaults are stripped from the back-compat signature to avoid ambiguous call sites with the current method. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jorgerangel-msft <102122018+jorgerangel-msft@users.noreply.github.com>
1 parent cb8f053 commit 72a95c2

17 files changed

Lines changed: 952 additions & 6 deletions

File tree

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

Lines changed: 163 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1256,16 +1256,29 @@ protected override ScmMethodProvider[] BuildMethods()
12561256

12571257
protected sealed override IReadOnlyList<MethodProvider> BuildMethodsForBackCompatibility(IEnumerable<MethodProvider> originalMethods)
12581258
{
1259+
List<MethodProvider> materializedMethods = [.. originalMethods];
1260+
12591261
if (LastContractView?.Methods == null || LastContractView.Methods.Count == 0)
12601262
{
1261-
return [.. originalMethods];
1263+
return materializedMethods;
12621264
}
12631265

1264-
var currentMethodSignatures = BuildCurrentMethodSignatures(originalMethods);
1266+
var currentMethodSignatures = BuildCurrentMethodSignatures(materializedMethods);
1267+
1268+
ProcessBackCompatForParameterReordering(materializedMethods, currentMethodSignatures);
1269+
ProcessBackCompatForNewOptionalParameters(materializedMethods, currentMethodSignatures);
1270+
1271+
return materializedMethods;
1272+
}
1273+
1274+
private void ProcessBackCompatForParameterReordering(
1275+
IList<MethodProvider> materializedMethods,
1276+
Dictionary<MethodSignature, MethodProvider> currentMethodSignatures)
1277+
{
12651278
var updatedSignatureToOriginal = new Dictionary<MethodSignature, MethodSignature>(MethodSignature.MethodSignatureComparer);
12661279
var methodsWithReorderedParams = new List<MethodProvider>();
12671280

1268-
foreach (var previousMethod in LastContractView.Methods)
1281+
foreach (var previousMethod in LastContractView!.Methods)
12691282
{
12701283
if (!ShouldProcessMethodForBackCompat(previousMethod.Signature, currentMethodSignatures))
12711284
{
@@ -1290,10 +1303,8 @@ protected sealed override IReadOnlyList<MethodProvider> BuildMethodsForBackCompa
12901303

12911304
if (methodsWithReorderedParams.Count > 0)
12921305
{
1293-
UpdateConvenienceMethodsForBackCompat(originalMethods, methodsWithReorderedParams, updatedSignatureToOriginal);
1306+
UpdateConvenienceMethodsForBackCompat(materializedMethods, methodsWithReorderedParams, updatedSignatureToOriginal);
12941307
}
1295-
1296-
return [.. originalMethods];
12971308
}
12981309

12991310
private Dictionary<MethodSignature, MethodProvider> BuildCurrentMethodSignatures(IEnumerable<MethodProvider> originalMethods)
@@ -1748,5 +1759,151 @@ private static void UpdateXmlDocProviderForParamReorder(
17481759
xmlDocs.Update(parameters: reorderedParamDocs);
17491760
}
17501761
}
1762+
1763+
private void ProcessBackCompatForNewOptionalParameters(
1764+
List<MethodProvider> methods,
1765+
Dictionary<MethodSignature, MethodProvider> currentMethodSignatures)
1766+
{
1767+
var currentMethodsByName = new Dictionary<string, List<MethodProvider>>();
1768+
foreach (var method in currentMethodSignatures.Values)
1769+
{
1770+
if (method is ScmMethodProvider { Kind: ScmMethodKind.CreateRequest })
1771+
{
1772+
continue;
1773+
}
1774+
1775+
if (!currentMethodsByName.TryGetValue(method.Signature.Name, out var list))
1776+
{
1777+
list = [];
1778+
currentMethodsByName[method.Signature.Name] = list;
1779+
}
1780+
list.Add(method);
1781+
}
1782+
1783+
foreach (var previousMethod in LastContractView!.Methods)
1784+
{
1785+
var previousSignature = previousMethod.Signature;
1786+
1787+
if (!previousSignature.Modifiers.HasFlag(MethodSignatureModifiers.Public) &&
1788+
!previousSignature.Modifiers.HasFlag(MethodSignatureModifiers.Protected))
1789+
{
1790+
continue;
1791+
}
1792+
1793+
if (currentMethodSignatures.ContainsKey(previousSignature) ||
1794+
!currentMethodsByName.TryGetValue(previousSignature.Name, out var candidates))
1795+
{
1796+
continue;
1797+
}
1798+
1799+
ScmMethodProvider? matchedCurrent = null;
1800+
foreach (var candidate in candidates)
1801+
{
1802+
if (candidate is ScmMethodProvider { Kind: ScmMethodKind.Convenience or ScmMethodKind.Protocol } scmCandidate &&
1803+
HasNewOptionalNonBodyParametersOnly(previousSignature, scmCandidate.Signature))
1804+
{
1805+
matchedCurrent = scmCandidate;
1806+
break;
1807+
}
1808+
}
1809+
1810+
if (matchedCurrent is null)
1811+
{
1812+
continue;
1813+
}
1814+
1815+
var overload = BuildBackCompatOverloadForNewOptionalParameters(previousMethod, matchedCurrent);
1816+
if (overload == null || !currentMethodSignatures.TryAdd(overload.Signature, overload))
1817+
{
1818+
continue;
1819+
}
1820+
1821+
methods.Add(overload);
1822+
CodeModelGenerator.Instance.Emitter.Debug(
1823+
$"Added back-compat overload for '{Name}.{previousSignature.Name}' to handle new optional parameter(s) introduced relative to the last contract.",
1824+
BackCompatibilityChangeCategory.SvcMethodNewOptionalParameterOverloadAdded);
1825+
}
1826+
}
1827+
1828+
// Returns true when currentSignature contains all parameters of previousSignature in the same
1829+
// relative order, every "extra" parameter is optional, and none of the extras are body parameters.
1830+
private static bool HasNewOptionalNonBodyParametersOnly(
1831+
MethodSignature previousSignature,
1832+
MethodSignature currentSignature)
1833+
{
1834+
if (currentSignature.Parameters.Count <= previousSignature.Parameters.Count)
1835+
{
1836+
return false;
1837+
}
1838+
1839+
if (previousSignature.ReturnType is null
1840+
? currentSignature.ReturnType is not null
1841+
: !previousSignature.ReturnType.AreNamesEqual(currentSignature.ReturnType))
1842+
{
1843+
return false;
1844+
}
1845+
1846+
// Walk current parameters and ensure previous parameters appear in the same relative order
1847+
// (matched by variable name and type), with every "extra" parameter being optional and non-body.
1848+
int previousIndex = 0;
1849+
for (int currentIndex = 0; currentIndex < currentSignature.Parameters.Count; currentIndex++)
1850+
{
1851+
var currentParam = currentSignature.Parameters[currentIndex];
1852+
1853+
if (previousIndex < previousSignature.Parameters.Count)
1854+
{
1855+
var previousParam = previousSignature.Parameters[previousIndex];
1856+
if (currentParam.Name.ToVariableName() == previousParam.Name.ToVariableName() &&
1857+
currentParam.Type.AreNamesEqual(previousParam.Type))
1858+
{
1859+
previousIndex++;
1860+
continue;
1861+
}
1862+
}
1863+
1864+
if (currentParam.DefaultValue is null)
1865+
{
1866+
return false;
1867+
}
1868+
1869+
if (currentParam.Location == ParameterLocation.Body)
1870+
{
1871+
return false;
1872+
}
1873+
}
1874+
1875+
return previousIndex == previousSignature.Parameters.Count;
1876+
}
1877+
1878+
private ScmMethodProvider? BuildBackCompatOverloadForNewOptionalParameters(
1879+
MethodProvider previousMethod,
1880+
ScmMethodProvider currentMethod)
1881+
{
1882+
var previousSignature = previousMethod.Signature;
1883+
var currentSignature = currentMethod.Signature;
1884+
1885+
var previousParamsByName = new Dictionary<string, ParameterProvider>();
1886+
foreach (var p in previousSignature.Parameters)
1887+
{
1888+
previousParamsByName.TryAdd(p.Name, p);
1889+
}
1890+
1891+
var arguments = new List<ValueExpression>(currentSignature.Parameters.Count);
1892+
foreach (var currentParam in currentSignature.Parameters)
1893+
{
1894+
ValueExpression value = previousParamsByName.TryGetValue(currentParam.Name, out var prevParam)
1895+
? prevParam
1896+
: (currentParam.DefaultValue ?? Default);
1897+
arguments.Add(PositionalReference(currentParam.Name, value));
1898+
}
1899+
1900+
return new ScmMethodProvider(
1901+
signature: MethodSignatureHelper.BuildBackCompatMethodSignature(previousSignature, hideMethod: true),
1902+
bodyStatements: Return(This.Invoke(currentSignature.Name, arguments)),
1903+
enclosingType: this,
1904+
methodKind: currentMethod.Kind,
1905+
xmlDocProvider: previousMethod.XmlDocs,
1906+
serviceMethod: currentMethod.ServiceMethod);
1907+
}
17511908
}
17521909
}

0 commit comments

Comments
 (0)