diff --git a/tools/Test-WrapperDelta.ps1 b/tools/Test-WrapperDelta.ps1 new file mode 100644 index 00000000000..909d33cdd5e --- /dev/null +++ b/tools/Test-WrapperDelta.ps1 @@ -0,0 +1,153 @@ +<# +.SYNOPSIS +Deterministic delta-pagination gate (#3742). Drives a REAL compiled delta cmdlet through a stub +transport that fabricates a change set - no tenant data is read beyond one /me call. + +.DESCRIPTION +The runtime's session adapter cache is pre-seeded with an adapter whose HttpClient returns a +scripted change set: two @odata.nextLink pages, then a page carrying @odata.deltaLink. A separate +scripted response answers a resume request and returns a replacement link. The active +Connect-MgGraph session is used only as the cache KEY. + +Proves, deterministically: + 1. -All walks the change set to its terminal page and publishes the deltaLink + 2. without -All: first page only, exactly one warning, no link published + 3. -DeltaLink resumes from the literal link and publishes the replacement + 4. the variable is cleared at invocation start, so a run that ends without a terminal page + cannot leave a previous run's link readable + 5. no *DeltaWithToken command exists - the token form is a parameter set, not a command + +Requires an active or cached Graph session (any scopes - only /me is requested). + +.EXAMPLE +pwsh -NoProfile -File .\tools\Test-WrapperDelta.ps1 +#> +param([string]$Configuration = 'Release') +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path $PSScriptRoot -Parent +$psd1 = Join-Path $repoRoot "src\Files\wrapper\v1.0\bin\$Configuration\netstandard2.0\Microsoft.Graph.Wrapper.Files.psd1" +if (-not (Test-Path $psd1)) { + throw "no built module at $psd1 - run tools\Build-WrapperModule.ps1 -Module Files -Configuration $Configuration first" +} + +$fail = [System.Collections.Generic.List[string]]::new() +function Assert([bool]$ok, [string]$what) { + $tag = if ($ok) { 'PASS' } else { $script:fail.Add($what); 'FAIL' } + Write-Host "$tag $what" +} + +$probe = @" +`$ErrorActionPreference = 'Stop' +Import-Module Microsoft.Graph.Authentication +Import-Module '$psd1' +Connect-MgGraph -NoWelcome +`$null = Invoke-MgGraphRequest -Method GET -Uri 'https://graph.microsoft.com/v1.0/me' + +Add-Type -TypeDefinition @' +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +public class DeltaStubHandler : HttpMessageHandler +{ + public System.Collections.Generic.List Urls = new System.Collections.Generic.List(); + protected override Task SendAsync(HttpRequestMessage request, CancellationToken token) + { + string url = request.RequestUri.ToString(); + Urls.Add(url); + string body; + if (url.Contains("RESUMELINK")) + body = "{\"value\":[{\"id\":\"r1\"}],\"@odata.deltaLink\":\"https://graph.microsoft.com/v1.0/drives/d/items/i/delta?`$deltatoken=SECOND\"}"; + else if (url.Contains("PAGE3")) + body = "{\"value\":[{\"id\":\"c1\"}],\"@odata.deltaLink\":\"https://graph.microsoft.com/v1.0/drives/d/items/i/delta?`$deltatoken=FIRST\"}"; + else if (url.Contains("PAGE2")) + body = "{\"value\":[{\"id\":\"b1\"},{\"id\":\"b2\"}],\"@odata.nextLink\":\"https://graph.microsoft.com/v1.0/drives/d/items/i/delta?`$skiptoken=PAGE3\"}"; + else + body = "{\"value\":[{\"id\":\"a1\"},{\"id\":\"a2\"}],\"@odata.nextLink\":\"https://graph.microsoft.com/v1.0/drives/d/items/i/delta?`$skiptoken=PAGE2\"}"; + var resp = new HttpResponseMessage(HttpStatusCode.OK); + resp.Content = new StringContent(body, Encoding.UTF8, "application/json"); + return Task.FromResult(resp); + } +} +'@ + +function Get-LoadedAssembly([string]`$name) { + `$a = [System.AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { `$_.GetName().Name -eq `$name } | Select-Object -First 1 + if (-not `$a) { throw "assembly not loaded: `$name" } + `$a +} +`$runtimeAsm = Get-LoadedAssembly 'Microsoft.Graph.Wrapper.Runtime' +`$kiotaHttp = Get-LoadedAssembly 'Microsoft.Kiota.Http.HttpClientLibrary' +`$kiotaAbs = Get-LoadedAssembly 'Microsoft.Kiota.Abstractions' + +`$real = [Microsoft.Graph.PowerShell.Authentication.Helpers.HttpHelpers]::GetGraphHttpClient() +`$handler = [DeltaStubHandler]::new() +`$stub = [System.Net.Http.HttpClient]::new(`$handler) +`$anon = `$kiotaAbs.GetType('Microsoft.Kiota.Abstractions.Authentication.AnonymousAuthenticationProvider').GetConstructor([type[]]@()).Invoke(@()) +`$adapterType = `$kiotaHttp.GetType('Microsoft.Kiota.Http.HttpClientLibrary.HttpClientRequestAdapter') +`$adapter = `$adapterType.GetConstructors() | Where-Object { `$_.GetParameters().Count -eq 5 } | + ForEach-Object { `$_.Invoke(@(`$anon, `$null, `$null, `$stub, `$null)) } | Select-Object -First 1 +if (-not `$adapter) { throw 'no 5-parameter HttpClientRequestAdapter constructor - kiota surface changed' } + +`$cache = `$runtimeAsm.GetType('Microsoft.Graph.Wrapper.Runtime.SessionAdapterCache', `$true) +`$flags = [System.Reflection.BindingFlags]'NonPublic,Static' +`$cache.GetField('_key', `$flags).SetValue(`$null, `$real) +`$cache.GetField('_adapter', `$flags).SetValue(`$null, `$adapter) + +# --- 1. -All walks to the terminal page and publishes the link --- +`$handler.Urls.Clear() +`$dl = 'PRESET-STALE' +`$w1 = @() +`$all = @(Get-MgDriveItemDelta -DriveId d -DriveItemId i -All -DeltaLinkVariable dl -WarningVariable w1 -WarningAction SilentlyContinue) +`$r1 = [pscustomobject]@{ Test='all'; Items=`$all.Count; Requests=`$handler.Urls.Count; Warnings=`$w1.Count; Link="`$dl" } + +# --- 2. without -All: one page, one warning, nothing published --- +`$handler.Urls.Clear() +`$dl2 = 'PRESET-STALE' +`$w2 = @() +`$page1 = @(Get-MgDriveItemDelta -DriveId d -DriveItemId i -DeltaLinkVariable dl2 -WarningVariable w2 -WarningAction SilentlyContinue) +`$r2 = [pscustomobject]@{ Test='nopage'; Items=`$page1.Count; Requests=`$handler.Urls.Count; Warnings=`$w2.Count; Link="`$dl2" } + +# --- 3. resume from a link --- +`$handler.Urls.Clear() +`$dl3 = '' +`$w3 = @() +`$res = @(Get-MgDriveItemDelta -DeltaLink 'https://graph.microsoft.com/v1.0/drives/d/items/i/delta?RESUMELINK' -DeltaLinkVariable dl3 -WarningVariable w3 -WarningAction SilentlyContinue) +`$r3 = [pscustomobject]@{ Test='resume'; Items=`$res.Count; Requests=`$handler.Urls.Count; Warnings=`$w3.Count; Link="`$dl3"; FirstUrl=`$handler.Urls[0] } + +# --- 5. the token form is not a command --- +`$tokenCmds = @(Get-Command -Module Microsoft.Graph.Wrapper.Files -Name '*DeltaWithToken*' -ErrorAction SilentlyContinue).Count +`$r5 = [pscustomobject]@{ Test='notoken'; Count=`$tokenCmds } + +@(`$r1, `$r2, `$r3, `$r5) | ConvertTo-Json -Compress +"@ +$enc = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($probe)) +$out = & pwsh -NoProfile -NonInteractive -EncodedCommand $enc 2>&1 +$json = $out | Where-Object { $_ -match '^\[' } | Select-Object -Last 1 +if (-not $json) { Assert $false "probe produced no result: $(($out | Select-Object -Last 3) -join ' | ')" } +else { + $r = $json | ConvertFrom-Json + $a = $r | Where-Object Test -eq 'all' + Assert ($a.Items -eq 5) "-All returned the whole change set across 3 pages (got $($a.Items))" + Assert ($a.Requests -eq 3) "-All made exactly 3 requests (got $($a.Requests))" + Assert ($a.Warnings -eq 0) "-All emitted no warning (got $($a.Warnings))" + Assert ($a.Link -like '*deltatoken=FIRST*') "terminal deltaLink published to the variable (got '$($a.Link)')" + + $n = $r | Where-Object Test -eq 'nopage' + Assert ($n.Items -eq 2 -and $n.Requests -eq 1) "without -All: first page only, 1 request (items=$($n.Items) req=$($n.Requests))" + Assert ($n.Warnings -eq 1) "exactly one truncation warning (got $($n.Warnings))" + Assert ([string]::IsNullOrEmpty($n.Link)) "no terminal page reached: variable cleared, not left stale (got '$($n.Link)')" + + $s = $r | Where-Object Test -eq 'resume' + Assert ($s.FirstUrl -like '*RESUMELINK*') "resume issued the literal link supplied (got '$($s.FirstUrl)')" + Assert ($s.Items -eq 1 -and $s.Requests -eq 1) "resume returned its page in 1 request (items=$($s.Items) req=$($s.Requests))" + Assert ($s.Link -like '*deltatoken=SECOND*') "replacement deltaLink published (got '$($s.Link)')" + + $t = $r | Where-Object Test -eq 'notoken' + Assert ($t.Count -eq 0) "no *DeltaWithToken command exists (found $($t.Count))" +} + +'' +if ($fail.Count -eq 0) { 'RESULT: ALL PASS' } else { "RESULT: $($fail.Count) FAILURE(S)" } +exit $fail.Count diff --git a/tools/WrapperGenerator.Tests/EmitterTests.cs b/tools/WrapperGenerator.Tests/EmitterTests.cs index 3779dc82243..971138eed52 100644 --- a/tools/WrapperGenerator.Tests/EmitterTests.cs +++ b/tools/WrapperGenerator.Tests/EmitterTests.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Net.Http; using WrapperGenerator; using Xunit; @@ -119,6 +120,103 @@ public void DispatcherDeclaresAllOnListSetAndPassesPipelineStopThrough() Assert.Contains("catch (Exception ex) when (ex is not PipelineStoppedException && ex is not OperationCanceledException)", source); } + // Delta pins (#3742). A change-tracking read is a function by classification but a paged + // collection in practice, so it gets its own shape. The contract and its evidence live in + // tools/WrapperGenerator/docs/edge-cases/delta-edge-cases.md. + private static string EmitDeltaSource(IReadOnlySet? queryParams = null) + { + var naming = Naming.Resolve(new OperationInfo(HttpMethod.Get, "/users/delta()")); + return CmdletEmitter.EmitDelta(naming, new EmitContext("Test.Client"), + new CmdletEmitter.CallPlan("GetAsDeltaGetResponseAsync", "DeltaGetResponse", BodyTypeName: null), + queryParams ?? new HashSet { "$top", "$filter" }); + } + + // Initial sync and resume are one command with two parameter sets: the published SDK folds + // the token form into the canonical delta command rather than shipping it separately. + // -DeltaLink is universal, where a token argument exists on only a few routes. + [Fact] + public void DeltaEmitsSyncAndResumeParameterSets() + { + var source = EmitDeltaSource(); + + Assert.Contains("DefaultParameterSetName = \"DeltaSync\"", source); + Assert.Contains("[Parameter(Mandatory = true, ParameterSetName = \"Resume\")]", source); + Assert.Contains("public string DeltaLink { get; set; }", source); + Assert.Contains("ParameterSetName == \"Resume\"", source); + Assert.Contains(".WithUrl(DeltaLink).GetAsDeltaGetResponseAsync(", source); + + // Query options belong to the initial sync only. + Assert.Contains("[Parameter(Mandatory = false, ParameterSetName = \"DeltaSync\")]", source); + + // The token form must never appear as a parameter or a command of its own. + Assert.DoesNotContain("Token", source); + } + + // Resuming from a link must not demand the path ids the link already carries - the raw-URL + // builder discards them anyway. Found by the delta gate: with the ids mandatory in every set, + // -DeltaLink failed binding with "missing mandatory parameters". + [Fact] + public void DeltaScopesPathIdsToTheInitialSyncSet() + { + var naming = Naming.Resolve(new OperationInfo(HttpMethod.Get, "/users/{user-id}/todo/lists/{todoTaskList-id}/tasks/delta()")); + var source = CmdletEmitter.EmitDelta(naming, new EmitContext("Test.Client"), + new CmdletEmitter.CallPlan("GetAsDeltaGetResponseAsync", "DeltaGetResponse", BodyTypeName: null), + new HashSet()); + + Assert.Contains("[Parameter(Mandatory = true, Position = 0, ParameterSetName = \"DeltaSync\")]", source); + Assert.DoesNotContain("[Parameter(Mandatory = true, Position = 0)]", source); + } + + // The terminal link is published to a caller-named variable, the idiom this SDK already uses + // for returning a scalar beside a pipeline, and cleared first so a failed run cannot leave + // the previous run's link readable. + [Fact] + public void DeltaPublishesTerminalLinkAndClearsItFirst() + { + var source = EmitDeltaSource(); + + Assert.Contains("[Alias(\"DLV\")]", source); + Assert.Contains("public string? DeltaLinkVariable { get; set; }", source); + Assert.Contains("SessionState.PSVariable.Set(DeltaLinkVariable, null);", source); + Assert.Contains("SessionState.PSVariable.Set(DeltaLinkVariable, deltaLink);", source); + + // Clearing must precede the request, or a failure leaves a stale link behind. + Assert.True(source.IndexOf("SessionState.PSVariable.Set(DeltaLinkVariable, null);", StringComparison.Ordinal) + < source.IndexOf("result = ParameterSetName", StringComparison.Ordinal)); + } + + // Every terminal state is decided explicitly; a response carrying both links is refused + // rather than guessed at. + [Fact] + public void DeltaHandlesEveryTerminalState() + { + var source = EmitDeltaSource(); + + Assert.Contains("if (!string.IsNullOrEmpty(nextLink) && !string.IsNullOrEmpty(deltaLink))", source); + Assert.Contains("\"InvalidDeltaResponse\"", source); + Assert.Contains("if (string.IsNullOrEmpty(nextLink)) break;", source); + Assert.Contains("WriteWarning(\"More results are available. Use -All to return all pages.\");", source); + Assert.Contains("if (Stopping) break;", source); + Assert.Contains("if (this.IsParameterBound(nameof(Top)) && fetched >= Top) break;", source); + Assert.Contains("catch (Exception ex) when (ex is not PipelineStoppedException)", source); + + // Continuation re-applies headers only: the link already carries the query state. + var continuation = source[source.IndexOf(".WithUrl(nextLink)", StringComparison.Ordinal)..]; + Assert.DoesNotContain("QueryParameters", continuation); + } + + // Without a declared $top there is no cap to enforce, so no counter is emitted either - + // an unused variable would not compile cleanly and would be dead weight in every file. + [Fact] + public void DeltaWithoutTopEmitsNoCounter() + { + var source = EmitDeltaSource(new HashSet()); + + Assert.Contains("if (Stopping) break;", source); + Assert.DoesNotContain("fetched", source); + Assert.DoesNotContain("public int Top", source); + } + // Control: non-list shapes must not grow paging machinery. [Fact] public void NonListShapesEmitNoPagingMachinery() diff --git a/tools/WrapperGenerator/CmdletEmitter.cs b/tools/WrapperGenerator/CmdletEmitter.cs index c9a697ef6ba..ce5da30a89e 100644 --- a/tools/WrapperGenerator/CmdletEmitter.cs +++ b/tools/WrapperGenerator/CmdletEmitter.cs @@ -26,11 +26,17 @@ public static class CmdletEmitter private static string EscapeLiteral(string value) => value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal); - private static string PathParams(CmdletNaming naming) => - string.Join("\n", naming.PathParamNames.Select((name, i) => $$""" - [Parameter(Mandatory = true, Position = {{i}})] + // parameterSetName scopes the ids to one set. Only the delta shape needs it: resuming from a + // link must not demand path ids the link already carries, and which the raw-URL builder + // discards. Everywhere else the ids belong to every set, which is what null produces. + private static string PathParams(CmdletNaming naming, string? parameterSetName = null) + { + var setAttr = parameterSetName is null ? "" : $", ParameterSetName = \"{parameterSetName}\""; + return string.Join("\n", naming.PathParamNames.Select((name, i) => $$""" + [Parameter(Mandatory = true, Position = {{i}}{{setAttr}})] public string {{name}} { get; set; } = string.Empty; """)); + } private static string TargetId(CmdletNaming naming) => naming.PathParamNames.Count > 0 ? naming.PathParamNames[^1] : "null"; @@ -386,6 +392,154 @@ protected override void ProcessRecord() } } +"""; + } + + // A delta (change-tracking) read. It is a function by classification, but its response is a + // change set spread over nextLink pages and terminated by a deltaLink, so it gets its own + // shape rather than the function template: items are enumerated like a list, and the + // terminal link is published to a caller-named variable. The token form of the same + // operation (delta(token='{token}')) is NOT a separate command - it is this command's + // Resume parameter set, reached through -DeltaLink, which works for every delta route + // rather than only the five whose spec declares a token argument. + // Contract and evidence: docs/edge-cases/delta-edge-cases.md. + public static string EmitDelta(CmdletNaming naming, EmitContext ctx, CallPlan call, IReadOnlySet queryParamNames) + { + ArgumentNullException.ThrowIfNull(naming); + ArgumentNullException.ThrowIfNull(ctx); + ArgumentNullException.ThrowIfNull(call); + ArgumentNullException.ThrowIfNull(queryParamNames); + + // Query options belong to the initial sync only: a resume or continuation starts from a + // link that already encodes them, and a raw-URL builder ignores them anyway. + var applicable = CollectionQueryOptions.Where(o => queryParamNames.Contains(o.ODataName)).ToList(); + var queryParamDecls = string.Join("\n\n", applicable.Select(o => o.ParamDecl("DeltaSync"))); + var queryBindings = string.Join("\n\n", applicable.Select(o => o.Binding)); + + // -Top caps the total at whole-page granularity, as it does for list cmdlets; the counter + // exists only when the operation declares $top. + var hasTop = queryParamNames.Contains("$top"); + var fetchedDecl = hasTop ? "\n var fetched = 0;" : ""; + var fetchedAdd = hasTop ? "\n fetched += items.Count;" : ""; + var capGuard = hasTop + ? "\n if (this.IsParameterBound(nameof(Top)) && fetched >= Top) break;" + : ""; + var receiver = $"client.{naming.BuilderExpression}"; + var continuationConfig = HeaderBindingsFor(naming.HeaderParams, extraIndent: " ") + GenericHeadersBinding(" "); + + return $$""" +#nullable enable + +using System; +using System.Collections.Generic; +using System.Management.Automation; +using System.Net.Http; +using Microsoft.Graph.Wrapper.Runtime; +using {{ctx.ClientNamespace}}; +using {{ctx.ModelsNamespace}}; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Authentication; +using Microsoft.Kiota.Http.HttpClientLibrary; + +namespace {{ctx.CmdletNamespace}} +{ +{{RouteAttr(naming)}} + [Cmdlet({{naming.VerbsClass}}.{{naming.VerbName}}, "{{EscapeLiteral(naming.Noun)}}", DefaultParameterSetName = "DeltaSync")] + [OutputType(typeof({{call.ReturnTypeName}}))] + public class {{naming.ClassName}} : GraphClientCmdlet + { +{{PathParams(naming, "DeltaSync")}} + +{{AccessTokenParamDecl()}} + +{{queryParamDecls}} + + // Resumes a previous sync from the link that run published. Universal: every delta + // request builder accepts a raw URL, whereas a token argument exists on only a few. + [Parameter(Mandatory = true, ParameterSetName = "Resume")] + public string DeltaLink { get; set; } = string.Empty; + + // Follows @odata.nextLink through the change set. Without it only the first page returns, + // plus a warning when more pages exist. + [Parameter(Mandatory = false)] + public SwitchParameter All { get; set; } + + // Receives the @odata.deltaLink that terminates the change set, for the next sync round. + // A named variable is how this SDK already returns a scalar alongside a pipeline + // (-CountVariable on the published list cmdlets). + [Parameter(Mandatory = false)] + [Alias("DLV")] + public string? DeltaLinkVariable { get; set; } +{{HeaderParamDecls(naming)}} +{{GenericHeadersParamDecl()}} + + protected override void ProcessRecord() + { +{{AuthBlock}} + + // Cleared before the request so a failed or interrupted run cannot leave the previous + // run's link readable, which would silently resume from the wrong point. + if (this.IsParameterBound(nameof(DeltaLinkVariable))) + SessionState.PSVariable.Set(DeltaLinkVariable, null); + + {{call.ReturnTypeName}}? result; + try + { + result = ParameterSetName == "Resume" + ? {{receiver}}.WithUrl(DeltaLink).{{call.MethodName}}(requestConfiguration => + {{{continuationConfig}} + }).GetAwaiter().GetResult() + : {{EmitCallOn(receiver, naming, call.MethodName, null, queryBindings)}}.GetAwaiter().GetResult(); +{{fetchedDecl}} + while (true) + { + if (result?.Value is { } items) + { + WriteObject(items, enumerateCollection: true);{{fetchedAdd}} + } + + var nextLink = result?.OdataNextLink; + var deltaLink = result?.OdataDeltaLink; + + // A response cannot be both continued and terminated; treating one as + // authoritative would silently drop pages or resume from a partial set. + if (!string.IsNullOrEmpty(nextLink) && !string.IsNullOrEmpty(deltaLink)) + { + ThrowTerminatingError(new ErrorRecord( + new InvalidOperationException("The response carries both @odata.nextLink and @odata.deltaLink, which is not a valid delta response."), + "InvalidDeltaResponse", ErrorCategory.InvalidData, targetObject: null)); + return; + } + + if (!string.IsNullOrEmpty(deltaLink)) + { + if (this.IsParameterBound(nameof(DeltaLinkVariable))) + SessionState.PSVariable.Set(DeltaLinkVariable, deltaLink); + break; + } + + // No link of either kind: the change set ends here and there is nothing to + // publish for a next round. + if (string.IsNullOrEmpty(nextLink)) break; + + if (!All.IsPresent) + { + WriteWarning("More results are available. Use -All to return all pages."); + break; + } + + if (Stopping) break;{{capGuard}} + + result = {{receiver}}.WithUrl(nextLink).{{call.MethodName}}(requestConfiguration => + {{{continuationConfig}} + }).GetAwaiter().GetResult(); + } + } +{{CatchBlock(TargetId(naming))}} + } + } +} + """; } diff --git a/tools/WrapperGenerator/PowerShellWrapperGenerationService.cs b/tools/WrapperGenerator/PowerShellWrapperGenerationService.cs index adef43aa1b6..c3d9e71e6d3 100644 --- a/tools/WrapperGenerator/PowerShellWrapperGenerationService.cs +++ b/tools/WrapperGenerator/PowerShellWrapperGenerationService.cs @@ -107,6 +107,29 @@ private sealed record GetOperationRecord(CmdletNaming Naming, IOpenApiSchema Res public bool IsCollection => CollectionValueSchema is not null; } + private sealed record DeltaOperationRecord(CmdletNaming Naming, OpenApiOperation Operation, IReadOnlyList QueryParams, string PathTemplate) + { + // The parameterless form is the canonical command; the argument form is its resume path. + public bool IsResumeForm => !LastSegment(PathTemplate).Equals("delta()", StringComparison.Ordinal); + + // Both forms of one operation normalise to the same key, which is what pairs them. + public string PairKey => PathTemplate[..^LastSegment(PathTemplate).Length] + "delta()"; + } + + private static string LastSegment(string pathTemplate) + { + var i = pathTemplate.LastIndexOf('/'); + return i < 0 ? pathTemplate : pathTemplate[(i + 1)..]; + } + + // A change-tracking call, identified by shape rather than by a list of routes: the final + // segment is a call whose name is delta, in either its parameterless or argument form. + private static bool IsDeltaCall(string pathTemplate) + { + var last = LastSegment(pathTemplate); + return last.StartsWith("delta(", StringComparison.Ordinal) && last.EndsWith(')'); + } + public async Task GenerateAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -125,6 +148,9 @@ public async Task GenerateAsync(CancellationToken cancellationToken) // project references. var written = 0; var getOperations = new List(); + // Delta operations are held back for the same reason GETs are: the parameterless form and + // its token form are one command, so the decision needs both before either is emitted. + var deltaOperations = new List(); foreach (var (pathTemplate, pathItem) in document.Paths) { @@ -191,6 +217,12 @@ public async Task GenerateAsync(CancellationToken cancellationToken) // classes kiota generates beside the request builder. if (operationKind != OperationKind.Resource) { + if (operationKind == OperationKind.Function && IsDeltaCall(pathTemplate)) + { + deltaOperations.Add(new DeltaOperationRecord(cmdletNaming, operation, queryParams, pathTemplate)); + continue; + } + var operationSource = EmitOperationCall(cmdletNaming, ctx, operation, operationKind, queryParams, pathTemplate); if (operationSource is null) continue; @@ -305,6 +337,7 @@ public async Task GenerateAsync(CancellationToken cancellationToken) } written += await EmitGetOperationsAsync(getOperations, ctx, cancellationToken).ConfigureAwait(false); + written += await EmitDeltaOperationsAsync(deltaOperations, ctx, cancellationToken).ConfigureAwait(false); // All collisions for the run are reported together so one generation surfaces the // complete list; see docs/edge-cases/naming-edge-cases.md for how each kind is resolved. @@ -333,6 +366,47 @@ public async Task GenerateAsync(CancellationToken cancellationToken) // exactly one id, in either of the shapes Naming.IsListItemPair accepts. Everything else // keeps the standalone shape: singleton navs with no list (GET /users/{id}/calendar), // list-only endpoints such as delta queries, or an unexpected same-noun collision. + // One command per change-tracking operation. The published SDK does not ship the argument + // form as its own command - it folds it into the canonical delta command - so the argument + // form is emitted as this command's Resume parameter set and produces no file of its own. + // The pairing is DERIVED from route shape, never from a list of cmdlet names, and anything + // the rule cannot resolve fails generation rather than being silently kept or dropped: + // a wrong guess here would either invent a command the SDK does not ship or lose an + // operation, and both are worse than a build that stops and names the route. + private async Task EmitDeltaOperationsAsync(List deltaOperations, EmitContext ctx, CancellationToken cancellationToken) + { + var written = 0; + foreach (var pair in deltaOperations.GroupBy(d => d.PairKey, StringComparer.Ordinal)) + { + var canonical = pair.Where(d => !d.IsResumeForm).ToList(); + var resume = pair.Where(d => d.IsResumeForm).ToList(); + + if (canonical.Count == 0) + throw new InvalidOperationException( + $"delta resume form has no parameterless sibling to merge into: {string.Join(", ", resume.Select(r => r.PathTemplate))}"); + if (canonical.Count > 1) + throw new InvalidOperationException( + $"delta operation has {canonical.Count} parameterless forms, so the resume form cannot be attached unambiguously: {string.Join(", ", canonical.Select(c => c.PathTemplate))}"); + if (resume.Count > 1) + throw new InvalidOperationException( + $"delta operation has {resume.Count} resume forms: {string.Join(", ", resume.Select(r => r.PathTemplate))}"); + + var op = canonical[0]; + if (!TryResolveOperationReturnType(op.Operation, ctx, op.Naming, isAction: false, out var returnType, out var methodName, out var returnsStream)) + { + LogSkippedUnsupportedOperation("GET", op.Naming.BuilderExpression, "delta response schema is neither a resolvable entity nor a value-wrapping response"); + continue; + } + + var source = CmdletEmitter.EmitDelta(op.Naming, ctx, + new CmdletEmitter.CallPlan(methodName, returnType, BodyTypeName: null, returnsStream), + op.QueryParams.ToHashSet()); + written += await WriteCmdletFileAsync(op.Naming, source, cancellationToken).ConfigureAwait(false); + } + + return written; + } + private async Task EmitGetOperationsAsync(List getOperations, EmitContext ctx, CancellationToken cancellationToken) { var written = 0; diff --git a/tools/WrapperGenerator/docs/edge-cases/delta-edge-cases.md b/tools/WrapperGenerator/docs/edge-cases/delta-edge-cases.md new file mode 100644 index 00000000000..08b63b63931 --- /dev/null +++ b/tools/WrapperGenerator/docs/edge-cases/delta-edge-cases.md @@ -0,0 +1,74 @@ +# Delta (change tracking) edge cases + +Delta operations are classed by the spec as functions (`x-ms-docs-operation-type: function`) but +behave as paged collections, so they are emitted from their own shape rather than the function +template. This file records the decisions and the evidence behind them. + +## The command surface + +One command per change-tracking operation, with two parameter sets: + +| set | purpose | +|---|---| +| `DeltaSync` (default) | initial sync; binds the query options the operation declares | +| `Resume` | continues a previous sync from `-DeltaLink` | + +Universal parameters: `-All`, and `-DeltaLinkVariable` (alias `DLV`), which receives the +`@odata.deltaLink` that terminates the change set. + +## Why the token form is not its own command + +The spec declares two routes for the same operation - `delta()` and, on some resources, +`delta(token='{token}')`. The published SDK does not ship the argument form as a separate +command: it folds it into the canonical delta command. + +Measured on branch `feat/wrapper-pagination @ bc7f1e68cd`, POPULATION 11,719 emitted cmdlets and +11,116 v1.0 oracle rows: + +- 72 delta cmdlets existed: 67 `delta()` + 5 `delta(token=…)`, agreed by two independent + instruments (route attribute, and the kiota response-method call site). +- All 5 argument forms had exactly one parameterless sibling; none orphaned, none ambiguous. +- Each sibling mapped to exactly one published command; **no** argument form appeared anywhere + in the oracle - 0 commands named `*WithToken*`, 0 URIs carrying a delta token. +- CONTROL: 22 routes carry `delta` inside their ARGUMENTS (the Excel + `resizedRange(deltaRows=…,deltaColumns=…)` family). None were caught by the rule. + +So the argument form is emitted as the `Resume` parameter set of its sibling. The pairing is +derived from route shape - never a list of cmdlet names - and anything the rule cannot resolve +(orphan, ambiguous, multiple resume forms) **fails generation** naming the offending routes, +rather than silently keeping or dropping an operation. + +`-DeltaLink` rather than `-Token`: every generated delta request builder accepts a raw URL, so a +link resumes all 72 operations, whereas a token argument exists on 5. + +## Terminal states + +A delta response ends in one of four states, each handled explicitly: + +| response carries | behaviour | +|---|---| +| `@odata.nextLink` only | continue if `-All`; otherwise write one warning and stop | +| `@odata.deltaLink` only | stop, and publish the link if `-DeltaLinkVariable` is bound | +| neither | stop, publish nothing - there is no link for a next round | +| both | `ThrowTerminatingError` - a response cannot be both continued and terminated | + +A `-Top` cap or a pipeline stop reached before the terminal page ends the run **without** +publishing a link: a partial change set must not be resumable as though it were complete. The +variable is cleared at invocation start for the same reason, so a failed run cannot leave the +previous run's link readable. + +## Why a caller-scope variable, not global + +The published SDK's `-CountVariable` documents that it sets the variable in the **global** scope, +because its cmdlets are exported as functions and a function cannot modify its parent's scope. +Ours are compiled cmdlets, and the public dispatcher forwards through `InvokeCommand.InvokeScript` +with `useNewScope: false`. A compiled probe confirmed the value reaches the caller in all five +cases - direct, direct-global, dispatched, dispatched-global, and from inside a function - so the +global-scope fallback is not needed here. + +## Output shape + +Items are enumerated to the pipeline, like every other collection-returning cmdlet. Before this +change delta cmdlets wrote the whole response envelope, which incidentally exposed +`@odata.deltaLink` as a property; `-DeltaLinkVariable` replaces that access deliberately rather +than removing it.