Source/Public/Compare-ObjectGraph.ps1

<#
.SYNOPSIS
    Compare Object Graph
 
.DESCRIPTION
    Deep compares two Object Graph and lists the differences between them.
 
.PARAMETER InputObject
    The input object that will be compared with the reference object (see: [-Reference] parameter).
 
    > [!NOTE]
    > Multiple input object might be provided via the pipeline.
    > The common PowerShell behavior is to unroll any array (aka list) provided by the pipeline.
    > To avoid a list of (root) objects to unroll, use the **comma operator**:
 
        ,$InputObject | Compare-ObjectGraph $Reference.
 
.PARAMETER Reference
    The reference that is used to compared with the input object (see: [-InputObject] parameter).
 
.PARAMETER PrimaryKey
    If supplied, dictionaries (including PSCustomObject or Component Objects) in a list are matched
    based on the values of the `-PrimaryKey` supplied.
 
.PARAMETER IsEqual
    If set, the cmdlet will return a boolean (`$true` or `$false`).
    As soon a Discrepancy is found, the cmdlet will immediately stop comparing further properties.
 
.PARAMETER MatchCase
    Unless the `-MatchCase` switch is provided, string values are considered case insensitive.
 
    > [!NOTE]
    > Dictionary keys are compared based on the `$Reference`.
    > if the `$Reference` is an object (PSCustomObject or component object), the key or name comparison
    > is case insensitive otherwise the comparer supplied with the dictionary is used.
 
.PARAMETER MatchType
    Unless the `-MatchType` switch is provided, a loosely (inclusive) comparison is done where the
    `$Reference` object is leading. Meaning `$Reference -eq $InputObject`:
 
        '1.0' -eq 1.0 # $false
        1.0 -eq '1.0' # $true (also $false if the `-MatchType` is provided)
 
.PARAMETER MatchOrder
    By default, items in a list and dictionary (including properties of an PSCustomObject or Component Object)
    are matched independent of the order. If the `-MatchOrder` switch is supplied the index of the concerned
    item (or property) is matched.
 
    > [!NOTE]
    > A `[HashTable]` type is unordered by design and therefore, regardless the `-MatchOrder` switch, the order
    > of the `[HashTable]` are always ignored.
 
    > [!NOTE]
    > Regardless of the `-MatchOrder` switch, indexed (defined by the [PrimaryKey] parameter) dictionaries
    (including PSCustomObject or Component Objects) in a list are matched independent of the order.
 
.PARAMETER MaxDepth
    The maximal depth to recursively compare each embedded property (default: 10).
#>

function Compare-ObjectGraph {
    [CmdletBinding(HelpUri='https://github.com/iRon7/ObjectGraphTools/blob/main/Docs/Compare-ObjectGraph.md')] param(

        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)]
        $InputObject,

        [Parameter(Mandatory = $true, Position=0)]
        $Reference,

        [String[]]$PrimaryKey,

        [Switch]$IsEqual,

        [Switch]$MatchCase,

        [Switch]$MatchType,

        [Switch]$MatchOrder,

        [Alias('Depth')][int]$MaxDepth = [PSNode]::DefaultMaxDepth
    )
    begin {
        function CompareObject(
            [PSNode]$ReferenceNode,
            [PSNode]$ObjectNode,
            [String[]]$PrimaryKey = $PrimaryKey,
            [Switch]$IsEqual      = $IsEqual,
            [Switch]$MatchCase    = $MatchCase,
            [Switch]$MatchType    = $MatchType,
            [Switch]$MatchOrder   = $MatchOrder
        ) {
            if ($MatchType) {
                if ($ObjectNode.ValueType -ne $ReferenceNode.ValueType) {
                    if ($IsEqual) { return $false }
                    [PSCustomObject]@{
                        Path        = $ObjectNode.PathName
                        Discrepancy = 'Type'
                        InputObject = $ObjectNode.ValueType
                        Reference   = $ReferenceNode.ValueType
                    }
                }
            }
            if ($ObjectNode -is [PSCollectionNode] -and $ReferenceNode -is [PSCollectionNode]) {
                if ($ObjectNode.Count -ne $ReferenceNode.Count) {
                    if ($IsEqual) { return $false }
                    [PSCustomObject]@{
                        Path        = $ObjectNode.PathName
                        Discrepancy = 'Size'
                        InputObject = $ObjectNode.Count
                        Reference   = $ReferenceNode.Count
                    }
                }
            }
            if ($ObjectNode -is [PSLeafNode] -and $ReferenceNode -is [PSLeafNode]) {
                $NotEqual = if ($MatchCase) { $ReferenceNode.Value -cne $ObjectNode.Value } else { $ReferenceNode.Value -ne $ObjectNode.Value }
                if ($NotEqual) { # $ReferenceNode dictates the type
                    if ($IsEqual) { return $false }
                    [PSCustomObject]@{
                        Path        = $ObjectNode.PathName
                        Discrepancy = 'Value'
                        InputObject = $ObjectNode.Value
                        Reference   = $ReferenceNode.Value
                    }
                }
            }
            elseif ($ObjectNode -is [PSListNode] -and $ReferenceNode -is [PSListNode]) {
                $ObjectItems      = $ObjectNode.ChildNodes
                $ReferenceItems   = $ReferenceNode.ChildNodes
                if ($ObjectItems.Count)    { $ObjectIndices    = [Collections.Generic.List[Int]]$ObjectItems.Name } else { $ObjectIndices      = @() }
                if ($ReferenceItems.Count) { $ReferenceIndices = [Collections.Generic.List[Int]]$ReferenceItems.Name } else { $ReferenceIndices = @() }
                if ($PrimaryKey) {
                    $ObjectDictionaries = [Collections.Generic.List[Int]]$ObjectItems.where{ $_ -is [PSMapNode] }.Name
                    if ($ObjectDictionaries.Count) {
                        $ReferenceDictionaries = [Collections.Generic.List[Int]]$ReferenceItems.where{ $_ -is [PSMapNode] }.Name
                        if ($ReferenceDictionaries.Count) {
                            foreach ($Key in $PrimaryKey) {
                                foreach($ObjectIndex in @($ObjectDictionaries)) {
                                    $ObjectItem = $ObjectItems[$ObjectIndex]
                                    foreach ($ReferenceIndex in $ReferenceDictionaries) {
                                        $ReferenceItem = $ReferenceItems[$ReferenceIndex]
                                        if ($ReferenceItem.GetItem($Key) -eq $ObjectItem.GetItem($Key)) {
                                            if (CompareObject -Reference $ReferenceItem -Object $ObjectItem -IsEqual) {
                                                $null = $ObjectDictionaries.Remove($ObjectIndex)
                                                $Null = $ReferenceDictionaries.Remove($ReferenceIndex)
                                                $null = $ObjectIndices.Remove($ObjectIndex)
                                                $Null = $ReferenceIndices.Remove($ReferenceIndex)
                                                break # Only match a single node
                                            }
                                        }
                                    }
                                }
                                foreach ($Key in $PrimaryKey) { # in case of any single leftovers where the key value doesn't match
                                    if($ObjectDictionaries.Count -eq 1 -and $ReferenceDictionaries.Count -eq 1) {
                                        $ObjectItem    = $ObjectItems[$ObjectDictionaries[0]]
                                        $ReferenceItem = $ReferenceItems[$ReferenceDictionaries[0]]
                                        $Compare = CompareObject -Reference $ReferenceItem -Object $ObjectItem
                                        if ($Compare -eq $false) { return $Compare } elseif ($Compare -ne $true) { $Compare }
                                        $ObjectDictionaries.Clear()
                                        $ReferenceDictionaries.Clear()
                                        $null = $ObjectIndices.Remove($ObjectDictionaries[0])
                                        $Null = $ReferenceIndices.Remove($ReferenceDictionaries[0])
                                    }
                                }
                            }
                        }
                    }
                }
                foreach($ObjectIndex in @($ObjectIndices)) {
                    $ObjectItem = $ObjectItems[$ObjectIndex]
                    foreach ($ReferenceIndex in $ReferenceIndices) {
                        $ReferenceItem = $ReferenceItems[$ReferenceIndex]
                        if (CompareObject -Reference $ReferenceItem -Object $ObjectItem -IsEqual) {
                            if ($MatchOrder -and $ObjectItem.Name -ne $ReferenceItem.Name) {
                                if ($IsEqual) { return $false }
                                [PSCustomObject]@{
                                    Path        = $ReferenceNode.PathName
                                    Discrepancy = 'Index'
                                    InputObject = $ObjectItem.Name
                                    Reference   = $ReferenceItem.Name
                                }
                            }
                            $null = $ObjectIndices.Remove($ObjectIndex)
                            $Null = $ReferenceIndices.Remove($ReferenceIndex)
                            break # Only match a single node
                        }
                    }
                }
                for ($i = 0; $i -lt [math]::max($ObjectIndices.Count, $ReferenceIndices.Count); $i++) {
                    $ObjectIndex    = if ($i -lt $ObjectIndices.Count)    { $ObjectIndices[$i] }
                    $ReferenceIndex = if ($i -lt $ReferenceIndices.Count) { $ReferenceIndices[$i] }
                    $ObjectItem     = if ($Null -ne $ObjectIndex)    { $ObjectItems[$ObjectIndex] }
                    $ReferenceItem  = if ($Null -ne $ReferenceIndex) { $ReferenceItems[$ReferenceIndex] }
                    if ($Null -eq $ObjectItem) {            # if ($IsEqual) { never happens as the size already differs
                        [PSCustomObject]@{
                            Path        = $ReferenceNode.PathName + "[$ReferenceIndex]"
                            Discrepancy = 'Value'
                            InputObject = $Null
                            Reference   = if ($ReferenceItem -eq 'Scalar') { $ReferenceItem.Value } else { "[$($ReferenceItem.ValueType)]" }
                        }
                    }
                    elseif ($Null -eq $ReferenceItem) {     # if ($IsEqual) { never happens as the size already differs
                        [PSCustomObject]@{
                            Path        = $ObjectNode.PathName + "[$ObjectIndex]"
                            Discrepancy = 'Value'
                            InputObject = if ($ObjectItem -eq 'Scalar') { $ObjectItem.Value } else { "[$($ObjectItem.ValueType)]" }
                            Reference   = $Null
                        }
                    }
                    else {
                        $Compare = CompareObject -Reference $ReferenceItem -Object $ObjectItem
                        if ($Compare -eq $false) { return $Compare } elseif ($Compare -ne $true) { $Compare }
                    }
                }
            }
            elseif ($ObjectNode -is [PSMapNode] -and $ReferenceNode -is [PSMapNode]) {
                $Found = [HashTable]::new() # (Case sensitive)
                $Order = if ($MatchOrder -and $ReferenceNode.ValueType.Name -ne 'HashTable') { [HashTable]::new() }
                $Index = 0
                if ($Order) { $ReferenceNode.Names.foreach{ $Order[$_] = $Index++ } }
                $Index = 0
                foreach ($ObjectItem in $ObjectNode.ChildNodes) {
                    if ($ReferenceNode.Contains($ObjectItem.Name)) {
                        $ReferenceItem = $ReferenceNode.GetChildNode($ObjectItem.Name)
                        $Found[$ReferenceItem.Name] = $true
                        if ($Order -and $Order[$ReferenceItem.Name] -ne $Index) {
                            if ($IsEqual) { return $false }
                            [PSCustomObject]@{
                                Path        = $ObjectItem.PathName
                                Discrepancy = 'Index'
                                InputObject = $Index
                                Reference   = $Order[$ReferenceItem.Name]
                            }
                        }
                        $Compare = CompareObject -Reference $ReferenceItem -Object $ObjectItem
                        if ($Compare -eq $false) { return $Compare } elseif ($Compare -ne $true) { $Compare }
                    }
                    else {
                        if ($IsEqual) { return $false }
                        [PSCustomObject]@{
                            Path        = $ObjectItem.PathName
                            Discrepancy = 'Exists'
                            InputObject = $true
                            Reference   = $false
                        }
                    }
                    $Index++
                }
                $ReferenceNode.Names.foreach{
                    if (-not $Found.Contains($_)) {
                        if ($IsEqual) { return $false }
                        [PSCustomObject]@{
                            Path        = $ReferenceNode.GetChildNode($_).PathName
                            Discrepancy = 'Exists'
                            InputObject = $false
                            Reference   = $true
                        }
                    }
                }
            }
            else {
                if ($IsEqual) { return $false }
                [PSCustomObject]@{
                    Path        = $ObjectNode.PathName
                    Discrepancy = 'Structure'
                    InputObject = $ObjectNode.ValueType.Name
                    Reference   = $ReferenceNode.ValueType.Name
                }
            }            if ($IsEqual) { return $true }
        }
        $ReferenceNode = [PSNode]::ParseInput($Reference, $MaxDepth)
    }
    process {
        $ObjectNode = [PSNode]::ParseInput($InputObject, $MaxDepth)
        $Arguments = @{
            ReferenceNode = $ReferenceNode
            ObjectNode    = $ObjectNode
            PrimaryKey    = $PrimaryKey
            IsEqual       = $IsEqual
            MatchCase     = $MatchCase
            MatchType     = $MatchType
            MatchOrder    = $MatchOrder
        }
        CompareObject @Arguments
    }
}