internal/functions/format/ConvertFrom-RestDelta.ps1
|
function ConvertFrom-RestDelta { <# .SYNOPSIS Converts a delta response into the strongly typed delta object for that resource. .DESCRIPTION One converter for every delta endpoint, because the shape they add is identical: an optional "@removed" annotation, and for groups a "members@delta" list. Graph says what happened in an awkward way. A changed object carries no annotation at all, so "changed" is the absence of information; a vanished one carries "@removed" whose reason is confusingly named: (no annotation) -> Changed @removed.reason = changed -> Deleted (soft-deleted, still restorable) @removed.reason = deleted -> Purged (gone for good) That becomes the DeltaChangeType enum so callers switch on a name instead of probing for an annotation. PERFORMANCE. A delta page can hold thousands of objects, so this serializes the whole page ONCE and deserializes it ONCE, then walks the raw entries alongside the typed results to stamp the change type. The obvious per-object implementation costs three JSON round-trips each, which is minutes rather than seconds on a large tenant. The annotations are left in the JSON rather than stripped: DataContract ignores members it does not know, so removing them would only cost another copy of every object. A removed object arrives with little more than its id - Graph does not resend the rest of something that is gone. Expected, not a conversion failure. .PARAMETER InputObject The parsed delta response, or the individual value entries from one. .PARAMETER TargetType The delta type to produce, e.g. [PSMicrosoftEntraID.Users.UserDelta]. .EXAMPLE PS C:\> ConvertFrom-RestDelta -InputObject $response -TargetType ([PSMicrosoftEntraID.Users.UserDelta]) Turns one raw delta page into typed objects, each stamped with its ChangeType. #> [CmdletBinding()] param ( $InputObject, [Parameter(Mandatory = $true)] [type] $TargetType ) if (-not $InputObject) { return } $entries = if ($InputObject.PSObject.Properties['value']) { @($InputObject.value) } else { @($InputObject) } $entries = @($entries | Where-Object { $_ }) if ($entries.Count -eq 0) { return } # One serialize, one deserialize, for the whole page. # The unary comma keeps a single-entry page an array so the [] type still binds. $jsonString = , $entries | ConvertTo-Json -Depth 6 $arrayType = ($TargetType.MakeArrayType()) $byteArray = [System.Text.Encoding]::UTF8.GetBytes($jsonString) $stream = [System.IO.MemoryStream]::new($byteArray) $serializer = [System.Runtime.Serialization.Json.DataContractJsonSerializer]::new($arrayType) $converted = @($serializer.ReadObject($stream)) $hasMembers = $TargetType.GetProperty('MembersDelta') for ($i = 0; $i -lt $converted.Count; $i++) { $typed = $converted[$i] $raw = $entries[$i] if ($raw.PSObject.Properties['@removed']) { # See the reason mapping above - Graph's naming is inverted from intuition. $typed.ChangeType = if ([string]$raw.'@removed'.reason -eq 'deleted') { 'Purged' } else { 'Deleted' } } # members@delta cannot round-trip through DataContract: '@odata.type' does not # survive its naming rules. Built directly instead, and only for the one type # that has the property. if ($hasMembers -and $raw.PSObject.Properties['members@delta']) { $typed.MembersDelta = [PSMicrosoftEntraID.Groups.GroupMemberDelta[]] @( foreach ($member in @($raw.'members@delta')) { if (-not $member) { continue } [PSMicrosoftEntraID.Groups.GroupMemberDelta]@{ Id = [string]$member.id ObjectType = [string]$member.'@odata.type' Removed = [bool]$member.PSObject.Properties['@removed'] } }) } $typed } } |