ObjectGraphTools.psm1
#Region Using using namespace System.Collections using namespace System.Collections.Generic using namespace System.Management.Automation using namespace System.Management.Automation.Language using namespace System.Linq.Expressions using namespace System.Reflection #EndRegion Using #Region Enum enum LogicalOperatorEnum { not = 0 and = 1 or = 2 xor = 3 } enum PSNodeStructure { Leaf = 0 List = 1 Map = 2 } enum PSNodeOrigin { Root = 0 List = 1 Map = 2 } enum ObjectCompareMode { Equals = 0 Compare = 1 Report = 2 } [Flags()] enum ObjectComparison { MatchCase = 1 MatchType = 2 IgnoreListOrder = 4 MatchMapOrder = 8 Descending = 128 } enum XdnType { Root = 0 Ancestor = 1 Index = 2 Child = 3 Descendant = 4 Equals = 5 Error = 99 } enum XdnColorName { Reset = 0 Regular = 1 Literal = 2 WildCard = 3 Operator = 4 Error = 99 } #EndRegion Enum #Region Class Class Abbreviate { hidden static [String]$Ellipses = [Char]0x2026 hidden [String] $Prefix hidden [String] $String hidden [String] $AndSoForth = [Abbreviate]::Ellipses hidden [String] $Suffix hidden [Int] $MaxLength Abbreviate([String]$Prefix, [String]$String, [Int]$MaxLength, [String]$AndSoForth, [String]$Suffix) { $this.Prefix = $Prefix $this.String = $String $this.MaxLength = $MaxLength $this.AndSoForth = $AndSoForth $this.Suffix = $Suffix } Abbreviate([String]$Prefix, [String]$String, [Int]$MaxLength, [String]$Suffix) { $this.Prefix = $Prefix $this.String = $String $this.MaxLength = $MaxLength $this.Suffix = $Suffix } Abbreviate([String]$String, [Int]$MaxLength) { $this.String = $String $this.MaxLength = $MaxLength } [String] ToString() { if ($this.MaxLength -le 0) { return $this.String } if ($this.String.Length -gt 3 * $this.MaxLength) { $this.String = $this.String.SubString(0, (3 * $this.MaxLength)) } # https://stackoverflow.com/q/78787537/1701026 $this.String = [Regex]::Replace($this.String, '\s+', ' ') if ($this.Prefix.Length + $this.String.Length + $this.Suffix.Length -gt $this.MaxLength) { $Length = $this.MaxLength - $this.Prefix.Length - $this.AndSoForth.Length - $this.Suffix.Length if ($Length -gt 0) { $this.String = $this.String.SubString(0, $Length) + $this.AndSoForth } else { $this.String = $this.AndSoForth } } return $this.Prefix + $this.String + $this.Suffix } } class LogicalTerm {} Class PSNodePath { hidden [PSNode[]]$Nodes hidden [String]$_String hidden PSNodePath($Nodes) { $this.Nodes = [PSNode[]]$Nodes } static [String] op_Addition([PSNodePath]$Path, [String]$String) { return "$Path" + $String } [Bool] Equals([Object]$Path) { if ($Path -is [PSNodePath]) { if ($this.Nodes.Count -ne $Path.Nodes.Count) { return $false } $Index = 0 foreach( $Node in $this.Nodes) { if ($Node.NodeOrigin -ne $Path.Nodes[$Index].NodeOrigin -or $Node.Name -ne $Path.Nodes[$Index].Name ) { return $false } $Index++ } return $true } elseif ($Path -is [String]) { return $this.ToString() -eq $Path } return $false } [String] ToString() { if ($Null -eq $this._String) { $Count = $this.Nodes.Count $this._String = if ($Count -gt 1) { $this.Nodes[-2].Path.ToString() } $Node = $this.Nodes[-1] $this._String += # Copy the new path into the current node if ($Node.NodeOrigin -eq 'List') { "[$($Node._Name)]" } elseif ($Node.NodeOrigin -eq 'Map') { $KeyExpression = [PSKeyExpression]$Node._Name if ($Count -le 2) { $KeyExpression } else { ".$KeyExpression" } } } return $this._String } } Class PSNode : IComparable { hidden static PSNode() { Use-ClassAccessors } static [int]$DefaultMaxDepth = 20 hidden $_Name [Int]$Depth hidden $_Value hidden [Int]$_MaxDepth = [PSNode]::DefaultMaxDepth [PSNode]$ParentNode [PSNode]$RootNode = $this hidden [Dictionary[String,Object]]$Cache = [Dictionary[String,Object]]::new() hidden [DateTime]$MaxDepthWarningTime # Warn ones per item branch static [PSNode] ParseInput($Object, $MaxDepth) { $Node = if ($Object -is [PSNode]) { $Object } else { if ($Null -eq $Object) { [PSLeafNode]::new($Object) } elseif ($Object -is [Management.Automation.PSCustomObject]) { [PSObjectNode]::new($Object) } elseif ($Object -is [Collections.IDictionary]) { [PSDictionaryNode]::new($Object) } elseif ($Object -is [Specialized.StringDictionary]) { [PSDictionaryNode]::new($Object) } elseif ($Object -is [Collections.ICollection]) { [PSListNode]::new($Object) } elseif ($Object -is [ValueType]) { [PSLeafNode]::new($Object) } elseif ($Object -is [String]) { [PSLeafNode]::new($Object) } elseif ($Object -is [ScriptBlock]) { [PSLeafNode]::new($Object) } elseif ($Object.PSObject.Properties) { [PSObjectNode]::new($Object) } else { [PSLeafNode]::new($Object) } } $Node.RootNode = $Node if ($MaxDepth -gt 0) { $Node._MaxDepth = $MaxDepth } return $Node } static [PSNode] ParseInput($Object) { return [PSNode]::parseInput($Object, 0) } static [int] Compare($Left, $Right) { return [ObjectComparer]::new().Compare($Left, $Right) } static [int] Compare($Left, $Right, [String[]]$PrimaryKey) { return [ObjectComparer]::new($PrimaryKey, 0, [CultureInfo]::CurrentCulture).Compare($Left, $Right) } static [int] Compare($Left, $Right, [String[]]$PrimaryKey, [ObjectComparison]$ObjectComparison) { return [ObjectComparer]::new($PrimaryKey, $ObjectComparison, [CultureInfo]::CurrentCulture).Compare($Left, $Right) } static [int] Compare($Left, $Right, [String[]]$PrimaryKey, [ObjectComparison]$ObjectComparison, [CultureInfo]$CultureInfo) { return [ObjectComparer]::new($PrimaryKey, $ObjectComparison, $CultureInfo).Compare($Left, $Right) } hidden [object] get_Value() { return ,$this._Value } hidden set_Value($Value) { $this.Cache.Remove('ChildNodes') $this._Value = $Value if ($Null -ne $this.ParentNode) { $this.ParentNode.SetValue($this._Name, $Value) } if ($this.GetType() -ne [PSNode]::ParseInput($Value).GetType()) { # The root node is of type PSNode (always false) Write-Warning "The supplied value has a different PSNode type than the existing $($this.Path). Use .ParentNode.SetValue() method and reload its child item(s)." } } hidden [Object] get_Name() { return ,$this._Name } hidden [Object] get_MaxDepth() { return $this.RootNode._MaxDepth } hidden set_MaxDepth($MaxDepth) { if (-not $this.ChildType) { $this._MaxDepth = $MaxDepth } else { Throw 'The MaxDepth can only be set at the root node: [PSNode].RootNode.MaxDepth = <Maximum Depth>' } } hidden [PSNodeStructure] get_NodeStructure() { if ($this -is [PSListNode]) { return 'List' } elseif ($this -is [PSMapNode]) { return 'Map' } else { return 'Leaf' } } hidden [PSNodeOrigin] get_NodeOrigin() { if ($this.ParentNode -is [PSListNode]) { return 'List' } elseif ($this.ParentNode -is [PSMapNode]) { return 'Map' } else { return 'Root' } } hidden [Type] get_ValueType() { if ($Null -eq $this._Value) { return $Null } else { return $this._Value.getType() } } [Int]GetHashCode() { return $this.GetHashCode($false) } # Ignore the case of a string value hidden [Object] get_Path() { if (-not $this.Cache.ContainsKey('Path')) { if ($this.ParentNode) { $this.Cache['Path'] = [PSNodePath]($this.ParentNode.get_Path().Nodes + $this) } else { $this.Cache['Path'] = [PSNodePath]$this } } return $this.Cache['Path'] } [String] GetPathName($VariableName) { $PathName = $this.get_Path().ToString() if (-not $PathName) { return $VariableName } elseif ($PathName.StartsWith('.')) { return "$VariableName$PathName" } else { return "$VariableName.$PathName" } } [String] GetPathName() { return $this.get_Path().ToString() } hidden [String] get_Expression() { return [PSSerialize]$this } Remove() { if ($null -eq $this.ParentNode) { Throw "The root node can't be removed." } $this.ParentNode.RemoveAt($this.Name) $this.Cache.Remove('ChildNodes') } [Bool] Equals($Object) { # https://learn.microsoft.com/dotnet/api/system.globalization.compareoptions if ($Object -is [PSNode]) { $Node = $Object } else { $Node = [PSNode]::ParseInput($Object) } $ObjectComparer = [ObjectComparer]::new() return $ObjectComparer.IsEqual($this, $Node) } [int] CompareTo($Object) { if ($Object -is [PSNode]) { $Node = $Object } else { $Node = [PSNode]::ParseInput($Object) } $ObjectComparer = [ObjectComparer]::new() return $ObjectComparer.Compare($this, $Node) } hidden CollectNodes($NodeTable, [XdnPath]$Path, [Int]$PathIndex) { $Entry = $Path.Entries[$PathIndex] $NextIndex = if ($PathIndex -lt $Path.Entries.Count -1) { $PathIndex + 1 } $NextEntry = if ($NextIndex) { $Path.Entries[$NextIndex] } $Equals = if ($NextEntry -and $NextEntry.Key -eq 'Equals') { $NextEntry.Value $NextIndex = if ($NextIndex -lt $Path.Entries.Count -1) { $NextIndex + 1 } } switch ($Entry.Key) { Root { $Node = $this.RootNode if ($NextIndex) { $Node.CollectNodes($NodeTable, $Path, $NextIndex) } else { $NodeTable[$Node.getPathName()] = $Node } } Ancestor { $Node = $this for($i = $Entry.Value; $i -gt 0 -and $Node.ParentNode; $i--) { $Node = $Node.ParentNode } if ($i -eq 0) { # else: reached root boundary if ($NextIndex) { $Node.CollectNodes($NodeTable, $Path, $NextIndex) } else { $NodeTable[$Node.getPathName()] = $Node } } } Index { if ($this -is [PSListNode] -and [Int]::TryParse($Entry.Value, [Ref]$Null)) { $Node = $this.GetChildNode([Int]$Entry.Value) if ($NextIndex) { $Node.CollectNodes($NodeTable, $Path, $NextIndex) } else { $NodeTable[$Node.getPathName()] = $Node } } } Default { # Child, Descendant if ($this -is [PSListNode]) { # Member access enumeration foreach ($Node in $this.get_ChildNodes()) { $Node.CollectNodes($NodeTable, $Path, $PathIndex) } } elseif ($this -is [PSMapNode]) { $Found = $False foreach ($Value in $Entry.Value) { $Name = $Value._Value if ($Value.ContainsWildcard()) { foreach ($Node in $this.ChildNodes) { if ($Node.Name -like $Name -and (-not $Equals -or ($Node -is [PSLeafNode] -and $Equals -eq $Node._Value))) { $Found = $True if ($NextIndex) { $Node.CollectNodes($NodeTable, $Path, $NextIndex) } else { $NodeTable[$Node.getPathName()] = $Node } } } } elseif ($this.Contains($Name)) { $Node = $this.GetChildNode($Name) if (-not $Equals -or ($Node -is [PSLeafNode] -and $Equals -eq $Node._Value)) { $Found = $True if ($NextIndex) { $Node.CollectNodes($NodeTable, $Path, $NextIndex) } else { $NodeTable[$Node.getPathName()] = $Node } } } } if (-not $Found -and $Entry.Key -eq 'Descendant') { foreach ($Node in $this.ChildNodes) { $Node.CollectNodes($NodeTable, $Path, $PathIndex) } } } } } } [Object] GetNode([XdnPath]$Path) { $NodeTable = [system.collections.generic.dictionary[String, PSNode]]::new() # Case sensitive (case insensitive map nodes use the same name) $this.CollectNodes($NodeTable, $Path, 0) if ($NodeTable.Count -eq 0) { return @() } if ($NodeTable.Count -eq 1) { return $NodeTable[$NodeTable.Keys] } else { return [PSNode[]]$NodeTable.Values } } } class ObjectComparer { # Report properties (column names) [String]$Name1 = 'Reference' [String]$Name2 = 'InputObject' [String]$Issue = 'Discrepancy' [String[]]$PrimaryKey [ObjectComparison]$ObjectComparison [Collections.Generic.List[Object]]$Differences ObjectComparer () {} ObjectComparer ([String[]]$PrimaryKey) { $this.PrimaryKey = $PrimaryKey } ObjectComparer ([ObjectComparison]$ObjectComparison) { $this.ObjectComparison = $ObjectComparison } ObjectComparer ([String[]]$PrimaryKey, [ObjectComparison]$ObjectComparison) { $this.PrimaryKey = $PrimaryKey; $this.ObjectComparison = $ObjectComparison } ObjectComparer ([ObjectComparison]$ObjectComparison, [String[]]$PrimaryKey) { $this.PrimaryKey = $PrimaryKey; $this.ObjectComparison = $ObjectComparison } [bool] IsEqual ($Object1, $Object2) { return $this.Compare($Object1, $Object2, 'Equals') } [int] Compare ($Object1, $Object2) { return $this.Compare($Object1, $Object2, 'Compare') } [Object] Report ($Object1, $Object2) { $this.Differences = [Collections.Generic.List[Object]]::new() $null = $this.Compare($Object1, $Object2, 'Report') return $this.Differences } [Object] Compare($Object1, $Object2, [ObjectCompareMode]$Mode) { if ($Object1 -is [PSNode]) { $Node1 = $Object1 } else { $Node1 = [PSNode]::ParseInput($Object1) } if ($Object2 -is [PSNode]) { $Node2 = $Object2 } else { $Node2 = [PSNode]::ParseInput($Object2) } return $this.CompareRecurse($Node1, $Node2, $Mode) } hidden [Object] CompareRecurse([PSNode]$Node1, [PSNode]$Node2, [ObjectCompareMode]$Mode) { $Comparison = $this.ObjectComparison $MatchCase = $Comparison -band 'MatchCase' $EqualType = $true if ($Mode -ne 'Compare') { # $Mode -ne 'Compare' if ($MatchCase -and $Node1.ValueType -ne $Node2.ValueType) { if ($Mode -eq 'Equals') { return $false } else { # if ($Mode -eq 'Report') $this.Differences.Add([PSCustomObject]@{ Path = $Node2.Path $this.Issue = 'Type' $this.Name1 = $Node1.ValueType $this.Name2 = $Node2.ValueType }) } } if ($Node1 -is [PSCollectionNode] -and $Node2 -is [PSCollectionNode] -and $Node1.Count -ne $Node2.Count) { if ($Mode -eq 'Equals') { return $false } else { # if ($Mode -eq 'Report') $this.Differences.Add([PSCustomObject]@{ Path = $Node2.Path $this.Issue = 'Size' $this.Name1 = $Node1.Count $this.Name2 = $Node2.Count }) } } } if ($Node1 -is [PSLeafNode] -and $Node2 -is [PSLeafNode]) { $Eq = if ($MatchCase) { $Node1.Value -ceq $Node2.Value } else { $Node1.Value -eq $Node2.Value } Switch ($Mode) { Equals { return $Eq } Compare { if ($Eq) { return 1 - $EqualType } # different types results in 1 (-gt) else { $Greater = if ($MatchCase) { $Node1.Value -cgt $Node2.Value } else { $Node1.Value -gt $Node2.Value } if ($Greater -xor $Comparison -band 'Descending') { return 1 } else { return -1 } } } default { if (-not $Eq) { $this.Differences.Add([PSCustomObject]@{ Path = $Node2.Path $this.Issue = 'Value' $this.Name1 = $Node1.Value $this.Name2 = $Node2.Value }) } } } } elseif ($Node1 -is [PSListNode] -and $Node2 -is [PSListNode]) { $MatchOrder = -not ($Comparison -band 'IgnoreListOrder') # if ($Node1.GetHashCode($MatchCase) -eq $Node2.GetHashCode($MatchCase)) { # if ($Mode -eq 'Equals') { return $true } else { return 0 } # Report mode doesn't care about the output # } $Items1 = $Node1.ChildNodes $Items2 = $Node2.ChildNodes if ($Items1.Count) { $Indices1 = [Collections.Generic.List[Int]]$Items1.Name } else { $Indices1 = @() } if ($Items2.Count) { $Indices2 = [Collections.Generic.List[Int]]$Items2.Name } else { $Indices2 = @() } if ($this.PrimaryKey) { $Maps2 = [Collections.Generic.List[Int]]$Items2.where{ $_ -is [PSMapNode] }.Name if ($Maps2.Count) { $Maps1 = [Collections.Generic.List[Int]]$Items1.where{ $_ -is [PSMapNode] }.Name if ($Maps1.Count) { foreach ($Key in $this.PrimaryKey) { foreach($Index2 in @($Maps2)) { $Item2 = $Items2[$Index2] foreach ($Index1 in $Maps1) { $Item1 = $Items1[$Index1] if ($Item1.GetValue($Key) -eq $Item2.GetValue($Key)) { if ($this.CompareRecurse($Item1, $Item2, 'Equals')) { $null = $Maps2.Remove($Index2) $Null = $Maps1.Remove($Index1) $null = $Indices2.Remove($Index2) $Null = $Indices1.Remove($Index1) break # Only match the first primary key } } } } } # in case of any single maps leftover without primary keys if($Maps2.Count -eq 1 -and $Maps1.Count -eq 1) { $Item2 = $Items2[$Maps2[0]] $Item1 = $Items1[$Maps1[0]] $Compare = $this.CompareRecurse($Item1, $Item2, $Mode) Switch ($Mode) { Equals { if (-not $Compare) { return $Compare } } Compare { if ($Compare) { return $Compare } } Default { $Maps2.Clear() $Maps1.Clear() $null = $Indices2.Remove($Maps2[0]) $Null = $Indices1.Remove($Maps1[0]) } } } } } } if (-not $MatchOrder) { # remove the equal nodes from the lists foreach($Index2 in @($Indices2)) { $Item2 = $Items2[$Index2] foreach ($Index1 in $Indices1) { $Item1 = $Items1[$Index1] if ($this.CompareRecurse($Item1, $Item2, 'Equals')) { $null = $Indices2.Remove($Index2) $Null = $Indices1.Remove($Index1) break # Only match a single node } } } } for ($i = 0; $i -lt [math]::max($Indices2.Count, $Indices1.Count); $i++) { $Index1 = if ($i -lt $Indices1.Count) { $Indices1[$i] } $Index2 = if ($i -lt $Indices2.Count) { $Indices2[$i] } $Item1 = if ($Null -ne $Index1) { $Items1[$Index1] } $Item2 = if ($Null -ne $Index2) { $Items2[$Index2] } if ($Null -eq $Item1) { Switch ($Mode) { Equals { return $false } Compare { return -1 } # None existing items can't be ordered default { $this.Differences.Add([PSCustomObject]@{ Path = $Node2.Path + "[$Index2]" $this.Issue = 'Exists' $this.Name1 = $Null $this.Name2 = if ($Item2 -is [PSLeafNode]) { "$($Item2.Value)" } else { "[$($Item2.ValueType)]" } }) } } } elseif ($Null -eq $Item2) { Switch ($Mode) { Equals { return $false } Compare { return 1 } # None existing items can't be ordered default { $this.Differences.Add([PSCustomObject]@{ Path = $Node1.Path + "[$Index1]" $this.Issue = 'Exists' $this.Name1 = if ($Item1 -is [PSLeafNode]) { "$($Item1.Value)" } else { "[$($Item1.ValueType)]" } $this.Name2 = $Null }) } } } else { $Compare = $this.CompareRecurse($Item1, $Item2, $Mode) if (($Mode -eq 'Equals' -and -not $Compare) -or ($Mode -eq 'Compare' -and $Compare)) { return $Compare } } } if ($Mode -eq 'Equals') { return $true } elseif ($Mode -eq 'Compare') { return 0 } else { return $null } } elseif ($Node1 -is [PSMapNode] -and $Node2 -is [PSMapNode]) { $MatchOrder = [Bool]($Comparison -band 'MatchMapOrder') if ($MatchOrder -and $Node1._Value -isnot [HashTable] -and $Node2._Value -isnot [HashTable]) { $Items2 = $Node2.ChildNodes $Index = 0 foreach ($Item1 in $Node1.ChildNodes) { if ($Index -lt $Items2.Count) { $Item2 = $Items2[$Index++] } else { break } $EqualName = if ($MatchCase) { $Item1.Name -ceq $Item2.Name } else { $Item1.Name -eq $Item2.Name } if ($EqualName) { $Compare = $this.CompareRecurse($Item1, $Item2, $Mode) if (($Mode -eq 'Equals' -and -not $Compare) -or ($Mode -eq 'Compare' -and $Compare)) { return $Compare } } else { Switch ($Mode) { Equals { return $false } Compare {} # The order depends on the child name and value default { $this.Differences.Add([PSCustomObject]@{ Path = $Item1.Path $this.Issue = 'Name' $this.Name1 = $Item1.Name $this.Name2 = $Item2.Name }) } } } } } else { $Found = [HashTable]::new() # (Case sensitive) foreach ($Item2 in $Node2.ChildNodes) { if ($Node1.Contains($Item2.Name)) { $Item1 = $Node1.GetChildNode($Item2.Name) # Left defines the comparer $Found[$Item1.Name] = $true $Compare = $this.CompareRecurse($Item1, $Item2, $Mode) if (($Mode -eq 'Equals' -and -not $Compare) -or ($Mode -eq 'Compare' -and $Compare)) { return $Compare } } else { Switch ($Mode) { Equals { return $false } Compare { return -1 } default { $this.Differences.Add([PSCustomObject]@{ Path = $Item2.Path $this.Issue = 'Exists' $this.Name1 = $false $this.Name2 = $true }) } } } } foreach ($Name in $Node1.Names) { if (-not $Found.Contains($Name)) { Switch ($Mode) { Equals { return $false } Compare { return 1 } default { $this.Differences.Add([PSCustomObject]@{ Path = $Node1.GetChildNode($Name).Path $this.Issue = 'Exists' $this.Name1 = $true $this.Name2 = $false }) } } } } } if ($Mode -eq 'Equals') { return $true } elseif ($Mode -eq 'Compare') { return 0 } else { return $null } } else { # Different structure Switch ($Mode) { Equals { return $false } Compare { # Structure order: PSLeafNode - PSListNode - PSMapNode (can't be reversed) if ($Node1 -is [PSLeafNode] -or $Node2 -isnot [PSMapNode] ) { return -1 } else { return 1 } } default { $this.Differences.Add([PSCustomObject]@{ Path = $Node1.Path $this.Issue = 'Structure' $this.Name1 = $Node1.ValueType.Name $this.Name2 = $Node2.ValueType.Name }) } } } if ($Mode -eq 'Equals') { throw 'Equals comparison should have returned boolean.' } if ($Mode -eq 'Compare') { throw 'Compare comparison should have returned integer.' } return $null } } class PSMapNodeComparer : IComparer[Object] { [String[]]$PrimaryKey [ObjectComparison]$ObjectComparison PSMapNodeComparer () {} PSMapNodeComparer ([String[]]$PrimaryKey) { $this.PrimaryKey = $PrimaryKey } PSMapNodeComparer ([ObjectComparison]$ObjectComparison) { $this.ObjectComparison = $ObjectComparison } PSMapNodeComparer ([String[]]$PrimaryKey, [ObjectComparison]$ObjectComparison) { $this.PrimaryKey = $PrimaryKey; $this.ObjectComparison = $ObjectComparison } PSMapNodeComparer ([ObjectComparison]$ObjectComparison, [String[]]$PrimaryKey) { $this.PrimaryKey = $PrimaryKey; $this.ObjectComparison = $ObjectComparison } [int] Compare ([Object]$Node1, [Object]$Node2) { $Comparison = $this.ObjectComparison $MatchCase = $Comparison -band 'MatchCase' $Equal = if ($MatchCase) { $Node1.Name -ceq $Node2.Name } else { $Node1.Name -eq $Node2.Name } if ($Equal) { return 0 } else { if ($this.PrimaryKey) { # Primary keys take always priority if ($this.PrimaryKey -eq $Node1.Name) { return -1 } if ($this.PrimaryKey -eq $Node2.Name) { return 1 } } $Greater = if ($MatchCase) { $Node1.Name -cgt $Node2.Name } else { $Node1.Name -gt $Node2.Name } if ($Greater -xor $Comparison -band 'Descending') { return 1 } else { return -1 } } } } Class PSDeserialize { hidden static [String[]]$Parameters = 'LanguageMode', 'ArrayType', 'HashTableType' hidden static PSDeserialize() { Use-ClassAccessors } hidden $_Object [PSLanguageMode]$LanguageMode = 'Restricted' [Type]$ArrayType = 'Array' -as [Type] [Type]$HashTableType = 'HashTable' -as [Type] [String] $Expression PSDeserialize([String]$Expression) { $this.Expression = $Expression } PSDeserialize( $Expression, $LanguageMode = 'Restricted', $ArrayType = $Null, $HashTableType = $Null ) { if ($this.LanguageMode -eq 'NoLanguage') { Throw 'The language mode "NoLanguage" is not supported.' } $this.Expression = $Expression $this.LanguageMode = $LanguageMode if ($Null -ne $ArrayType) { $this.ArrayType = $ArrayType } if ($Null -ne $HashTableType) { $this.HashTableType = $HashTableType } } hidden [Object] get_Object() { if ($Null -eq $this._Object) { $Ast = [System.Management.Automation.Language.Parser]::ParseInput($this.Expression, [ref]$null, [ref]$Null) $this._Object = $this.ParseAst([Ast]$Ast) } return $this._Object } hidden [Object] ParseAst([Ast]$Ast) { # Write-Host 'Ast type:' "$($Ast.getType())" $Type = $Null if ($Ast -is [ConvertExpressionAst]) { $FullTypeName = $Ast.Type.TypeName.FullName if ( $this.LanguageMode -eq 'Full' -or ( $this.LanguageMode -eq 'Constrained' -and [PSLanguageType]::IsConstrained($FullTypeName) ) ) { try { $Type = $FullTypeName -as [Type] } catch { write-error $_ } } $Ast = $Ast.Child } if ($Ast -is [ScriptBlockAst]) { $List = [List[Object]]::new() if ($Null -ne $Ast.BeginBlock) { $Ast.BeginBlock.Statements.ForEach{ $List.Add($this.ParseAst($_)) } } if ($Null -ne $Ast.ProcessBlock) { $Ast.ProcessBlock.Statements.ForEach{ $List.Add($this.ParseAst($_)) } } if ($Null -ne $Ast.EndBlock) { $Ast.EndBlock.Statements.ForEach{ $List.Add($this.ParseAst($_)) } } if ($List.Count -eq 1) { return $List[0] } else { return @($List) } } elseif ($Ast -is [PipelineAst]) { $Elements = $Ast.PipelineElements if (-not $Elements.Count) { return @() } elseif ($Elements -is [CommandAst]) { return $Null #85 ConvertFrom-Expression: convert function/cmdlet calls to Objects } elseif ($Elements.Expression.Count -eq 1) { return $this.ParseAst($Elements.Expression[0]) } else { return $Elements.Expression.Foreach{ $this.ParseAst($_) } } } elseif ($Ast -is [ArrayLiteralAst] -or $Ast -is [ArrayExpressionAst]) { if (-not $Type -or 'System.Object[]', 'System.Array' -eq $Type.FullName) { $Type = $this.ArrayType } if ($Ast -is [ArrayLiteralAst]) { $Value = $Ast.Elements.foreach{ $this.ParseAst($_) } } else { $Value = $Ast.SubExpression.Statements.foreach{ $this.ParseAst($_) } } if ('System.Object[]', 'System.Array' -eq $Type.FullName) { if ($Value -isnot [Array]) { $Value = @($Value) } # Prevent single item array unrolls } else { $Value = $Value -as $Type } return $Value } elseif ($Ast -is [HashtableAst]) { if (-not $Type -or $Type.FullName -eq 'System.Collections.Hashtable') { $Type = $this.HashTableType } $IsPSCustomObject = "$Type" -in 'PSCustomObject', 'System.Management.Automation.PSCustomObject', 'PSObject', 'System.Management.Automation.PSObject' if ($Type.FullName -eq 'System.Collections.Hashtable') { $Map = @{} } # Case insensitive elseif ($IsPSCustomObject) { $Map = [Ordered]@{} } else { $Map = New-Object -Type $Type } $Ast.KeyValuePairs.foreach{ if ( $Map -is [Collections.IDictionary]) { $Map.Add($_.Item1.Value, $this.ParseAst($_.Item2)) } else { $Map."$($_.Item1.Value)" = $this.ParseAst($_.Item2) } } if ($IsPSCustomObject) { return [PSCustomObject]$Map } else { return $Map } } elseif ($Ast -is [ConstantExpressionAst]) { if ($Type) { $Value = $Ast.Value -as $Type } else { $Value = $Ast.Value } return $Value } elseif ($Ast -is [VariableExpressionAst]) { $Value = switch ($Ast.VariablePath.UserPath) { Null { $Null } True { $True } False { $False } PSCulture { (Get-Culture).ToString() } PSUICulture { (Get-UICulture).ToString() } Default { $Ast.Extent.Text } } return $Value } else { return $Null } } } Class PSInstance { static [Object]Create($Object) { if ($Null -eq $Object) { return $Null } elseif ($Object -is [String]) { $String = if ($Object.StartsWith('[') -and $Object.EndsWith(']')) { $Object.SubString(1, ($Object.Length - 2)) } else { $Object } Switch -Regex ($String) { '^((System\.)?String)?$' { return '' } '^(System\.)?Array$' { return ,@() } '^(System\.)?Object\[\]$' { return ,@() } '^((System\.)?Collections\.Hashtable\.)?hashtable$' { return @{} } '^((System\.)?Management\.Automation\.)?ScriptBlock$' { return {} } '^((System\.)?Collections\.Specialized\.)?Ordered(Dictionary)?$' { return [Ordered]@{} } '^((System\.)?Management\.Automation\.)?PS(Custom)?Object$' { return [PSCustomObject]@{} } } $Type = $String -as [Type] if (-not $Type) { Throw "Unknown type: [$Object]" } } elseif ($Object -is [Type]) { $Type = $Object.UnderlyingSystemType if ("$Type" -eq 'string') { Return '' } elseif ("$Type" -eq 'array') { Return ,@() } elseif ("$Type" -eq 'scriptblock') { Return {} } } else { if ($Object -is [Object[]]) { Return ,@() } elseif ($Object -is [ScriptBlock]) { Return {} } elseif ($Object -is [PSCustomObject]) { Return [PSCustomObject]::new() } $Type = $Object.GetType() } try { return [Activator]::CreateInstance($Type) } catch { throw $_ } } } Class PSKeyExpression { hidden static [Regex]$UnquoteMatch = '^[\?\*\p{L}\p{Lt}\p{Lm}\p{Lo}_][\?\*\p{L}\p{Lt}\p{Lm}\p{Lo}\p{Nd}_]*$' # https://stackoverflow.com/questions/62754771/unquoted-key-rules-and-best-practices hidden $Key hidden [PSLanguageMode]$LanguageMode = 'Restricted' hidden [Bool]$Compress hidden [Int]$MaxLength PSKeyExpression($Key) { $this.Key = $Key } PSKeyExpression($Key, [PSLanguageMode]$LanguageMode) { $this.Key = $Key; $this.LanguageMode = $LanguageMode } PSKeyExpression($Key, [PSLanguageMode]$LanguageMode, [Bool]$Compress) { $this.Key = $Key; $this.LanguageMode = $LanguageMode; $this.Compress = $Compress } PSKeyExpression($Key, [int]$MaxLength) { $this.Key = $Key; $this.MaxLength = $MaxLength } [String]ToString() { $Name = $this.Key if ($Name -is [byte] -or $Name -is [int16] -or $Name -is [int32] -or $Name -is [int64] -or $Name -is [sByte] -or $Name -is [uint16] -or $Name -is [uint32] -or $Name -is [uint64] -or $Name -is [float] -or $Name -is [double] -or $Name -is [decimal]) { return [Abbreviate]::new($Name, $this.MaxLength) } if ($this.MaxLength) { $Name = "$Name" } if ($Name -is [String]) { if ($Name -cMatch [PSKeyExpression]::UnquoteMatch) { return [Abbreviate]::new($Name, $this.MaxLength) } return "'$([Abbreviate]::new($Name.Replace("'", "''"), ($this.MaxLength - 2)))'" } $Node = [PSNode]::ParseInput($Name, 2) # There is no way to expand keys more than 2 levels return [PSSerialize]::new($Node, $this.LanguageMode, -$this.Compress) } } Class PSLanguageType { hidden static $_TypeCache = [Dictionary[String,Bool]]::new() hidden Static PSLanguageType() { # Hardcoded [PSLanguageType]::_TypeCache['System.Void'] = $True [PSLanguageType]::_TypeCache['System.Management.Automation.PSCustomObject'] = $True # https://github.com/PowerShell/PowerShell/issues/20767 } static [Bool]IsRestricted($TypeName) { if ($Null -eq $TypeName) { return $True } # Warning: a $Null is considered a restricted "type"! $Type = $TypeName -as [Type] if ($Null -eq $Type) { Throw 'Unknown type name: $TypeName' } $TypeName = $Type.FullName return $TypeName -in 'bool', 'array', 'hashtable' } static [Bool]IsConstrained($TypeName) { # https://stackoverflow.com/a/64806919/1701026 if ($Null -eq $TypeName) { return $True } # Warning: a $Null is considered a constrained "type"! $Type = $TypeName -as [Type] if ($Null -eq $Type) { Throw 'Unknown type name: $TypeName' } $TypeName = $Type.FullName if (-not [PSLanguageType]::_TypeCache.ContainsKey($TypeName)) { [PSLanguageType]::_TypeCache[$TypeName] = try { $ConstrainedSession = [PowerShell]::Create() $ConstrainedSession.RunSpace.SessionStateProxy.LanguageMode = 'Constrained' $ConstrainedSession.AddScript("[$TypeName]0").Invoke().Count -ne 0 -or $ConstrainedSession.Streams.Error[0].FullyQualifiedErrorId -ne 'ConversionSupportedOnlyToCoreTypes' } catch { $False } } return [PSLanguageType]::_TypeCache[$TypeName] } } Class PSSerialize { # hidden static [Dictionary[String,Bool]]$IsConstrainedType = [Dictionary[String,Bool]]::new() hidden static [Dictionary[String,Bool]]$HasStringConstructor = [Dictionary[String,Bool]]::new() hidden static [String]$AnySingleQuote = "'|$([char]0x2018)|$([char]0x2019)" # NoLanguage mode only hidden static [int]$MaxLeafLength = 48 hidden static [int]$MaxKeyLength = 12 hidden static [int]$MaxValueLength = 16 hidden static [int[]]$NoLanguageIndices = 0, 1, -1 hidden static [int[]]$NoLanguageItems = 0, 1, -1 hidden $_Object hidden [PSLanguageMode]$LanguageMode = 'Restricted' # "NoLanguage" will stringify the object for displaying (Use: PSStringify) hidden [Int]$ExpandDepth = [Int]::MaxValue hidden [Bool]$Explicit hidden [Bool]$FullTypeName hidden [bool]$HighFidelity hidden [String]$Indent = ' ' hidden [Bool]$ExpandSingleton # The dictionary below defines the round trip property. Unless the `-HighFidelity` switch is set, # the serialization will stop (even it concerns a `PSCollectionNode`) when the specific property # type is reached. # * An empty string will return the string representation of the object: `"<Object>"` # * Any other string will return the string representation of the object property: `"$(<Object>.<Property>)"` # * A ScriptBlock will be invoked and the result will be used for the object value hidden static $RoundTripProperty = @{ 'Microsoft.Management.Infrastructure.CimInstance' = '' 'Microsoft.Management.Infrastructure.CimSession' = 'ComputerName' 'Microsoft.PowerShell.Commands.ModuleSpecification' = 'Name' 'System.DateTime' = { $($Input).ToString('o') } 'System.DirectoryServices.DirectoryEntry' = 'Path' 'System.DirectoryServices.DirectorySearcher' = 'Filter' 'System.Globalization.CultureInfo' = 'Name' 'Microsoft.PowerShell.VistaCultureInfo' = 'Name' 'System.Management.Automation.AliasAttribute' = 'AliasNames' 'System.Management.Automation.ArgumentCompleterAttribute' = 'ScriptBlock' 'System.Management.Automation.ConfirmImpact' = '' 'System.Management.Automation.DSCResourceRunAsCredential' = '' 'System.Management.Automation.ExperimentAction' = '' 'System.Management.Automation.OutputTypeAttribute' = 'Type' 'System.Management.Automation.PSCredential' = { ,@($($Input).UserName, @("(""$($($Input).Password | ConvertFrom-SecureString)""", '|', 'ConvertTo-SecureString)')) } 'System.Management.Automation.PSListModifier' = 'Replace' 'System.Management.Automation.PSReference' = 'Value' 'System.Management.Automation.PSTypeNameAttribute' = 'PSTypeName' 'System.Management.Automation.RemotingCapability' = '' 'System.Management.Automation.ScriptBlock' = 'Ast' 'System.Management.Automation.SemanticVersion' = '' 'System.Management.Automation.ValidatePatternAttribute' = 'RegexPattern' 'System.Management.Automation.ValidateScriptAttribute' = 'ScriptBlock' 'System.Management.Automation.ValidateSetAttribute' = 'ValidValues' 'System.Management.Automation.WildcardPattern' = { $($Input).ToWql().Replace('%', '*').Replace('_', '?').Replace('[*]', '%').Replace('[?]', '_') } 'Microsoft.Management.Infrastructure.CimType' = '' 'System.Management.ManagementClass' = 'Path' 'System.Management.ManagementObject' = 'Path' 'System.Management.ManagementObjectSearcher' = { $($Input).Query.QueryString } 'System.Net.IPAddress' = 'IPAddressToString' 'System.Net.IPEndPoint' = { $($Input).Address.Address; $($Input).Port } 'System.Net.Mail.MailAddress' = 'Address' 'System.Net.NetworkInformation.PhysicalAddress' = '' 'System.Security.Cryptography.X509Certificates.X500DistinguishedName' = 'Name' 'System.Security.SecureString' = { ,[string[]]("(""$($Input | ConvertFrom-SecureString)""", '|', 'ConvertTo-SecureString)') } 'System.Text.RegularExpressions.Regex' = '' 'System.RuntimeType' = '' 'System.Uri' = 'OriginalString' 'System.Version' = '' 'System.Void' = $Null } hidden $StringBuilder hidden [Int]$Offset = 0 hidden [Int]$LineNumber = 1 PSSerialize($Object) { $this._Object = $Object } PSSerialize($Object, $LanguageMode) { $this._Object = $Object $this.LanguageMode = $LanguageMode } PSSerialize($Object, $LanguageMode, $ExpandDepth) { $this._Object = $Object $this.LanguageMode = $LanguageMode $this.ExpandDepth = $ExpandDepth } PSSerialize( $Object, $LanguageMode = 'Restricted', $ExpandDepth = [Int]::MaxValue, $Explicit = $False, $FullTypeName = $False, $HighFidelity = $False, $ExpandSingleton = $False, $Indent = ' ' ) { $this._Object = $Object $this.LanguageMode = $LanguageMode $this.ExpandDepth = $ExpandDepth $this.Explicit = $Explicit $this.FullTypeName = $FullTypeName $this.HighFidelity = $HighFidelity $this.ExpandSingleton = $ExpandSingleton $this.Indent = $Indent } hidden static [String[]]$Parameters = 'LanguageMode', 'Explicit', 'FullTypeName', 'HighFidelity', 'Indent', 'ExpandSingleton' PSSerialize($Object, [HashTable]$Parameters) { $this._Object = $Object foreach ($Name in $Parameters.get_Keys()) { # https://github.com/PowerShell/PowerShell/issues/13307 if ($Name -notin [PSSerialize]::Parameters) { Throw "Unknown parameter: $Name." } $this.GetType().GetProperty($Name).SetValue($this, $Parameters[$Name]) } } [String]Serialize($Object) { if ($this.LanguageMode -eq 'NoLanguage') { Throw 'The language mode "NoLanguage" is not supported.' } if (-not ('ConstrainedLanguage', 'FullLanguage' -eq $this.LanguageMode)) { if ($this.FullTypeName) { Write-Warning 'The FullTypeName switch requires Constrained - or FullLanguage mode.' } if ($this.Explicit) { Write-Warning 'The Explicit switch requires Constrained - or FullLanguage mode.' } } if ($Object -is [PSNode]) { $Node = $Object } else { $Node = [PSNode]::ParseInput($Object) } $this.StringBuilder = [System.Text.StringBuilder]::new() $this.Stringify($Node) return $this.StringBuilder.ToString() } hidden Stringify([PSNode]$Node) { $Value = $Node.Value $IsSubNode = $this.StringBuilder.Length -ne 0 if ($Null -eq $Value) { $this.StringBuilder.Append('$Null') return } $Type = $Node.ValueType $TypeName = "$Type" $TypeInitializer = if ($Null -ne $Type -and ( $this.LanguageMode -eq 'Full' -or ( $this.LanguageMode -eq 'Constrained' -and [PSLanguageType]::IsConstrained($Type) -and ( $this.Explicit -or -not ( $Type.IsPrimitive -or $Value -is [String] -or $Value -is [Object[]] -or $Value -is [Hashtable] ) ) ) ) ) { if ($this.FullTypeName) { if ($Type.FullName -eq 'System.Management.Automation.PSCustomObject' ) { '[System.Management.Automation.PSObject]' } # https://github.com/PowerShell/PowerShell/issues/2295 else { "[$($Type.FullName)]" } } elseif ($TypeName -eq 'System.Object[]') { "[Array]" } elseif ($TypeName -eq 'System.Management.Automation.PSCustomObject') { "[PSCustomObject]" } elseif ($Type.Name -eq 'RuntimeType') { "[Type]" } else { "[$TypeName]" } } if ($TypeInitializer) { $this.StringBuilder.Append($TypeInitializer) } if ($Node -is [PSLeafNode] -or (-not $this.HighFidelity -and [PSSerialize]::RoundTripProperty.Contains($Node.ValueType.FullName))) { $MaxLength = if ($IsSubNode) { [PSSerialize]::MaxValueLength } else { [PSSerialize]::MaxLeafLength } $Expression = if ([PSSerialize]::RoundTripProperty.Contains($Node.ValueType.FullName)) { $Property = [PSSerialize]::RoundTripProperty[$Node.ValueType.FullName] if ($Null -eq $Property) { $Null } elseif ($Property -is [String]) { if ($Property) { ,$Value.$Property } else { "$Value" } } elseif ($Property -is [ScriptBlock] ) { Invoke-Command $Property -InputObject $Value } elseif ($Property -is [HashTable]) { if ($this.LanguageMode -eq 'Restricted') { $Null } else { @{} } } elseif ($Property -is [Array]) { @($Property.foreach{ $Value.$_ }) } else { Throw "Unknown round trip property type: $($Property.GetType())."} } elseif ($Type.IsPrimitive) { $Value } elseif (-not $Type.GetConstructors()) { "$TypeName" } elseif ($Type.GetMethod('ToString', [Type[]]@())) { $Value.ToString() } elseif ($Value -is [Collections.ICollection]) { ,$Value } else { $Value } # Handle compression if ($Null -eq $Expression) { $Expression = '$Null' } elseif ($Expression -is [Bool]) { $Expression = "`$$Value" } elseif ($Expression -is [Char]) { $Expression = "'$Value'" } elseif ($Expression -is [ScriptBlock]) { $Expression = [Abbreviate]::new('{', $Expression, $MaxLength, '}') } elseif ($Expression -is [HashTable]) { $Expression = '@{}' } elseif ($Expression -is [Array]) { if ($this.LanguageMode -eq 'NoLanguage') { $Expression = [Abbreviate]::new('[', $Expression[0], $MaxLength, ']') } else { $Space = if ($this.ExpandDepth -ge 0) { ' ' } $New = if ($TypeInitializer) { '::new(' } else { '@(' } $Expression = $New + ($Expression.foreach{ if ($Null -eq $_) { '$Null' } elseif ($_.GetType().IsPrimitive) { "$_" } elseif ($_ -is [Array]) { $_ -Join $Space } else { "'$_'" } } -Join ",$Space") + ')' } } elseif ($Type -and $Type.IsPrimitive) { if ($this.LanguageMode -eq 'NoLanguage') { $Expression = [CommandColor]([String]$Expression[0]) } } else { if ($Expression -isnot [String]) { $Expression = "$Expression" } if ($this.LanguageMode -eq 'NoLanguage') { $Expression = [StringColor]([Abbreviate]::new("'", $Expression, $MaxLength, "'")) } else { if ($Expression.Contains("`n")) { $Expression = "@'" + [Environment]::NewLine + "$Expression".Replace("'", "''") + [Environment]::NewLine + "'@" } else { $Expression = "'$($Expression -Replace [PSSerialize]::AnySingleQuote, '$0$0')'" } } } $this.StringBuilder.Append($Expression) } elseif ($Node -is [PSListNode]) { $ChildNodes = $Node.get_ChildNodes() $this.StringBuilder.Append('@(') if ($this.LanguageMode -eq 'NoLanguage') { if ($ChildNodes.Count -eq 0) { } elseif ($IsSubNode) { $this.StringBuilder.Append([Abbreviate]::Ellipses) } else { $Indices = [PSSerialize]::NoLanguageIndices if (-not $Indices -or $ChildNodes.Count -lt $Indices.Count) { $Indices = 0..($ChildNodes.Count - 1) } $LastIndex = $Null foreach ($Index in $Indices) { if ($Null -ne $LastIndex) { $this.StringBuilder.Append(',') } if ($Index -lt 0) { $Index = $ChildNodes.Count + $Index } if ($Index -gt $LastIndex + 1) { $this.StringBuilder.Append("$([Abbreviate]::Ellipses),") } $this.StringBuilder.Append($this.Stringify($ChildNodes[$Index])) $LastIndex = $Index } } } else { $this.Offset++ $StartLine = $this.LineNumber $ExpandSingle = $this.ExpandSingleton -or $ChildNodes.Count -gt 1 -or ($ChildNodes.Count -eq 1 -and $ChildNodes[0] -isnot [PSLeafNode]) foreach ($ChildNode in $ChildNodes) { if ($ChildNode.Name -gt 0) { $this.StringBuilder.Append(',') $this.NewWord() } elseif ($ExpandSingle) { $this.NewWord('') } $this.Stringify($ChildNode) } $this.Offset-- if ($this.LineNumber -gt $StartLine) { $this.NewWord('') } } $this.StringBuilder.Append(')') } else { # if ($Node -is [PSMapNode]) { $ChildNodes = $Node.get_ChildNodes() if ($ChildNodes) { $this.StringBuilder.Append('@{') if ($this.LanguageMode -eq 'NoLanguage') { if ($ChildNodes.Count -gt 0) { $Indices = [PSSerialize]::NoLanguageItems if (-not $Indices -or $ChildNodes.Count -lt $Indices.Count) { $Indices = 0..($ChildNodes.Count - 1) } $LastIndex = $Null foreach ($Index in $Indices) { if ($IsSubNode -and $Index) { $this.StringBuilder.Append(";$([Abbreviate]::Ellipses)"); break } if ($Null -ne $LastIndex) { $this.StringBuilder.Append(';') } if ($Index -lt 0) { $Index = $ChildNodes.Count + $Index } if ($Index -gt $LastIndex + 1) { $this.StringBuilder.Append("$([Abbreviate]::Ellipses);") } $this.StringBuilder.Append([VariableColor]( [PSKeyExpression]::new($ChildNodes[$Index].Name, [PSSerialize]::MaxKeyLength))) $this.StringBuilder.Append('=') if (-not $IsSubNode -or $this.StringBuilder.Length -le [PSSerialize]::MaxKeyLength) { $this.StringBuilder.Append($this.Stringify($ChildNodes[$Index])) } else { $this.StringBuilder.Append([Abbreviate]::Ellipses) } $LastIndex = $Index } } } else { $this.Offset++ $StartLine = $this.LineNumber $Index = 0 $ExpandSingle = $this.ExpandSingleton -or $ChildNodes.Count -gt 1 -or $ChildNodes[0] -isnot [PSLeafNode] $ChildNodes.foreach{ if ($Index++) { $Separator = if ($this.ExpandDepth -ge 0) { '; ' } else { ';' } $this.NewWord($Separator) } elseif ($this.ExpandDepth -ge 0) { if ($ExpandSingle) { $this.NewWord() } else { $this.StringBuilder.Append(' ') } } $this.StringBuilder.Append([PSKeyExpression]::new($_.Name, $this.LanguageMode, ($this.ExpandDepth -lt 0))) if ($this.ExpandDepth -ge 0) { $this.StringBuilder.Append(' = ') } else { $this.StringBuilder.Append('=') } $this.Stringify($_) } $this.Offset-- if ($this.LineNumber -gt $StartLine) { $this.NewWord() } elseif ($this.ExpandDepth -ge 0) { $this.StringBuilder.Append(' ') } } $this.StringBuilder.Append('}') } elseif ($Node -is [PSObjectNode] -and $TypeInitializer) { $this.StringBuilder.Append('::new()') } else { $this.StringBuilder.Append('@{}') } } } hidden NewWord() { $this.NewWord(' ') } hidden NewWord([String]$Separator) { if ($this.Offset -le $this.ExpandDepth) { $this.StringBuilder.AppendLine() for($i = $this.Offset; $i -gt 0; $i--) { $this.StringBuilder.Append($this.Indent) } $this.LineNumber++ } else { $this.StringBuilder.Append($Separator) } } [String] ToString() { if ($this._Object -is [PSNode]) { $Node = $this._Object } else { $Node = [PSNode]::ParseInput($this._Object) } $this.StringBuilder = [System.Text.StringBuilder]::new() $this.Stringify($Node) return $this.StringBuilder.ToString() } } Class ANSI { # Retrieved from Get-PSReadLineOption static [String]$CommandColor static [String]$CommentColor static [String]$ContinuationPromptColor static [String]$DefaultTokenColor static [String]$EmphasisColor static [String]$ErrorColor static [String]$KeywordColor static [String]$MemberColor static [String]$NumberColor static [String]$OperatorColor static [String]$ParameterColor static [String]$SelectionColor static [String]$StringColor static [String]$TypeColor static [String]$VariableColor # Hardcoded (if valid Get-PSReadLineOption) static [String]$Reset static [String]$ResetColor static [String]$InverseColor static [String]$InverseOff Static ANSI() { $PSReadLineOption = try { Get-PSReadLineOption -ErrorAction SilentlyContinue } catch { $null } if (-not $PSReadLineOption) { return } $ANSIType = [ANSI] -as [Type] foreach ($Property in [ANSI].GetProperties()) { $PSReadLineProperty = $PSReadLineOption.PSObject.Properties[$Property.Name] if ($PSReadLineProperty) { $ANSIType.GetProperty($Property.Name).SetValue($Property.Name, $PSReadLineProperty.Value) } } $Esc = [char]0x1b [ANSI]::Reset = "$Esc[0m" [ANSI]::ResetColor = "$Esc[39m" [ANSI]::InverseColor = "$Esc[7m" [ANSI]::InverseOff = "$Esc[27m" } } Class TextStyle { hidden [String]$Text hidden [String]$AnsiCode hidden [String]$ResetCode = [ANSI]::Reset TextStyle ([String]$Text, [String]$AnsiCode, [String]$ResetCode) { $this.Text = $Text $this.AnsiCode = $AnsiCode $this.ResetCode = $ResetCode } TextStyle ([String]$Text, [String]$AnsiCode) { $this.Text = $Text $this.AnsiCode = $AnsiCode } [String] ToString() { if ($this.ResetCode -eq [ANSI]::ResetColor) { return "$($this.AnsiCode)$($this.Text.Replace($this.ResetCode, $this.AnsiCode))$($this.ResetCode)" } else { return "$($this.AnsiCode)$($this.Text)$($this.ResetCode)" } } } class XdnName { hidden [Bool]$_Literal hidden $_IsVerbatim hidden $_ContainsWildcard hidden $_Value hidden Initialize($Value, $Literal) { $this._Value = $Value if ($Null -ne $Literal) { $this._Literal = $Literal } else { $this._Literal = $this.IsVerbatim() } if ($this._Literal) { $XdnName = [XdnName]::new() $XdnName._ContainsWildcard = $False } else { $XdnName = [XdnName]::new() $XdnName._ContainsWildcard = $null } } XdnName() {} XdnName($Value) { $this.Initialize($Value, $null) } XdnName($Value, [Bool]$Literal) { $this.Initialize($Value, $Literal) } static [XdnName]Literal($Value) { return [XdnName]::new($Value, $true) } static [XdnName]Expression($Value) { return [XdnName]::new($Value, $false) } [Bool] IsVerbatim() { if ($Null -eq $this._IsVerbatim) { $this._IsVerbatim = $this._Value -is [String] -and $this._Value -Match '^[\?\*\p{L}\p{Lt}\p{Lm}\p{Lo}_][\?\*\p{L}\p{Lt}\p{Lm}\p{Lo}\p{Nd}_]*$' # https://stackoverflow.com/questions/62754771/unquoted-key-rules-and-best-practices } return $this._IsVerbatim } [Bool] ContainsWildcard() { if ($Null -eq $this._ContainsWildcard) { $this._ContainsWildcard = $this._Value -is [String] -and $this._Value -Match '(?<=([^`]|^)(``)*)[\?\*]' } return $this._ContainsWildcard } [Bool] Equals($Object) { if ($this._Literal) { return $this._Value -eq $Object } elseif ($this.ContainsWildcard()) { return $Object -Like $this._Value } else { return $this._Value -eq $Object } } [String] ToString($Colored) { $Color = if ($Colored) { if ($this._Literal) { [ANSI]::VariableColor } elseif (-not $this.IsVerbatim()) { [ANSI]::StringColor } elseif ($this.ContainsWildcard()) { [ANSI]::EmphasisColor } else { [ANSI]::VariableColor } } $String = if ($this._Literal) { "'" + "$($this._Value)".Replace("'", "''") + "'" } else { "$($this._Value)" -replace '(?<!([^`]|^)(``)*)[\.\[\~\=\/]', '`${0}' } # Escape any Xdn operator (that isn't yet escaped) $Reset = if ($Colored) { [ANSI]::ResetColor } return $Color + $String + $Reset } [String] ToString() { return $this.ToString($False) } [String] ToColoredString() { return $this.ToString($True) } } class XdnPath { hidden $_Entries = [List[KeyValuePair[XdnType, Object]]]::new() hidden [Object]get_Entries() { return ,$this._Entries } # Read-only XdnPath ([String]$Path) { $this.FromString($Path, $False) } XdnPath ([String]$Path, [Bool]$Literal) { $this.FromString($Path, $Literal) } XdnPath ([PSNodePath]$Path) { foreach ($Node in $Path.Nodes) { Switch ($Node.NodeOrigin) { Root { $this.Add('Root', $Null) } List { $this.Add('Index', $Node.Name) } Map { $this.Add('Child', [XdnName]$Node.Name) } } } } hidden AddError($Value) { $this._Entries.Add([KeyValuePair[XdnType, Object]]::new('Error', $Value)) } Add ($EntryType, $Value) { if ($EntryType -eq '/') { if ($this._Entries.Count -eq 0) { $this.AddError($Value) } elseif ($this._Entries[-1].Key -NotIn 'Child', 'Descendant', 'Equals') { $this.AddError($Value) } else { $EntryValue = $this._Entries[-1].Value if ($EntryValue -IsNot [IList]) { $EntryValue = [List[Object]]$EntryValue } $EntryValue.Add($Value) $this._Entries[-1] = [KeyValuePair[XdnType, Object]]::new($this._Entries[-1].Key, $EntryValue) } } else { $XdnType = Switch ($EntryType) { '.' { 'Child' } '~' { 'Descendant' } '=' { 'Equals' } default { $EntryType } } if ($XdnType -in [XdnType].GetEnumNames()) { $this._Entries.Add([KeyValuePair[XdnType, Object]]::new($XdnType, $Value)) } else { $this.AddError($Value) } } } hidden FromString ([String]$Path, [Bool]$Literal) { $XdnOperator = $Null if (-not $this._Entries.Count) { $IsRoot = if ($Literal) { $Path -NotMatch '^\.' } else { $Path -NotMatch '^(?<=([^`]|^)(``)*)\.' } if ($IsRoot) { $this.Add('Root', $Null) $XdnOperator = 'Child' } } $Length = [Int]::MaxValue while ($Path) { if ($Path.Length -ge $Length) { break } $Length = $Path.Length if ($Path[0] -in "'", '"') { if (-not $XdnOperator) { $XdnOperator = 'Child' } $Ast = [Parser]::ParseInput($Path, [ref]$Null, [ref]$Null) $StringAst = $Ast.EndBlock.Statements.Find({ $args[0] -is [StringConstantExpressionAst] }, $False) if ($Null -ne $StringAst) { $this.Add($XdnOperator, [XdnName]::Literal($StringAst[0].Value)) $Path = $Path.SubString($StringAst[0].Extent.EndOffset) } else { # Probably a quoting error $this.Add($XdnOperator, [XdnName]::Literal($Path, $True)) $Path = $Null } } else { $Match = if ($Literal) { [regex]::Match($Path, '[\.\[]') } else { [regex]::Match($Path, '(?<=([^`]|^)(``)*)[\.\[\~\=\/]') } $Match = [regex]::Match($Path, '(?<=([^`]|^)(``)*)[\.\[\~\=\/]') if ($Match.Success -and $Match.Index -eq 0) { # Operator $IndexEnd = if ($Match.Value -eq '[') { $Path.IndexOf(']') } $Ancestors = if ($Match.Value -eq '.' -and $Path -Match '^\.\.+') { $Matches[0].Length - 1 } if ($IndexEnd -gt 0) { $Index = $Path.SubString(1, ($IndexEnd - 1)) $CommandAst = [Parser]::ParseInput($Index, [ref]$Null, [ref]$Null).EndBlock.Statements.PipelineElements if ($CommandAst -is [CommandExpressionAst]) { $Index = $CommandAst.expression.Value } $this.Add('Index', $Index) $Path = $Path.SubString(($IndexEnd + 1)) $XdnOperator = $Null } elseif ($Ancestors) { $this.Add('Ancestor', $Ancestors) $Path = $Path.Substring($Ancestors + 1) $XdnOperator = 'Child' } elseif ($Match.Value -in '.', '~', '=', '/' -and $Match.Value -ne $XdnOperator) { $XdnOperator = $Match.Value $Path = $Path.Substring(1) } else { $XdnOperator = 'Error' $this.Add($XdnOperator, $Match.Value) $Path = $Path.Substring(1) } } elseif ($Match.Success) { if (-not $XdnOperator) { $XdnOperator = 'Child' } $Name = $Path.SubString(0, $Match.Index) $Value = if ($Literal) { [XdnName]::Literal($Name) } else { [XdnName]::Expression($Name) } $this.Add($XdnOperator, $Value) $Path = $Path.SubString($Match.Index) $XdnOperator = $Null } else { $Value = if ($Literal) { [XdnName]::Literal($Path) } else { [XdnName]::Expression($Path)} $this.Add($XdnOperator, $Value) $Path = $Null } } } } [String] ToString([String]$VariableName, [Bool]$Colored) { $RegularColor = if ($Colored) { [ANSI]::VariableColor } $OperatorColor = if ($Colored) { [ANSI]::CommandColor } $ErrorColor = if ($Colored) { [ANSI]::ErrorColor } $ResetColor = if ($Colored) { [ANSI]::ResetColor } $Path = [System.Text.StringBuilder]::new() $PreviousEntry = $Null foreach ($Entry in $this._Entries) { $Value = $Entry.Value $Append = Switch ($Entry.Key) { Root { "$OperatorColor$VariableName" } Ancestor { "$OperatorColor$('.' * $Value)" } Index { $Dot = if (-not $PreviousEntry -or $PreviousEntry.Key -eq 'Ancestor') { "$OperatorColor." } if ([int]::TryParse($Value, [Ref]$Null)) { "$Dot$RegularColor[$Value]" } else { "$ErrorColor[$Value]" } } Child { "$RegularColor.$(@($Value).foreach{ $_.ToString($Colored) } -Join ""$OperatorColor/"")" } Descendant { "$OperatorColor~$(@($Value).foreach{ $_.ToString($Colored) } -Join ""$OperatorColor/"")" } Equals { "$OperatorColor=$(@($Value).foreach{ $_.ToString($Colored) } -Join ""$OperatorColor/"")" } Default { "$ErrorColor$($Value)" } } $Path.Append($Append) $PreviousEntry = $Entry } $Path.Append($ResetColor) return $Path.ToString() } [String] ToString() { return $this.ToString($Null , $False)} [String] ToString([String]$VariableName) { return $this.ToString($VariableName, $False)} [String] ToColoredString() { return $this.ToString($Null, $True)} [String] ToColoredString([String]$VariableName) { return $this.ToString($VariableName, $True)} static XdnPath() { Use-ClassAccessors } } class LogicalOperator : LogicalTerm { hidden [LogicalOperatorEnum]$Value LogicalOperator ([LogicalOperatorEnum]$Operator) { $this.Value = $Operator } LogicalOperator ([String]$Operator) { $this.Value = [LogicalOperatorEnum]$Operator } [String]ToString() { return $this.Value } } class LogicalVariable : LogicalTerm { hidden [Object]$Value LogicalVariable ($Variable) { $this.Value = $Variable } [String]ToString() { if ($this.Value -is [String]) { return "'$($this.Value -Replace "'", "''")'" } else { return $this.Value } } } class LogicalFormula : LogicalTerm { hidden static $OperatorSymbols = @{ '!' = [LogicalOperatorEnum]'Not' ',' = [LogicalOperatorEnum]'And' '*' = [LogicalOperatorEnum]'And' '|' = [LogicalOperatorEnum]'Or' '+' = [LogicalOperatorEnum]'Or' } hidden static [Int[]]$OperatorNameLengths hidden [List[LogicalTerm]]$Terms = [List[LogicalTerm]]::new() hidden [Int]$Pointer GetFormula([String]$Expression, [Int]$Start) { $SubExpression = $Start -gt 0 $InString = $null # Quote type (double - or single quoted) $Escaped = $null $this.Pointer = $Start While ($this.Pointer -le $Expression.Length) { $Char = if ($this.Pointer -lt $Expression.Length) { $Expression[$this.Pointer] } if ($InString) { if ($Char -eq $InString) { if ($this.Pointer + 1 -lt $Expression.Length -and $Expression[$this.Pointer + 1] -eq $InString) { $Escaped = $true $this.Pointer++ } else { $Name = $Expression.SubString($Start + 1, ($this.Pointer - $Start - 1)) if ($Escaped) { $Name = $Name.Replace("$InString$InString", $InString) } $this.Terms.Add([LogicalVariable]::new($Name)) $InString = $Null $Start = $this.Pointer + 1 } } } elseif ('"', "'" -eq $Char) { $InString = $Char $Escaped = $false $Start = $this.Pointer } elseif ($Char -eq '(') { $Formula = [LogicalFormula]::new($Expression, ($this.Pointer + 1)) $this.Terms.Add($Formula) $this.Pointer = $Formula.Pointer $Start = $this.Pointer + 1 } elseif ($Char -in $Null, ' ', ')' + [LogicalFormula]::OperatorSymbols.Keys) { $Length = $this.Pointer - $Start if ($Length -gt 0) { $Term = $Expression.SubString($Start, $Length) if ([LogicalOperatorEnum].GetEnumNames() -eq $Term) { $this.Terms.Add([LogicalOperator]::new($Term)) } else { $Double = 0 if ([double]::TryParse($Term, [Ref]$Double)) { $this.Terms.Add([LogicalVariable]::new($Double)) } else { $this.Terms.Add([LogicalVariable]::new($Term)) } } } if ($Char -eq ')') { return } if ($Char -gt ' ') { $this.Terms.Add([LogicalOperator]::new([LogicalFormula]::OperatorSymbols($Char))) } $Start = $this.Pointer + 1 } # elseif ($Char -le ' ' -or $Null -eq $Char) { # A space or any control code # if ($Start -lt $this.Pointer) { # $this.Terms.Add($this.GetUnquotedTerm($Expression, $Start, ($this.Pointer - $Start))) # } # $Start = $this.Pointer + 1 # } $this.Pointer++ } if ($InString) { Throw "Missing the terminator: $InString in logical expression: $Expression" } if ($SubExpression) { Throw "Missing closing ')' in logical expression: $Expression" } } LogicalFormula ([String]$Expression) { $this.GetFormula($Expression, 0) if ($this.Pointer -lt $Expression.Length) { Throw "Unexpected token ')' at position $($this.Pointer) in logical expression: $Expression" } } LogicalFormula ([String]$Expression, $Start) { $this.GetFormula($Expression, $Start) } Append ([LogicalOperator]$Operator, [LogicalFormula]$Formula) { if ($Operator.Value -eq 'Not') { $this.Terms.Add([LogicalOperator]'And') } $this.Terms.Add($Operator) $this.Terms.AddRange($Formula.Terms) } [String] ToString() { $StringBuilder = [System.Text.StringBuilder]::new() $Stack = [System.Collections.Stack]::new() $Enumerator = $this.Terms.GetEnumerator() $Term = $null while ($true) { while ($Enumerator.MoveNext()) { if ($Null -ne $Term) { $null = $StringBuilder.Append([ANSI]::ResetColor) # Not really necessarily $null = $StringBuilder.Append(' ') } $Term = $Enumerator.Current if ($Term -is [LogicalVariable]) { if ($Term.Value -is [String]) { $null = $StringBuilder.Append([ANSI]::VariableColor) } else { $null = $StringBuilder.Append([ANSI]::NumberColor) } } elseif ($Term -is [LogicalOperator]) { $null = $StringBuilder.Append([ANSI]::OperatorColor) } else { # if ($Term -is [LogicalFormula]) $null = $StringBuilder.Append([ANSI]::StringColor) $null = $StringBuilder.Append('(') $Stack.Push($Enumerator) $Enumerator = $Term.Terms.GetEnumerator() $Term = $null continue } $null = $StringBuilder.Append($Term) } if (-not $Stack.Count) { $null = $StringBuilder.Append([ANSI]::ResetColor) break } $null = $StringBuilder.Append([ANSI]::StringColor) $null = $StringBuilder.Append(')') $Enumerator = $Stack.Pop() } return $StringBuilder.ToString() } } Class PSLeafNode : PSNode { hidden MaxDepthReached() { } # return nothing knowing that leaf nodes terminate the path anyways hidden PSLeafNode($Object) { if ($Object -is [PSNode]) { $this._Value = $Object._Value } else { $this._Value = $Object } } [Int]GetHashCode($CaseSensitive) { if ($Null -eq $this._Value) { return '$Null'.GetHashCode() } if ($CaseSensitive) { return $this._Value.GetHashCode() } else { if ($this._Value -is [String]) { return $this._Value.ToUpper().GetHashCode() } # Windows PowerShell doesn't have a System.HashCode structure else { return $this._Value.GetHashCode() } } } [string]ToString() { return "$([TypeColor][PSSerialize]::new($this, [PSLanguageMode]'NoLanguage'))" } } Class PSCollectionNode : PSNode { hidden static PSCollectionNode() { Use-ClassAccessors } hidden [Dictionary[bool,int]]$_HashCode # Unlike the value HashCode, the default (bool = $false) node HashCode is case insensitive hidden [Dictionary[bool,int]]$_ReferenceHashCode # if changed, recalculate the (bool = case sensitive) node's HashCode hidden [bool]MaxDepthReached() { # Check whether the max depth has been reached. # Warn if it has, but suppress the warning if # it took less then 5 seconds since the last # time it reached the max depth. $MaxDepthReached = $this.Depth -ge $this.RootNode._MaxDepth if ($MaxDepthReached) { if (([DateTime]::Now - $this.RootNode.MaxDepthWarningTime).TotalSeconds -gt 5) { Write-Warning "$($this.Path) reached the maximum depth of $($this.RootNode._MaxDepth)." } $this.RootNode.MaxDepthWarningTime = [DateTime]::Now } return $MaxDepthReached } hidden WarnSelector ([PSCollectionNode]$Node, [String]$Name) { if ($Node -is [PSListNode]) { $SelectionName = "'$Name'" $CollectionType = 'list' } else { $SelectionName = "[$Name]" $CollectionType = 'list' } Write-Warning "Expected $SelectionName to be a $CollectionType selector for: <Object>$($Node.Path)" } hidden [List[Ast]] GetAstSelectors ($Ast) { $List = [List[Ast]]::new() if ($Ast -isnot [Ast]) { $Ast = [Parser]::ParseInput("`$_$Ast", [ref]$Null, [ref]$Null) $Ast = $Ast.EndBlock.Statements.PipeLineElements.Expression } if ($Ast -is [IndexExpressionAst]) { $List.AddRange($this.GetAstSelectors($Ast.Target)) $List.Add($Ast) } elseif ($Ast -is [MemberExpressionAst]) { $List.AddRange($this.GetAstSelectors($Ast.Expression)) $List.Add($Ast) } elseif ($Ast.Extent.Text -ne '$_') { Throw "Parse error: $($Ast.Extent.Text)" } return $List } [List[PSNode]]GetNodeList($Levels, [Bool]$LeafNodesOnly) { $NodeList = [List[PSNode]]::new() $Stack = [Stack]::new() $Stack.Push($this.get_ChildNodes().GetEnumerator()) $Level = 1 While ($Stack.Count -gt 0) { $Enumerator = $Stack.Pop() $Level-- while ($Enumerator.MoveNext()) { $Node = $Enumerator.Current if ($Node.MaxDepthReached() -or ($Levels -ge 0 -and $Level -ge $Levels)) { break } if (-not $LeafNodesOnly -or $Node -is [PSLeafNode]) { $NodeList.Add($Node) } if ($Node -is [PSCollectionNode]) { $Stack.Push($Enumerator) $Level++ $Enumerator = $Node.get_ChildNodes().GetEnumerator() } } } return $NodeList } [List[PSNode]]GetNodeList() { return $this.GetNodeList(1, $False) } [List[PSNode]]GetNodeList([Int]$Levels) { return $this.GetNodeList($Levels, $False) } hidden [PSNode[]]get_DescendantNodes() { return $this.GetNodeList(-1, $False) } hidden [PSNode[]]get_LeafNodes() { return $this.GetNodeList(-1, $True) } Sort() { $this.Sort($Null, 0) } Sort([ObjectComparison]$ObjectComparison) { $this.Sort($Null, $ObjectComparison) } Sort([String[]]$PrimaryKey) { $this.Sort($PrimaryKey, 0) } Sort([ObjectComparison]$ObjectComparison, [String[]]$PrimaryKey) { $this.Sort($PrimaryKey, $ObjectComparison) } Sort([String[]]$PrimaryKey, [ObjectComparison]$ObjectComparison) { # As the child nodes are sorted first, we just do a side-by-side node compare: $ObjectComparison = $ObjectComparison -bor [ObjectComparison]'MatchMapOrder' $ObjectComparison = $ObjectComparison -band (-1 - [ObjectComparison]'IgnoreListOrder') $PSListNodeComparer = [PSListNodeComparer]@{ PrimaryKey = $PrimaryKey; ObjectComparison = $ObjectComparison } $PSMapNodeComparer = [PSMapNodeComparer]@{ PrimaryKey = $PrimaryKey; ObjectComparison = $ObjectComparison } $this.SortRecurse($PSListNodeComparer, $PSMapNodeComparer) } hidden SortRecurse([PSListNodeComparer]$PSListNodeComparer, [PSMapNodeComparer]$PSMapNodeComparer) { $NodeList = $this.GetNodeList() foreach ($Node in $NodeList) { if ($Node -is [PSCollectionNode]) { $Node.SortRecurse($PSListNodeComparer, $PSMapNodeComparer) } } if ($this -is [PSListNode]) { $NodeList.Sort($PSListNodeComparer) if ($NodeList.Count) { $this._Value = @($NodeList.Value) } else { $this._Value = @() } } else { # if ($Node -is [PSMapNode]) $NodeList.Sort($PSMapNodeComparer) $Properties = [System.Collections.Specialized.OrderedDictionary]::new([StringComparer]::Ordinal) foreach($ChildNode in $NodeList) { $Properties[[Object]$ChildNode.Name] = $ChildNode.Value } # [Object] forces a key rather than an index (ArgumentOutOfRangeException) if ($this -is [PSObjectNode]) { $this._Value = [PSCustomObject]$Properties } else { $this._Value = $Properties } } } } Class PSListNode : PSCollectionNode { hidden static PSListNode() { Use-ClassAccessors } hidden PSListNode($Object) { if ($Object -is [PSNode]) { $this._Value = $Object._Value } else { $this._Value = $Object } } hidden [Object]get_Count() { return $this._Value.get_Count() } hidden [Object]get_Names() { if ($this._Value.Length) { return ,@(0..($this._Value.Length - 1)) } return ,@() } hidden [Object]get_Values() { return ,@($this._Value) } hidden [Object]get_CaseMatters() { return $false } [Bool]Contains($Index) { return $Index -ge 0 -and $Index -lt $this.get_Count() } [Bool]Exists($Index) { return $Index -ge 0 -and $Index -lt $this.get_Count() } [Object]GetValue($Index) { return $this._Value[$Index] } [Object]GetValue($Index, $Default) { if (-not $This.Contains($Index)) { return $Default } return $this._Value[$Index] } SetValue($Index, $Value) { if ($Value -is [PSNode]) { $Value = $Value.Value } $this._Value[$Index] = $Value } Add($Value) { if ($Value -is [PSNode]) { $Value = $Value._Value } if ($this._Value.GetType().GetMethod('Add')) { $null = $This._Value.Add($Value) } else { $this._Value = ($this._Value + $Value) -as $this._Value.GetType() } $this.Cache.Remove('ChildNodes') } Remove($Value) { if ($Value -is [PSNode]) { $Value = $Value.Value } if (-not $this.Value.Contains($Value)) { return } if ($this.Value.GetType().GetMethod('Remove')) { $null = $this._value.remove($Value) } else { $cList = [List[Object]]::new() $iList = [List[Object]]::new() $ceq = $false foreach ($ChildNode in $this.get_ChildNodes()) { if (-not $ceq -and $ChildNode.Value -ceq $Value) { $ceq = $true } else { $cList.Add($ChildNode.Value) } if (-not $ceq -and $ChildNode.Value -ine $Value) { $iList.Add($ChildNode.Value) } } if ($ceq) { $this._Value = $cList -as $this._Value.GetType() } else { $this._Value = $iList -as $this._Value.GetType() } } $this.Cache.Remove('ChildNodes') } RemoveAt([Int]$Index) { if ($Index -lt 0 -or $Index -ge $this.Value.Count) { Throw 'Index was out of range. Must be non-negative and less than the size of the collection.' } if ($this.Value.GetType().GetMethod('RemoveAt')) { $null = $this._Value.removeAt($Index) } else { $this._Value = $(for ($i = 0; $i -lt $this._Value.Count; $i++) { if ($i -ne $index) { $this._Value[$i] } }) -as $this.ValueType } $this.Cache.Remove('ChildNodes') } [Object]GetChildNode([Int]$Index) { if ($this.MaxDepthReached()) { return @() } $Count = $this._Value.get_Count() if ($Index -lt -$Count -or $Index -ge $Count) { throw "The <Object>$($this.Path) doesn't contain a child index: $Index" } if ($Index -lt 0) { $Index = $Count + $Index } # Negative index if (-not $this.Cache.ContainsKey('ChildNode')) { $this.Cache['ChildNode'] = [Dictionary[Int,Object]]::new() } if ( -not $this.Cache.ChildNode.ContainsKey($Index) -or -not [Object]::ReferenceEquals($this.Cache.ChildNode[$Index]._Value, $this._Value[$Index]) ) { $Node = [PSNode]::ParseInput($this._Value[$Index]) $Node._Name = $Index $Node.Depth = $this.Depth + 1 $Node.RootNode = [PSNode]$this.RootNode $Node.ParentNode = $this $this.Cache.ChildNode[$Index] = $Node } return $this.Cache.ChildNode[$Index] } hidden [Object[]]get_ChildNodes() { if (-not $this.Cache.ContainsKey('ChildNodes')) { $ChildNodes = for ($Index = 0; $Index -lt $this._Value.get_Count(); $Index++) { $this.GetChildNode($Index) } if ($null -ne $ChildNodes) { $this.Cache['ChildNodes'] = $ChildNodes } else { $this.Cache['ChildNodes'] = @() } } return $this.Cache['ChildNodes'] } [Int]GetHashCode($CaseSensitive) { # The hash of a list node is equal if all items match the order and the case. # The primary keys and the list type are not relevant if ($null -eq $this._HashCode) { $this._HashCode = [Dictionary[bool,int]]::new() $this._ReferenceHashCode = [Dictionary[bool,int]]::new() } $ReferenceHashCode = $This._value.GetHashCode() if (-not $this._ReferenceHashCode.ContainsKey($CaseSensitive) -or $this._ReferenceHashCode[$CaseSensitive] -ne $ReferenceHashCode) { $this._ReferenceHashCode[$CaseSensitive] = $ReferenceHashCode $HashCode = '@()'.GetHashCode() # Empty lists have a common hash that is not 0 $Index = 0 foreach ($Node in $this.GetNodeList()) { $HashCode = $HashCode -bxor "$Index.$($Node.GetHashCode($CaseSensitive))".GetHashCode() $index++ } $this._HashCode[$CaseSensitive] = $HashCode } return $this._HashCode[$CaseSensitive] } [string]ToString() { return "$([TypeColor][PSSerialize]::new($this, [PSLanguageMode]'NoLanguage'))" } } Class PSMapNode : PSCollectionNode { hidden static PSMapNode() { Use-ClassAccessors } [Int]GetHashCode($CaseSensitive) { # The hash of a map node is equal if all names and items match the order and the case. # The map type is not relevant if ($null -eq $this._HashCode) { $this._HashCode = [Dictionary[bool,int]]::new() $this._ReferenceHashCode = [Dictionary[bool,int]]::new() } $ReferenceHashCode = $This._value.GetHashCode() if (-not $this._ReferenceHashCode.ContainsKey($CaseSensitive) -or $this._ReferenceHashCode[$CaseSensitive] -ne $ReferenceHashCode) { $this._ReferenceHashCode[$CaseSensitive] = $ReferenceHashCode $HashCode = '@{}'.GetHashCode() # Empty maps have a common hash that is not 0 $Index = 0 foreach ($Node in $this.GetNodeList()) { $Name = if ($CaseSensitive) { $Node._Name } else { $Node._Name.ToUpper() } $HashCode = $HashCode -bxor "$Index.$Name=$($Node.GetHashCode())".GetHashCode() $Index++ } $this._HashCode[$CaseSensitive] = $HashCode } return $this._HashCode[$CaseSensitive] } } Class PSDictionaryNode : PSMapNode { hidden static PSDictionaryNode() { Use-ClassAccessors } hidden PSDictionaryNode($Object) { if ($Object -is [PSNode]) { $this._Value = $Object._Value } else { $this._Value = $Object } } hidden [Object]get_Count() { return $this._Value.get_Count() } hidden [Object]get_Names() { return ,$this._Value.get_Keys() } hidden [Object]get_Values() { return ,$this._Value.get_Values() } hidden [Object]get_CaseMatters() { #Returns Nullable[Boolean] if (-not $this.Cache.ContainsKey('CaseMatters')) { $this.Cache['CaseMatters'] = $null # else $Null means that there is no key with alphabetic characters in the dictionary foreach ($Key in $this._Value.Get_Keys()) { if ($Key -is [String] -and $Key -match '[a-z]') { $Case = if ([Int][Char]($Matches[0]) -ge 97) { $Key.ToUpper() } else { $Key.ToLower() } $this.Cache['CaseMatters'] = -not $this.Contains($Case) -or $Case -cin $this._Value.Get_Keys() break } } } return $this.Cache['CaseMatters'] } [Bool]Contains($Key) { if ($this._Value.GetType().GetMethod('ContainsKey')) { return $this._Value.ContainsKey($Key) } else { return $this._Value.Contains($Key) } } [Bool]Exists($Key) { return $this.Contains($Key) } [Object]GetValue($Key) { return $this._Value[$Key] } [Object]GetValue($Key, $Default) { if (-not $This.Contains($Key)) { return $Default } return $this._Value[$Key] } SetValue($Key, $Value) { if ($Value -is [PSNode]) { $Value = $Value.Value } $this._Value[$Key] = $Value $this.Cache.Remove('ChildNodes') } Add($Key, $Value) { if ($this.Contains($Key)) { Throw "Item '$Key' has already been added." } if ($Value -is [PSNode]) { $Value = $Value.Value } $this._Value.Add($Key, $Value) $this.Cache.Remove('ChildNodes') } Remove($Key) { $null = $this._Value.Remove($Key) $this.Cache.Remove('ChildNodes') } hidden RemoveAt($Key) { # General method for: ChildNode.Remove() { $_.ParentNode.Remove($_.Name) } if (-not $this.Contains($Key)) { Throw "Item '$Key' doesn't exist." } $null = $this._Value.Remove($Key) $this.Cache.Remove('ChildNodes') } [Object]GetChildNode([Object]$Key) { if ($this.MaxDepthReached()) { return @() } if (-not $this.Contains($Key)) { Throw "The <Object>$($this.Path) doesn't contain a child named: $Key" } if (-not $this.Cache.ContainsKey('ChildNode')) { # The ChildNode cache case sensitivity is based on the current dictionary population. # The ChildNode cache is always ordinal, if the contained dictionary is invariant, extra entries might # appear in the cache but shouldn't effect the results other than slightly slow down the performance. # In other words, do not use the cache to count the entries. Custom comparers are not supported. $this.Cache['ChildNode'] = if ($this.get_CaseMatters()) { [HashTable]::new() } else { @{} } # default is case insensitive } elseif ( -not $this.Cache.ChildNode.ContainsKey($Key) -or -not [Object]::ReferenceEquals($this.Cache.ChildNode[$Key]._Value, $this._Value[$Key]) ) { if($null -eq $this.get_CaseMatters()) { # If the case was undetermined, check the new key for case sensitivity $this.Cache.CaseMatters = if ($Key -is [String] -and $Key -match '[a-z]') { $Case = if ([Int][Char]($Matches[0]) -ge 97) { $Key.ToUpper() } else { $Key.ToLower() } -not $this._Value.Contains($Case) -or $Case -cin $this._Value.Get_Keys() } if ($this.get_CaseMatters()) { $ChildNode = $this.Cache['ChildNode'] $this.Cache['ChildNode'] = [HashTable]::new() # Create a new cache as it appears to be case sensitive foreach ($Key in $ChildNode.get_Keys()) { # Migrate the content $this.Cache.ChildNode[$Key] = $ChildNode[$Key] } } } } if ( -not $this.Cache.ChildNode.ContainsKey($Key) -or -not [Object]::ReferenceEquals($this.Cache.ChildNode[$Key].Value, $this._Value[$Key]) ) { $Node = [PSNode]::ParseInput($this._Value[$Key]) $Node._Name = $Key $Node.Depth = $this.Depth + 1 $Node.RootNode = [PSNode]$this.RootNode $Node.ParentNode = $this $this.Cache.ChildNode[$Key] = $Node } return $this.Cache.ChildNode[$Key] } hidden [Object[]]get_ChildNodes() { if (-not $this.Cache.ContainsKey('ChildNodes')) { $ChildNodes = foreach ($Key in $this._Value.get_Keys()) { $this.GetChildNode($Key) } if ($null -ne $ChildNodes) { $this.Cache['ChildNodes'] = $ChildNodes } else { $this.Cache['ChildNodes'] = @() } } return $this.Cache['ChildNodes'] } [string]ToString() { return "$([TypeColor][PSSerialize]::new($this, [PSLanguageMode]'NoLanguage'))" } } Class PSObjectNode : PSMapNode { hidden static PSObjectNode() { Use-ClassAccessors } hidden PSObjectNode($Object) { if ($Object -is [PSNode]) { $this._Value = $Object._Value } else { $this._Value = $Object } } hidden [Object]get_Count() { return @($this._Value.PSObject.Properties).get_Count() } hidden [Object]get_Names() { return ,$this._Value.PSObject.Properties.Name } hidden [Object]get_Values() { return ,$this._Value.PSObject.Properties.Value } hidden [Object]get_CaseMatters() { return $false } [Bool]Contains($Name) { return $this._Value.PSObject.Properties[$Name] } [Bool]Exists($Name) { return $this._Value.PSObject.Properties[$Name] } [Object]GetValue($Name) { return $this._Value.PSObject.Properties[$Name].Value } [Object]GetValue($Name, $Default) { if (-not $This.Contains($Name)) { return $Default } return $this._Value[$Name] } SetValue($Name, $Value) { if ($Value -is [PSNode]) { $Value = $Value.Value } if ($this._Value -isnot [PSCustomObject]) { $Properties = [Ordered]@{} foreach ($Property in $this._Value.PSObject.Properties) { $Properties[$Property.Name] = $Property.Value } $Properties[$Name] = $Value $this._Value = [PSCustomObject]$Properties $this.Cache.Remove('ChildNodes') } elseif ($this._Value.PSObject.Properties[$Name]) { $this._Value.PSObject.Properties[$Name].Value = $Value } else { Add-Member -InputObject $this._Value -Type NoteProperty -Name $Name -Value $Value $this.Cache.Remove('ChildNodes') } } Add($Name, $Value) { if ($this.Contains($Name)) { Throw "Item '$Name' has already been added." } $this.SetValue($Name, $Value) } Remove($Name) { $this._Value.PSObject.Properties.Remove($Name) $this.Cache.Remove('ChildNodes') } hidden RemoveAt($Name) { # General method for: ChildNode.Remove() { $_.ParentNode.Remove($_.Name) } if (-not $this.Contains($Name)) { Throw "Item '$Name' doesn't exist." } $this._Value.PSObject.Properties.Remove($Name) $this.Cache.Remove('ChildNodes') } [Object]GetChildNode([String]$Name) { if ($this.MaxDepthReached()) { return @() } if (-not $this.Contains($Name)) { Throw Throw "$($this.GetPathName('<Root>')) doesn't contain a child named: $Name" } if (-not $this.Cache.ContainsKey('ChildNode')) { $this.Cache['ChildNode'] = @{} } # Object properties are case insensitive if ( -not $this.Cache.ChildNode.ContainsKey($Name) -or -not [Object]::ReferenceEquals($this.Cache.ChildNode[$Name]._Value, $this._Value.PSObject.Properties[$Name].Value) ) { $Node = [PSNode]::ParseInput($this._Value.PSObject.Properties[$Name].Value) $Node._Name = $Name $Node.Depth = $this.Depth + 1 $Node.RootNode = [PSNode]$this.RootNode $Node.ParentNode = $this $this.Cache.ChildNode[$Name] = $Node } return $this.Cache.ChildNode[$Name] } hidden [Object[]]get_ChildNodes() { if (-not $this.Cache.ContainsKey('ChildNodes')) { $ChildNodes = foreach ($Property in $this._Value.PSObject.Properties) { if ($Property.Value -isnot [Reflection.MemberInfo]) { $this.GetChildNode($Property.Name) } } if ($null -ne $ChildNodes) { $this.Cache['ChildNodes'] = $ChildNodes } else { $this.Cache['ChildNodes'] = @() } } return $this.Cache['ChildNodes'] } [string]ToString() { return "$([TypeColor][PSSerialize]::new($this, [PSLanguageMode]'NoLanguage'))" } } class PSListNodeComparer : ObjectComparer, IComparer[Object] { # https://github.com/PowerShell/PowerShell/issues/23959 PSListNodeComparer () {} PSListNodeComparer ([String[]]$PrimaryKey) { $this.PrimaryKey = $PrimaryKey } PSListNodeComparer ([ObjectComparison]$ObjectComparison) { $this.ObjectComparison = $ObjectComparison } PSListNodeComparer ([String[]]$PrimaryKey, [ObjectComparison]$ObjectComparison) { $this.PrimaryKey = $PrimaryKey; $this.ObjectComparison = $ObjectComparison } PSListNodeComparer ([ObjectComparison]$ObjectComparison, [String[]]$PrimaryKey) { $this.PrimaryKey = $PrimaryKey; $this.ObjectComparison = $ObjectComparison } [int] Compare ([Object]$Node1, [Object]$Node2) { return $this.CompareRecurse($Node1, $Node2, 'Compare') } } Class TextColor : TextStyle { TextColor($Text, $AnsiColor) : base($Text, $AnsiColor, [ANSI]::ResetColor) {} } Class CommandColor : TextColor { CommandColor($Text) : base($Text, [ANSI]::CommandColor) {} } Class CommentColor : TextColor { CommentColor($Text) : base($Text, [ANSI]::CommentColor) {} } Class ContinuationPromptColor : TextColor { ContinuationPromptColor($Text) : base($Text, [ANSI]::ContinuationPromptColor) {} } Class DefaultTokenColor : TextColor { DefaultTokenColor($Text) : base($Text, [ANSI]::DefaultTokenColor) {} } Class EmphasisColor : TextColor { EmphasisColor($Text) : base($Text, [ANSI]::EmphasisColor) {} } Class ErrorColor : TextColor { ErrorColor($Text) : base($Text, [ANSI]::ErrorColor) {} } Class KeywordColor : TextColor { KeywordColor($Text) : base($Text, [ANSI]::KeywordColor) {} } Class MemberColor : TextColor { MemberColor($Text) : base($Text, [ANSI]::MemberColor) {} } Class NumberColor : TextColor { NumberColor($Text) : base($Text, [ANSI]::NumberColor) {} } Class OperatorColor : TextColor { OperatorColor($Text) : base($Text, [ANSI]::OperatorColor) {} } Class ParameterColor : TextColor { ParameterColor($Text) : base($Text, [ANSI]::ParameterColor) {} } Class SelectionColor : TextColor { SelectionColor($Text) : base($Text, [ANSI]::SelectionColor) {} } Class StringColor : TextColor { StringColor($Text) : base($Text, [ANSI]::StringColor) {} } Class TypeColor : TextColor { TypeColor($Text) : base($Text, [ANSI]::TypeColor) {} } Class VariableColor : TextColor { VariableColor($Text) : base($Text, [ANSI]::VariableColor) {} } Class InverseColor : TextStyle { InverseColor($Text) : base($Text, [ANSI]::InverseColor, [ANSI]::InverseOff) {} } #EndRegion Class #Region Function function Use-ClassAccessors { <# .SYNOPSIS Implements class getter and setter accessors. .DESCRIPTION The [Use-ClassAccessors][1] cmdlet updates script property of a class from the getter and setter methods. Which are also known as [accessors or mutator methods][2]. The getter and setter methods should use the following syntax: ### getter syntax [<type>] get_<property name>() { return <variable> } or: [Object] get_<property name>() { return ,[<Type>]<variable> } ### setter syntax set_<property name>(<variable>) { <code> } > [!NOTE] > A **setter** accessor requires a **getter** accessor to implement the related property. > [!NOTE] > In most cases, you might want to hide the getter and setter methods using the [`hidden` keyword][3] > on the getter and setter methods. .EXAMPLE # Using class accessors The following example defines a getter and setter for a `value` property and a _readonly_ property for the type of the type of the contained value. Install-Script -Name Use-ClassAccessors Class ExampleClass { hidden $_Value hidden [Object] get_Value() { return $this._Value } hidden set_Value($Value) { $this._Value = $Value } hidden [Type]get_Type() { if ($Null -eq $this.Value) { return $Null } else { return $this._Value.GetType() } } hidden static ExampleClass() { Use-ClassAccessors } } $Example = [ExampleClass]::new() $Example.Value = 42 # Set value to 42 $Example.Value # Returns 42 $Example.Type # Returns [Int] type info $Example.Type = 'Something' # Throws readonly error .PARAMETER Class Specifies the class from which the accessor need to be initialized. Default: The class from which this function is invoked (by its static initializer). .PARAMETER Property Filters the property that requires to be (re)initialized. Default: All properties in the given class .PARAMETER Force Indicates that the cmdlet reloads the specified accessors, even if the accessors already have been defined for the concerned class. .LINK [1]: https://github.com/iRon7/Use-ClassAccessors "Online Help" [2]: https://en.wikipedia.org/wiki/Mutator_method "Mutator method" [3]: https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_classes#hidden-keyword "Hidden keyword in classes" #> param( [Parameter(ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] [ValidateNotNullOrEmpty()] [string[]]$Class, [Parameter(ValueFromPipelineByPropertyName=$true)] [ValidateNotNullOrEmpty()] [string]$Property, [switch]$Force ) process { $ClassNames = if ($Class) { $Class } else { $Caller = (Get-PSCallStack)[1] if ($Caller.FunctionName -ne '<ScriptBlock>') { $Caller.FunctionName } elseif ($Caller.ScriptName) { $Ast = [System.Management.Automation.Language.Parser]::ParseFile($Caller.ScriptName, [ref]$Null, [ref]$Null) $Ast.EndBlock.Statements.where{ $_.IsClass }.Name } } foreach ($ClassName in $ClassNames) { $TargetType = $ClassName -as [Type] if (-not $TargetType) { Write-Warning "Class not found: $ClassName" } $TypeData = Get-TypeData -TypeName $ClassName $Members = if ($TypeData -and $TypeData.Members) { $TypeData.Members.get_Keys() } $Methods = if ($Property) { $TargetType.GetMethod("get_$Property") $TargetType.GetMethod("set_$Property") } else { $NativeProperties = $TargetType.GetProperties() $NativeNames = if ($NativeProperties) { $NativeProperties.Name } $targetType.GetMethods().where{ -not $_.IsStatic -and ($_.Name -Like 'get_*' -or $_.Name -Like 'set_*') -and $_.Name -NotLike '???__*' -and $_.Name.SubString(4) -notin $NativeNames } } $Accessors = [Ordered]@{} foreach ($Method in $Methods) { $Member = $Method.Name.SubString(4) if (-not $Force -and $Member -in $Members) { continue } $Parameters = $Method.GetParameters() if ($Method.Name -Like 'get_*') { if ($Parameters.Count -eq 0) { if ($Method.ReturnType.IsArray) { $Expression = @" `$TargetType = '$ClassName' -as [Type] `$Method = `$TargetType.GetMethod('$($Method.Name)') `$Invoke = `$Method.Invoke(`$this, `$Null) `$Output = `$Invoke -as '$($Method.ReturnType.FullName)' if (@(`$Invoke).Count -gt 1) { `$Output } else { ,`$Output } "@ } else { $Expression = @" `$TargetType = '$ClassName' -as [Type] `$Method = `$TargetType.GetMethod('$($Method.Name)') `$Method.Invoke(`$this, `$Null) -as '$($Method.ReturnType.FullName)' "@ } if (-not $Accessors.Contains($Member)) { $Accessors[$Member] = @{} } $Accessors[$Member].Value = [ScriptBlock]::Create($Expression) } else { Write-Warning "The getter '$($Method.Name)' is skipped as it is not parameter-less." } } elseif ($Method.Name -Like 'set_*') { if ($Parameters.Count -eq 1) { $Expression = @" `$TargetType = '$ClassName' -as [Type] `$Method = `$TargetType.GetMethod('$($Method.Name)') `$Method.Invoke(`$this, `$Args) "@ if (-not $Accessors.Contains($Member)) { $Accessors[$Member] = @{} } $Accessors[$Member].SecondValue = [ScriptBlock]::Create($Expression) } else { Write-Warning "The setter '$($Method.Name)' is skipped as it does not have a single parameter" } } } foreach ($MemberName in $Accessors.get_Keys()) { $TypeData = $Accessors[$MemberName] if ($TypeData.Contains('Value')) { $TypeData.TypeName = $ClassName $TypeData.MemberType = 'ScriptProperty' $TypeData.MemberName = $MemberName $TypeData.Force = $Force Update-TypeData @TypeData } else { Write-Warning "'[$ClassName].set_$MemberName()' accessor requires a '[$ClassName].get_$MemberName()' accessor." } } } } } #EndRegion Function #Region Cmdlet function Compare-ObjectGraph { <# .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 IgnoreLisOrder By default, items in a list are matched independent of the order (meaning by index position). If the `-IgnoreListOrder` switch is supplied, any list in the `$InputObject` is searched for a match with the reference. > [!NOTE] > Regardless the list order, any dictionary lists are matched by the primary key (if supplied) first. .PARAMETER MatchMapOrder By default, items in dictionary (including properties of an PSCustomObject or Component Object) are matched by their key name (independent of the order). If the `-MatchMapOrder` switch is supplied, each entry is also validated by the position. > [!NOTE] > A `[HashTable]` type is unordered by design and therefore, regardless the `-MatchMapOrder` switch, the order of the `[HashTable]` (defined by the `$Reference`) are always ignored. .PARAMETER MaxDepth The maximal depth to recursively compare each embedded property (default: 10). #> [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]$IgnoreListOrder, [Switch]$MatchMapOrder, [Alias('Depth')][int]$MaxDepth = [PSNode]::DefaultMaxDepth ) begin { $ObjectComparison = [ObjectComparison]0 [ObjectComparison].GetEnumNames().foreach{ if ($PSBoundParameters.ContainsKey($_) -and $PSBoundParameters[$_]) { $ObjectComparison = $ObjectComparison -bor [ObjectComparison]$_ } } $ObjectComparer = [ObjectComparer]@{ PrimaryKey = $PrimaryKey; ObjectComparison = $ObjectComparison } $Node1 = [PSNode]::ParseInput($Reference, $MaxDepth) } process { $Node2 = [PSNode]::ParseInput($InputObject, $MaxDepth) if ($IsEqual) { $ObjectComparer.IsEqual($Node1, $Node2) } else { $ObjectComparer.Report($Node1, $Node2) } } } function ConvertFrom-Expression { <# .SYNOPSIS Deserializes a PowerShell expression to an object. .DESCRIPTION The `ConvertFrom-Expression` cmdlet safely converts a PowerShell formatted expression to an object-graph existing of a mixture of nested arrays, hash tables and objects that contain a list of strings and values. .PARAMETER InputObject Specifies the PowerShell expressions to convert to objects. Enter a variable that contains the string, or type a command or expression that gets the string. You can also pipe a string to ConvertFrom-Expression. The **InputObject** parameter is required, but its value can be an empty string. The **InputObject** value can't be `$null` or an empty string. .PARAMETER LanguageMode Defines which object types are allowed for the deserialization, see: [About language modes][2] * Any type that is not allowed by the given language mode, will be omitted leaving a bare `[ValueType]`, `[String]`, `[Array]` or `[HashTable]`. * Any variable that is not `$True`, `$False` or `$Null` will be converted to a literal string, e.g. `$Test`. > [!Caution] > > In full language mode, `ConvertTo-Expression` permits all type initializers. Cmdlets, functions, > CIM commands, and workflows will *not* be invoked by the `ConvertFrom-Expression` cmdlet. > > Take reasonable precautions when using the `Invoke-Expression -LanguageMode Full` command in scripts. > Verify that the class types in the expression are safe before instantiating them. In general, it is > best to design your configuration expressions with restricted or constrained classes, rather than > allowing full freeform expressions. .PARAMETER ListAs If supplied, the array subexpression `@( )` syntaxes without an type initializer or with an unknown or denied type initializer will be converted to the given list type. .PARAMETER MapAs If supplied, the Hash table literal syntax `@{ }` syntaxes without an type initializer or with an unknown or denied type initializer will be converted to the given map (dictionary or object) type. #> [Alias('cfe')] [CmdletBinding(HelpUri='https://github.com/iRon7/ObjectGraphTools/blob/main/Docs/ConvertFrom-Expression.md')][OutputType([Object])] param( [Parameter(Mandatory = $true, ValueFromPipeLine = $True)] [Alias('Expression')][String]$InputObject, [ValidateScript({ $_ -ne 'NoLanguage' })] [System.Management.Automation.PSLanguageMode]$LanguageMode = 'Restricted', [ValidateNotNull()][Alias('ArrayAs')]$ListAs, [ValidateNotNull()][Alias('DictionaryAs')]$MapAs ) begin { function StopError($Exception, $Id = 'IncorrectArgument', $Group = [Management.Automation.ErrorCategory]::SyntaxError, $Object){ if ($Exception -is [System.Management.Automation.ErrorRecord]) { $Exception = $Exception.Exception } elseif ($Exception -isnot [Exception]) { $Exception = [ArgumentException]$Exception } $PSCmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new($Exception, $Id, $Group, $Object)) } if ($this.LanguageMode -eq 'NoLanguage') { Throw 'The language mode "NoLanguage" is not supported.' } $ListNode = if ($ListAs) { [PSNode]::ParseInput([PSInstance]::Create($ListAs)) } $MapNode = if ($MapAs) { [PSNode]::ParseInput([PSInstance]::Create($MapAs)) } if ( $ListNode -is [PSMapNode] -and $MapNode -is [PSListNode] -or -not $ListNode -and $MapNode -is [PSListNode] -or $ListNode -is [PSMapNode] -and -not $MapNode ) { $ListNode, $MapNode = $MapNode, $ListNode # In case the parameter positions are swapped } $ListType = if ($ListNode) { if ($ListType -is [PSListNode]) { $ListNode.ValueType } else { StopError 'The -ListAs parameter requires a string, type or an object example that supports a list structure' } } $MapType = if ($MapNode) { if ($MapNode -is [PSMapNode]) { $MapNode.ValueType } else { StopError 'The -MapAs parameter requires a string, type or an object example that supports a map structure' } } if ('System.Management.Automation.PSCustomObject' -eq $MapNode.ValueType) { $MapType = 'PSCustomObject' -as [type] } # https://github.com/PowerShell/PowerShell/issues/2295 } process { [PSDeserialize]::new($InputObject, $LanguageMode, $ListType, $MapType).Object } } function ConvertTo-Expression { <# .SYNOPSIS Serializes an object to a PowerShell expression. .DESCRIPTION The ConvertTo-Expression cmdlet converts (serializes) an object to a PowerShell expression. The object can be stored in a variable, (.psd1) file or any other common storage for later use or to be ported to another system. expressions might be restored to an object using the native [Invoke-Expression] cmdlet: $Object = Invoke-Expression ($Object | ConvertTo-Expression) > [!Warning] > Take reasonable precautions when using the Invoke-Expression cmdlet in scripts. When using `Invoke-Expression` > to run a command that the user enters, verify that the command is safe to run before running it. > In general, it is best to restore your objects using [ConvertFrom-Expression]. > [!Note] > Some object types can not be reconstructed from a simple serialized expression. .INPUTS Any. Each objects provided through the pipeline will converted to an expression. To concatenate all piped objects in a single expression, use the unary comma operator, e.g.: `,$Object | ConvertTo-Expression` .OUTPUTS String[]. `ConvertTo-Expression` returns a PowerShell [String] expression for each input object. .PARAMETER InputObject Specifies the objects to convert to a PowerShell expression. Enter a variable that contains the objects, or type a command or expression that gets the objects. You can also pipe one or more objects to `ConvertTo-Expression.` .PARAMETER LanguageMode Defines which object types are allowed for the serialization, see: [About language modes][2] If a specific type isn't allowed in the given language mode, it will be substituted by: * **`$Null`** in case of a null value * **`$False`** in case of a boolean false * **`$True`** in case of a boolean true * **A number** in case of a primitive value * **A string** in case of a string or any other **leaf** node * `@(...)` for an array (**list** node) * `@{...}` for any dictionary, PSCustomObject or Component (aka **map** node) See the [PSNode Object Parser][1] for a detailed definition on node types. .PARAMETER ExpandDepth Defines up till what level the collections will be expanded in the output. * A `-ExpandDepth 0` will create a single line expression. * A `-ExpandDepth -1` will compress the single line by removing command spaces. > [!Note] > White spaces (as newline characters and spaces) will not be removed from the content > of a (here) string. .PARAMETER Explicit By default, restricted language types initializers are suppressed. When the `Explicit` switch is set, *all* values will be prefixed with an initializer (as e.g. `[Long]` and `[Array]`) > [!Note] > The `-Explicit` switch can not be used in **restricted** language mode .PARAMETER FullTypeName In case a value is prefixed with an initializer, the full type name of the initializer is used. > [!Note] > The `-FullTypename` switch can not be used in **restricted** language mode and will only be > meaningful if the initializer is used (see also the [-Explicit] switch). .PARAMETER HighFidelity If the `-HighFidelity` switch is supplied, all nested object properties will be serialized. By default the fidelity of an object expression will end if: 1) the (embedded) object is a leaf node (see: [PSNode Object Parser][1]) 2) the (embedded) object expression is able to round trip. An object is able to roundtrip if the resulted expression of the object itself or one of its properties (prefixed with the type initializer) can be used to rebuild the object. The advantage of the default fidelity is that the resulted expression round trips (aka the object might be rebuild from the expression), the disadvantage is that information hold by less significant properties is lost (as e.g. timezone information in a `DateTime]` object). The advantage of the high fidelity switch is that all the information of the underlying properties is shown, yet any constrained or full object type will likely fail to rebuild due to constructor limitations such as readonly property. > [!Note] > Objects properties of type `[Reflection.MemberInfo]` are always excluded. .PARAMETER ExpandSingleton (List or map) collections nodes that contain a single item will not be expanded unless this `-ExpandSingleton` is supplied. .PARAMETER IndentSize Specifies indent used for the nested properties. .PARAMETER MaxDepth Specifies how many levels of contained objects are included in the PowerShell representation. The default value is define by the PowerShell object node parser (`[PSNode]::DefaultMaxDepth`). .LINK [1]: https://github.com/iRon7/ObjectGraphTools/blob/main/Docs/ObjectParser.md "PowerShell Object Parser" [2]: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_modes "About language modes" #> [Alias('cto')] [CmdletBinding(HelpUri='https://github.com/iRon7/ObjectGraphTools/blob/main/Docs/ConvertTo-Expression.md')][OutputType([String])] param( [Parameter(Mandatory = $true, ValueFromPipeLine = $True)] $InputObject, [ValidateScript({ $_ -ne 'NoLanguage' })] [System.Management.Automation.PSLanguageMode]$LanguageMode = 'Restricted', [Alias('Expand')][Int]$ExpandDepth = [Int]::MaxValue, [Switch]$Explicit, [Switch]$FullTypeName, [Switch]$HighFidelity, [Switch]$ExpandSingleton, [String]$Indent = ' ', [Alias('Depth')][int]$MaxDepth = [PSNode]::DefaultMaxDepth ) begin { function StopError($Exception, $Id = 'IncorrectArgument', $Group = [Management.Automation.ErrorCategory]::SyntaxError, $Object){ if ($Exception -is [System.Management.Automation.ErrorRecord]) { $Exception = $Exception.Exception } elseif ($Exception -isnot [Exception]) { $Exception = [ArgumentException]$Exception } $PSCmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new($Exception, $Id, $Group, $Object)) } if ($this.LanguageMode -eq 'NoLanguage') { Throw 'The language mode "NoLanguage" is not supported.' } if (-not ('ConstrainedLanguage', 'FullLanguage' -eq $LanguageMode)) { if ($Explicit) { StopError 'The Explicit switch requires Constrained - or FullLanguage mode.' } if ($FullTypeName) { StopError 'The FullTypeName switch requires Constrained - or FullLanguage mode.' } } } process { $Node = [PSNode]::ParseInput($InputObject, $MaxDepth) [PSSerialize]::new( $Node, $LanguageMode, $ExpandDepth, $Explicit, $FullTypeName, $HighFidelity, $ExpandSingleton, $Indent ) } } function Copy-ObjectGraph { <# .SYNOPSIS Copy object graph .DESCRIPTION Recursively ("deep") copies a object graph. .EXAMPLE # Deep copy a complete object graph into a new object graph $NewObjectGraph = Copy-ObjectGraph $ObjectGraph .EXAMPLE # Copy (convert) an object graph using common PowerShell arrays and PSCustomObjects $PSObject = Copy-ObjectGraph $Object -ListAs [Array] -DictionaryAs PSCustomObject .EXAMPLE # Convert a Json string to an object graph with (case insensitive) ordered dictionaries $PSObject = $Json | ConvertFrom-Json | Copy-ObjectGraph -DictionaryAs ([Ordered]@{}) .PARAMETER InputObject The input object that will be recursively copied. .PARAMETER ListAs If supplied, lists will be converted to the given type (or type of the supplied object example). .PARAMETER DictionaryAs If supplied, dictionaries will be converted to the given type (or type of the supplied object example). This parameter also accepts the [`PSCustomObject`][1] types By default (if the [-DictionaryAs] parameters is omitted), [`Component`][2] objects will be converted to a [`PSCustomObject`][1] type. .PARAMETER ExcludeLeafs If supplied, only the structure (lists, dictionaries, [`PSCustomObject`][1] types and [`Component`][2] types will be copied. If omitted, each leaf will be shallow copied .LINK [1]: https://learn.microsoft.com/dotnet/api/system.management.automation.pscustomobject "PSCustomObject Class" [2]: https://learn.microsoft.com/dotnet/api/system.componentmodel.component "Component Class" #> [Alias('Copy-Object', 'cpo')] [OutputType([Object[]])] [CmdletBinding(DefaultParameterSetName = 'ListAs', HelpUri='https://github.com/iRon7/ObjectGraphTools/blob/main/Docs/Copy-ObjectGraph.md')] param( [Parameter(Mandatory = $true, ValueFromPipeLine = $true)] $InputObject, [ValidateNotNull()][Alias('ArrayAs')]$ListAs, [ValidateNotNull()][Alias('DictionaryAs')]$MapAs, [Switch]$ExcludeLeafs, [Alias('Depth')][int]$MaxDepth = [PSNode]::DefaultMaxDepth ) begin { function StopError($Exception, $Id = 'IncorrectArgument', $Group = [Management.Automation.ErrorCategory]::SyntaxError, $Object){ if ($Exception -is [System.Management.Automation.ErrorRecord]) { $Exception = $Exception.Exception } elseif ($Exception -isnot [Exception]) { $Exception = [ArgumentException]$Exception } $PSCmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new($Exception, $Id, $Group, $Object)) } $ListNode = if ($PSBoundParameters.ContainsKey('ListAs')) { [PSNode]::ParseInput([PSInstance]::Create($ListAs)) } $MapNode = if ($PSBoundParameters.ContainsKey('MapAs')) { [PSNode]::ParseInput([PSInstance]::Create($MapAs)) } if ( $ListNode -is [PSMapNode] -and $MapNode -is [PSListNode] -or -not $ListNode -and $MapNode -is [PSListNode] -or $ListNode -is [PSMapNode] -and -not $MapNode ) { $ListNode, $MapNode = $MapNode, $ListNode # In case the parameter positions are swapped } $ListType = if ($ListNode) { if ($ListNode -is [PSListNode]) { $ListNode.ValueType } else { StopError 'The -ListAs parameter requires a string, type or an object example that supports a list structure' } } $MapType = if ($MapNode) { if ($MapNode -is [PSMapNode]) { $MapNode.ValueType } else { StopError 'The -MapAs parameter requires a string, type or an object example that supports a map structure' } } if ('System.Management.Automation.PSCustomObject' -eq $MapNode.ValueType) { $MapType = 'PSCustomObject' -as [type] } # https://github.com/PowerShell/PowerShell/issues/2295 function CopyObject( [PSNode]$Node, [Type]$ListType, [Type]$MapType, [Switch]$ExcludeLeafs ) { if ($Node -is [PSLeafNode]) { if ($ExcludeLeafs -or $Null -eq $Node.Value) { return $Node.Value } else { $Node.Value.PSObject.Copy() } } elseif ($Node -is [PSListNode]) { $Type = if ($Null -ne $ListType) { $ListType } else { $Node.ValueType } $Values = foreach ($ChildNode in $Node.ChildNodes) { CopyObject $ChildNode -ListType $ListType -MapType $MapType } $Values = $Values -as $Type ,$Values } elseif ($Node -is [PSMapNode]) { $Type = if ($Null -ne $MapType) { $MapType } else { $Node.ValueType } $IsDirectory = $Null -ne $Type.GetInterface('IDictionary') if ($Type.FullName -eq 'System.Collections.Hashtable') { $Dictionary = @{} } # Case insensitive elseif ($IsDirectory) { $Dictionary = New-Object -Type $Type } else { $Dictionary = [Ordered]@{} } foreach ($ChildNode in $Node.ChildNodes) { $Dictionary[[Object]$ChildNode.Name] = CopyObject $ChildNode -ListType $ListType -MapType $MapType } if ($IsDirectory) { $Dictionary } else { [PSCustomObject]$Dictionary } } } } process { $PSNode = [PSNode]::ParseInput($InputObject, $MaxDepth) CopyObject $PSNode -ListType $ListType -MapType $MapType -ExcludeLeafs:$ExcludeLeafs } } function Export-ObjectGraph { <# .SYNOPSIS Serializes a PowerShell File or object-graph and exports it to a PowerShell (data) file. .DESCRIPTION The `Export-ObjectGraph` cmdlet converts a PowerShell (complex) object to an PowerShell expression and exports it to a PowerShell (`.ps1`) file or a PowerShell data (`.psd1`) file. .PARAMETER Path Specifies the path to a file where `Export-ObjectGraph` exports the ObjectGraph. Wildcard characters are permitted. .PARAMETER LiteralPath Specifies a path to one or more locations where PowerShell should export the object-graph. The value of LiteralPath is used exactly as it's typed. No characters are interpreted as wildcards. If the path includes escape characters, enclose it in single quotation marks. Single quotation marks tell PowerShell not to interpret any characters as escape sequences. .PARAMETER LanguageMode Defines which object types are allowed for the serialization, see: [About language modes][2] If a specific type isn't allowed in the given language mode, it will be substituted by: * **`$Null`** in case of a null value * **`$False`** in case of a boolean false * **`$True`** in case of a boolean true * **A number** in case of a primitive value * **A string** in case of a string or any other **leaf** node * `@(...)` for an array (**list** node) * `@{...}` for any dictionary, PSCustomObject or Component (aka **map** node) See the [PSNode Object Parser][1] for a detailed definition on node types. .PARAMETER ExpandDepth Defines up till what level the collections will be expanded in the output. * A `-ExpandDepth 0` will create a single line expression. * A `-ExpandDepth -1` will compress the single line by removing command spaces. > [!Note] > White spaces (as newline characters and spaces) will not be removed from the content > of a (here) string. .PARAMETER Explicit By default, restricted language types initializers are suppressed. When the `Explicit` switch is set, *all* values will be prefixed with an initializer (as e.g. `[Long]` and `[Array]`) > [!Note] > The `-Explicit` switch can not be used in **restricted** language mode .PARAMETER FullTypeName In case a value is prefixed with an initializer, the full type name of the initializer is used. > [!Note] > The `-FullTypename` switch can not be used in **restricted** language mode and will only be > meaningful if the initializer is used (see also the [-Explicit] switch). .PARAMETER HighFidelity If the `-HighFidelity` switch is supplied, all nested object properties will be serialized. By default the fidelity of an object expression will end if: 1) the (embedded) object is a leaf node (see: [PSNode Object Parser][1]) 2) the (embedded) object expression is able to round trip. An object is able to roundtrip if the resulted expression of the object itself or one of its properties (prefixed with the type initializer) can be used to rebuild the object. The advantage of the default fidelity is that the resulted expression round trips (aka the object might be rebuild from the expression), the disadvantage is that information hold by less significant properties is lost (as e.g. timezone information in a `DateTime]` object). The advantage of the high fidelity switch is that all the information of the underlying properties is shown, yet any constrained or full object type will likely fail to rebuild due to constructor limitations such as readonly property. > [!Note] > Objects properties of type `[Reflection.MemberInfo]` are always excluded. .PARAMETER ExpandSingleton (List or map) collections nodes that contain a single item will not be expanded unless this `-ExpandSingleton` is supplied. .PARAMETER IndentSize Specifies indent used for the nested properties. .PARAMETER MaxDepth Specifies how many levels of contained objects are included in the PowerShell representation. The default value is defined by the PowerShell object node parser (`[PSNode]::DefaultMaxDepth`, default: `20`). .PARAMETER Encoding Specifies the type of encoding for the target file. The default value is `utf8NoBOM`. .LINK [1]: https://github.com/iRon7/ObjectGraphTools/blob/main/Docs/ObjectParser.md "PowerShell Object Parser" [2]: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_modes "About language modes" #> [Alias('Export-Object', 'epo')] [CmdletBinding(DefaultParameterSetName='Path', HelpUri='https://github.com/iRon7/ObjectGraphTools/blob/main/Docs/Export-ObjectGraph.md')] param( [Parameter(Mandatory = $true, ValueFromPipeLine = $True)] $InputObject, [Parameter(ParameterSetName='Path', Mandatory=$true, Position=0, ValueFromPipelineByPropertyName=$true)] [string[]] $Path, [Parameter(ParameterSetName='LiteralPath', Mandatory=$true, ValueFromPipelineByPropertyName=$true)] [Alias('PSPath','LP')] [string[]] $LiteralPath, [ValidateScript({ $_ -ne 'NoLanguage' })] [System.Management.Automation.PSLanguageMode]$LanguageMode, [Alias('Expand')][Int]$ExpandDepth = [Int]::MaxValue, [Switch]$Explicit, [Switch]$FullTypeName, [Switch]$HighFidelity, [Switch]$ExpandSingleton, [String]$Indent = ' ', [Alias('Depth')][int]$MaxDepth = [PSNode]::DefaultMaxDepth, [ValidateNotNullOrEmpty()]$Encoding ) begin { $Extension = if ($Path) { [System.IO.Path]::GetExtension($Path) } else { [System.IO.Path]::GetExtension($LiteralPath) } if (-not $PSBoundParameters.ContainsKey('LanguageMode')) { $PSBoundParameters['LanguageMode'] = if ($Extension -eq '.psd1') { 'Restricted' } else { 'Constrained' } } $ToExpressionParameters = 'LanguageMode', 'ExpandDepth', 'Explicit', 'FullTypeName', '$HighFidelity', 'ExpandSingleton', 'Indent', 'MaxDepth' $ToExpressionArguments = @{} $ToExpressionParameters.where{ $PSBoundParameters.ContainsKey($_) }.foreach{ $ToExpressionArguments[$_] = $PSBoundParameters[$_] } $ToExpressionContext = $ExecutionContext.InvokeCommand.GetCommand('ObjectGraphTools\ConvertTo-Expression', [System.Management.Automation.CommandTypes]::Cmdlet) $ToExpressionPipeline = { & $ToExpressionContext @ToExpressionArguments }.GetSteppablePipeline() $ToExpressionPipeline.Begin($True) $SetContentArguments = @{} @('Path', 'LiteralPath', 'Encoding').where{ $PSBoundParameters.ContainsKey($_) }.foreach{ $SetContentArguments[$_] = $PSBoundParameters[$_] } } process { $Expression = $ToExpressionPipeline.Process($InputObject) Set-Content @SetContentArguments -Value $Expression } end { $ToExpressionPipeline.End() } } function Get-ChildNode { <# .SYNOPSIS Gets the child nodes of an object-graph .DESCRIPTION Gets the (unique) nodes and child nodes in one or more specified locations of an object-graph The returned nodes are unique even if the provide list of input parent nodes have an overlap. .EXAMPLE # Select all leaf nodes in a object graph Given the following object graph: $Object = @{ Comment = 'Sample ObjectGraph' Data = @( @{ Index = 1 Name = 'One' Comment = 'First item' } @{ Index = 2 Name = 'Two' Comment = 'Second item' } @{ Index = 3 Name = 'Three' Comment = 'Third item' } ) } The following example will receive all leaf nodes: $Object | Get-ChildNode -Recurse -Leaf Path Name Depth Value ---- ---- ----- ----- .Data[0].Comment Comment 3 First item .Data[0].Name Name 3 One .Data[0].Index Index 3 1 .Data[1].Comment Comment 3 Second item .Data[1].Name Name 3 Two .Data[1].Index Index 3 2 .Data[2].Comment Comment 3 Third item .Data[2].Name Name 3 Three .Data[2].Index Index 3 3 .Comment Comment 1 Sample ObjectGraph .EXAMPLE # update a property The following example selects all child nodes named `Comment` at a depth of `3`. Than filters the one that has an `Index` sibling with the value `2` and eventually sets the value (of the `Comment` node) to: 'Two to the Loo'. $Object | Get-ChildNode -AtDepth 3 -Include Comment | Where-Object { $_.ParentNode.GetChildNode('Index').Value -eq 2 } | ForEach-Object { $_.Value = 'Two to the Loo' } ConvertTo-Expression $Object @{ Data = @{ Comment = 'First item' Name = 'One' Index = 1 }, @{ Comment = 'Two to the Loo' Name = 'Two' Index = 2 }, @{ Comment = 'Third item' Name = 'Three' Index = 3 } Comment = 'Sample ObjectGraph' } See the [PowerShell Object Parser][1] For details on the `[PSNode]` properties and methods. .PARAMETER InputObject The concerned object graph or node. .PARAMETER Recurse Recursively iterates through all embedded property objects (nodes) to get the selected nodes. The maximum depth of of a specific node that might be retrieved is define by the `MaxDepth` of the (root) node. To change the maximum depth the (root) node needs to be loaded first, e.g.: Get-Node <InputObject> -Depth 20 | Get-ChildNode ... (See also: [`Get-Node`][2]) > [!NOTE] > If the [AtDepth] parameter is supplied, the object graph is recursively searched anyways > for the selected nodes up till the deepest given `AtDepth` value. .PARAMETER AtDepth When defined, only returns nodes at the given depth(s). > [!NOTE] > The nodes below the `MaxDepth` can not be retrieved. .PARAMETER ListChild Returns the closest nodes derived from a **list node**. .PARAMETER Include Returns only nodes derived from a **map node** including only the ones specified by one or more string patterns defined by this parameter. Wildcard characters are permitted. > [!NOTE] > The [-Include] and [-Exclude] parameters can be used together. However, the exclusions are applied > after the inclusions, which can affect the final output. .PARAMETER Exclude Returns only nodes derived from a **map node** excluding the ones specified by one or more string patterns defined by this parameter. Wildcard characters are permitted. > [!NOTE] > The [-Include] and [-Exclude] parameters can be used together. However, the exclusions are applied > after the inclusions, which can affect the final output. .PARAMETER Literal The values of the [-Include] - and [-Exclude] parameters are used exactly as it is typed. No characters are interpreted as wildcards. .PARAMETER Leaf Only return leaf nodes. Leaf nodes are nodes at the end of a branch and do not have any child nodes. You can use the [-Recurse] parameter with the [-Leaf] parameter. .PARAMETER IncludeSelf Includes the current node with the returned child nodes. .PARAMETER ValueOnly returns the value of the node instead of the node itself. .PARAMETER MaxDepth Specifies the maximum depth that an object graph might be recursively iterated before it throws an error. The failsafe will prevent infinitive loops for circular references as e.g. in: $Test = @{Guid = New-Guid} $Test.Parent = $Test The default `MaxDepth` is defined by `[PSNode]::DefaultMaxDepth = 10`. > [!Note] > The `MaxDepth` is bound to the root node of the object graph. Meaning that a descendant node > at depth of 3 can only recursively iterated (`10 - 3 =`) `7` times. .LINK [1]: https://github.com/iRon7/ObjectGraphTools/blob/main/Docs/ObjectParser.md "PowerShell Object Parser" [2]: https://github.com/iRon7/ObjectGraphTools/blob/main/Docs/Get-Node.md "Get-Node" #> [Alias('gcn')] [OutputType([PSNode[]])] [CmdletBinding(DefaultParameterSetName='ListChild', HelpUri='https://github.com/iRon7/ObjectGraphTools/blob/main/Docs/Get-ChildNode.md')] param( [Parameter(Mandatory = $true, ValueFromPipeLine = $true)] $InputObject, [switch] $Recurse, [ValidateRange(0, [int]::MaxValue)] [int[]] $AtDepth, [Parameter(ParameterSetName='ListChild')] [switch] $ListChild, [Parameter(ParameterSetName='MapChild', Position = 0)] [string[]] $Include, [Parameter(ParameterSetName='MapChild')] [string[]] $Exclude, [Parameter(ParameterSetName='MapChild')] [switch] $Literal, [switch] $Leaf, [Alias('Self')][switch] $IncludeSelf, [switch] $ValueOnly, [Int] $MaxDepth ) begin { $SearchDepth = if ($PSBoundParameters.ContainsKey('AtDepth')) { [System.Linq.Enumerable]::Max($AtDepth) - $Node.Depth - 1 } elseif ($Recurse) { -1 } else { 1 } } process { if ($InputObject -is [PSNode]) { $Self = $InputObject } else { $Self = [PSNode]::ParseInput($InputObject, $MaxDepth) } if ($Self -is [PSCollectionNode]) { $NodeList = $Self.GetNodeList($SearchDepth, $Leaf) } else { Write-Warning "The node '$($Self.Path)' is a leaf node which does not contain any child nodes." $NodeList = [System.Collections.Generic.List[Object]]::new() } if ($IncludeSelf) { $NodeList.Insert(0, $Self) } foreach ($Node in $NodeList) { if ( ( (-not $ListChild -and $PSCmdlet.ParameterSetName -ne 'MapChild') -or ($ListChild -and $Node.ParentNode -is [PSListNode]) -or ($PSCmdlet.ParameterSetName -eq 'MapChild' -and $Node.ParentNode -is [PSMapNode]) ) -and ( -not $PSBoundParameters.ContainsKey('AtDepth') -or $Node.Depth -in $AtDepth ) -and ( -not $Include -or ( ($Literal -and $Node.Name -in $Include) -or (-not $Literal -and $Include.where({ $Node.Name -like $_ }, 'first')) ) ) -and -not ( $Exclude -and ( ($Literal -and $Node.Name -in $Exclude) -or (-not $Literal -and $Exclude.where({ $Node.Name -like $_ }, 'first')) ) ) ) { if ($ValueOnly) { $Node.Value } else { $Node } } } } } function Get-Node { <# .SYNOPSIS Get a node .DESCRIPTION The Get-Node cmdlet gets the node at the specified property location of the supplied object graph. .EXAMPLE # Parse a object graph to a node instance The following example parses a hash table to `[PSNode]` instance: @{ 'My' = 1, 2, 3; 'Object' = 'Graph' } | Get-Node PathName Name Depth Value -------- ---- ----- ----- 0 {My, Object} .EXAMPLE # select a sub node in an object graph The following example parses a hash table to `[PSNode]` instance and selects the second (`0` indexed) item in the `My` map node @{ 'My' = 1, 2, 3; 'Object' = 'Graph' } | Get-Node My[1] PathName Name Depth Value -------- ---- ----- ----- My[1] 1 2 2 .EXAMPLE # Change the price of the **PowerShell** book: $ObjectGraph = @{ BookStore = @( @{ Book = @{ Title = 'Harry Potter' Price = 29.99 } }, @{ Book = @{ Title = 'Learning PowerShell' Price = 39.95 } } ) } ($ObjectGraph | Get-Node BookStore~Title=*PowerShell*..Price).Value = 24.95 $ObjectGraph | ConvertTo-Expression @{ BookStore = @( @{ Book = @{ Price = 29.99 Title = 'Harry Potter' } }, @{ Book = @{ Price = 24.95 Title = 'Learning PowerShell' } } ) } for more details, see: [PowerShell Object Parser][1] and [Extended dot notation][2] .PARAMETER InputObject The concerned object graph or node. .PARAMETER Path Specifies the path to a specific node in the object graph. The path might be either: * A dot-notation (`[String]`) literal or expression (as natively used with PowerShell) * A array of strings (dictionary keys or Property names) and/or integers (list indices) * A `[PSNodePath]` (such as `$Node.Path`) or a `[XdnPath]` (Extended Dot-Notation) object .PARAMETER Literal If Literal switch is set, all (map) nodes in the given path are considered literal. .PARAMETER ValueOnly returns the value of the node instead of the node itself. .PARAMETER Unique Specifies that if a subset of the nodes has identical properties and values, only a single node of the subset should be selected. .PARAMETER MaxDepth Specifies the maximum depth that an object graph might be recursively iterated before it throws an error. The failsafe will prevent infinitive loops for circular references as e.g. in: $Test = @{Guid = New-Guid} $Test.Parent = $Test The default `MaxDepth` is defined by `[PSNode]::DefaultMaxDepth = 10`. > [!Note] > The `MaxDepth` is bound to the root node of the object graph. Meaning that a descendant node > at depth of 3 can only recursively iterated (`10 - 3 =`) `7` times. .LINK [1]: https://github.com/iRon7/ObjectGraphTools/blob/main/Docs/ObjectParser.md "PowerShell Object Parser" [2]: https://github.com/iRon7/ObjectGraphTools/blob/main/Docs/Xdn.md "Extended dot notation" #> [Alias('gn')] [OutputType([PSNode])] [CmdletBinding(HelpUri='https://github.com/iRon7/ObjectGraphTools/blob/main/Docs/Get-Node.md')] param( [Parameter(Mandatory = $true, ValueFromPipeLine = $true)] $InputObject, [Parameter(ParameterSetName='Path', Position=0, ValueFromPipelineByPropertyName = $true)] $Path, [Parameter(ParameterSetName='Path')] [Switch] $Literal, [switch] $ValueOnly, [switch] $Unique, [Int] $MaxDepth ) begin { if ($Unique) { # As we want to support case sensitive and insensitive nodes the unique nodes are matched by case # also knowing that in most cases nodes are compared with its self. $UniqueNodes = [System.Collections.Generic.Dictionary[String, System.Collections.Generic.HashSet[Object]]]::new() } $XdnPaths = @($Path).ForEach{ if ($_ -is [XdnPath]) { $_ } elseif ($literal) { [XdnPath]::new($_, $True) } else { [XdnPath]$_ } } } process { $Root = [PSNode]::ParseInput($InputObject, $MaxDepth) $Node = if ($XdnPaths) { $XdnPaths.ForEach{ $Root.GetNode($_) } } else { $Root } if (-not $Unique -or $( $PathName = $Node.Path.ToString() if (-not $UniqueNodes.ContainsKey($PathName)) { $UniqueNodes[$PathName] = [System.Collections.Generic.HashSet[Object]]::new() } $UniqueNodes[$PathName].Add($Node.Value) )) { if ($ValueOnly) { $Node.Value } else { $Node } } } } function Get-SortObjectGraph { <# .SYNOPSIS Sort an object graph .DESCRIPTION Recursively sorts a object graph. .PARAMETER InputObject The input object that will be recursively sorted. > [!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 | Sort-Object. .PARAMETER PrimaryKey Any primary key defined by the [-PrimaryKey] parameter will be put on top of [-InputObject] independent of the (descending) sort order. It is allowed to supply multiple primary keys. .PARAMETER MatchCase (Alias `-CaseSensitive`) Indicates that the sort is case-sensitive. By default, sorts aren't case-sensitive. .PARAMETER Descending Indicates that Sort-Object sorts the objects in descending order. The default is ascending order. > [!NOTE] > Primary keys (see: [-PrimaryKey]) will always put on top. .PARAMETER MaxDepth The maximal depth to recursively compare each embedded property (default: 10). #> [Alias('Sort-ObjectGraph', 'sro')] [Diagnostics.CodeAnalysis.SuppressMessage('PSUseApprovedVerbs', '')] [CmdletBinding(HelpUri='https://github.com/iRon7/ObjectGraphTools/blob/main/Docs/Sort-ObjectGraph.md')][OutputType([Object[]])] param( [Parameter(Mandatory = $true, ValueFromPipeLine = $True)] $InputObject, [Alias('By')][String[]]$PrimaryKey, [Alias('CaseSensitive')] [Switch]$MatchCase, [Switch]$Descending, [Alias('Depth')][int]$MaxDepth = [PSNode]::DefaultMaxDepth ) begin { $ObjectComparison = [ObjectComparison]0 if ($MatchCase) { $ObjectComparison = $ObjectComparison -bor [ObjectComparison]'MatchCase'} if ($Descending) { $ObjectComparison = $ObjectComparison -bor [ObjectComparison]'Descending'} # As the child nodes are sorted first, we just do a side-by-side node compare: $ObjectComparison = $ObjectComparison -bor [ObjectComparison]'MatchMapOrder' $PSListNodeComparer = [PSListNodeComparer]@{ PrimaryKey = $PrimaryKey; ObjectComparison = $ObjectComparison } $PSMapNodeComparer = [PSMapNodeComparer]@{ PrimaryKey = $PrimaryKey; ObjectComparison = $ObjectComparison } function SortRecurse([PSCollectionNode]$Node, [PSListNodeComparer]$PSListNodeComparer, [PSMapNodeComparer]$PSMapNodeComparer) { $NodeList = $Node.GetNodeList() for ($i = 0; $i -lt $NodeList.Count; $i++) { if ($NodeList[$i] -is [PSCollectionNode]) { $NodeList[$i] = SortRecurse $NodeList[$i] -PSListNodeComparer $PSListNodeComparer -PSMapNodeComparer $PSMapNodeComparer } } if ($Node -is [PSListNode]) { $NodeList.Sort($PSListNodeComparer) if ($NodeList.Count) { $Node.Value = @($NodeList.Value) } else { $Node.Value = @() } } else { # if ($Node -is [PSMapNode]) $NodeList.Sort($PSMapNodeComparer) $Properties = [System.Collections.Specialized.OrderedDictionary]::new([StringComparer]::Ordinal) foreach($ChildNode in $NodeList) { $Properties[[Object]$ChildNode.Name] = $ChildNode.Value } # [Object] forces a key rather than an index (ArgumentOutOfRangeException) if ($Node -is [PSObjectNode]) { $Node.Value = [PSCustomObject]$Properties } else { $Node.Value = $Properties } } $Node } } process { $Node = [PSNode]::ParseInput($InputObject, $MaxDepth) if ($Node -is [PSCollectionNode]) { $Node = SortRecurse $Node -PSListNodeComparer $PSListNodeComparer -PSMapNodeComparer $PSMapNodeComparer } $Node.Value } } function Import-ObjectGraph { <# .SYNOPSIS Deserializes a PowerShell File or any object-graphs from PowerShell file to an object. .DESCRIPTION The `Import-ObjectGraph` cmdlet safely converts a PowerShell formatted expression contained by a file to an object-graph existing of a mixture of nested arrays, hash tables and objects that contain a list of strings and values. .PARAMETER Path Specifies the path to a file where `Import-ObjectGraph` imports the object-graph. Wildcard characters are permitted. .PARAMETER LiteralPath Specifies a path to one or more locations that contain a PowerShell the object-graph. The value of LiteralPath is used exactly as it's typed. No characters are interpreted as wildcards. If the path includes escape characters, enclose it in single quotation marks. Single quotation marks tell PowerShell not to interpret any characters as escape sequences. .PARAMETER LanguageMode Defines which object types are allowed for the deserialization, see: [About language modes][2] * Any type that is not allowed by the given language mode, will be omitted leaving a bare `[ValueType]`, `[String]`, `[Array]` or `[HashTable]`. * Any variable that is not `$True`, `$False` or `$Null` will be converted to a literal string, e.g. `$Test`. The default `LanguageMode` is `Restricted` for PowerShell Data (`psd1`) files and `Constrained` for any other files, which usually concerns PowerShell (`.ps1`) files. > [!Caution] > > In full language mode, `ConvertTo-Expression` permits all type initializers. Cmdlets, functions, > CIM commands, and workflows will *not* be invoked by the `ConvertFrom-Expression` cmdlet. > > Take reasonable precautions when using the `Invoke-Expression -LanguageMode Full` command in scripts. > Verify that the class types in the expression are safe before instantiating them. In general, it is > best to design your configuration expressions with restricted or constrained classes, rather than > allowing full freeform expressions. .PARAMETER ListAs If supplied, the array subexpression `@( )` syntaxes without an type initializer or with an unknown or denied type initializer will be converted to the given list type. .PARAMETER MapAs If supplied, the array subexpression `@{ }` syntaxes without an type initializer or with an unknown or denied type initializer will be converted to the given map (dictionary or object) type. The default `MapAs` is an (ordered) `PSCustomObject` for PowerShell Data (`psd1`) files and a (unordered) `HashTable` for any other files, which usually concerns PowerShell (`.ps1`) files that support explicit type initiators. .PARAMETER Encoding Specifies the type of encoding for the target file. The default value is `utf8NoBOM`. .LINK [1]: https://github.com/iRon7/ObjectGraphTools/blob/main/Docs/ObjectParser.md "PowerShell Object Parser" [2]: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_modes "About language modes" #> [Alias('Import-Object', 'imo')] [CmdletBinding(DefaultParameterSetName='Path', HelpUri='https://github.com/iRon7/ObjectGraphTools/blob/main/Docs/Import-ObjectGraph.md')] param( [Parameter(ParameterSetName='Path', Mandatory=$true, Position=0, ValueFromPipelineByPropertyName=$true)] [string[]] $Path, [Parameter(ParameterSetName='LiteralPath', Mandatory=$true, ValueFromPipelineByPropertyName=$true)] [Alias('PSPath','LP')] [string[]] $LiteralPath, [ValidateNotNull()][Alias('ArrayAs')]$ListAs, [ValidateNotNull()][Alias('DictionaryAs')]$MapAs, [ValidateScript({ $_ -ne 'NoLanguage' })] [System.Management.Automation.PSLanguageMode]$LanguageMode, [ValidateNotNullOrEmpty()]$Encoding ) begin { $Extension = if ($Path) { [System.IO.Path]::GetExtension($Path) } else { [System.IO.Path]::GetExtension($LiteralPath) } if (-not $PSBoundParameters.ContainsKey('LanguageMode')) { $PSBoundParameters['LanguageMode'] = if ($Extension -eq '.psd1') { 'Restricted' } else { 'Constrained' } } if (-not $PSBoundParameters.ContainsKey('MapAs') -and $Extension -eq '.psd1') { $PSBoundParameters['MapAs'] = 'PSCustomObject' } $FromExpressionParameters = 'ListAs', 'MapAs', 'LanguageMode' $FromExpressionArguments = @{} $FromExpressionParameters.where{ $PSBoundParameters.ContainsKey($_) }.foreach{ $FromExpressionArguments[$_] = $PSBoundParameters[$_] } $FromExpressionContext = $ExecutionContext.InvokeCommand.GetCommand('ObjectGraphTools\ConvertFrom-Expression', [System.Management.Automation.CommandTypes]::Cmdlet) $FromExpressionPipeline = { & $FromExpressionContext @FromExpressionArguments }.GetSteppablePipeline() $FromExpressionPipeline.Begin($True) $GetContentArguments = @{} @('Path', 'LiteralPath', 'Encoding').where{ $PSBoundParameters.ContainsKey($_) }.foreach{ $GetContentArguments[$_] = $PSBoundParameters[$_] } } process { $Expression = Get-Content @GetContentArguments -Raw $FromExpressionPipeline.Process($Expression) } end { $FromExpressionPipeline.End() } } function Merge-ObjectGraph { <# .SYNOPSIS Merges two object graphs into one .DESCRIPTION Recursively merges two object graphs into a new object graph. .PARAMETER InputObject The input object that will be merged with the template object (see: [-Template] 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 $Template. .PARAMETER Template The template that is used to merge with the input object (see: [-InputObject] parameter). .PARAMETER PrimaryKey In case of a list of dictionaries or PowerShell objects, the PowerShell key is used to link the items or properties: if the PrimaryKey exists on both the [-Template] and the [-InputObject] and the values are equal, the dictionary or PowerShell object will be merged. Otherwise (if the key can't be found or the values differ), the complete dictionary or PowerShell object will be added to the list. It is allowed to supply multiple primary keys where each primary key will be used to check the relation between the [-Template] and the [-InputObject]. .PARAMETER MaxDepth The maximal depth to recursively compare each embedded node. The default value is defined by the PowerShell object node parser (`[PSNode]::DefaultMaxDepth`, default: `20`). #> [Alias('Merge-Object', 'mgo')] [Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssignments', '', Scope = "Function", Justification = 'False positive')] [CmdletBinding(HelpUri='https://github.com/iRon7/ObjectGraphTools/blob/main/Docs/Merge-ObjectGraph.md')][OutputType([Object[]])] param( [Parameter(Mandatory = $true, ValueFromPipeLine = $True)] $InputObject, [Parameter(Mandatory = $true, Position=0)] $Template, [String[]]$PrimaryKey, [Switch]$MatchCase, [Alias('Depth')][int]$MaxDepth = [PSNode]::DefaultMaxDepth ) begin { function MergeObject ([PSNode]$TemplateNode, [PSNode]$ObjectNode, [String[]]$PrimaryKey, [Switch]$MatchCase) { if ($ObjectNode -is [PSListNode] -and $TemplateNode -is [PSListNode]) { $FoundIndices = [System.Collections.Generic.HashSet[int]]::new() $Type = if ($ObjectNode.Value.IsFixedSize) { [Collections.Generic.List[PSObject]] } else { $ObjectNode.Value.GetType() } $Output = New-Object -TypeName $Type $ObjectItems = $ObjectNode.ChildNodes $TemplateItems = $TemplateNode.ChildNodes foreach($ObjectItem in $ObjectItems) { $FoundNode = $False foreach ($TemplateItem in $TemplateItems) { if ($ObjectItem -is [PSLeafNode] -and $TemplateItem -is [PSLeafNode]) { $Equal = if ($MatchCase) { $TemplateItem.Value -ceq $ObjectItem.Value } else { $TemplateItem.Value -eq $ObjectItem.Value } if ($Equal) { $Output.Add($ObjectItem.Value) $FoundNode = $True $Null = $FoundIndices.Add($TemplateItem.Name) } } elseif ($ObjectItem -is [PSMapNode] -and $TemplateItem -is [PSMapNode]) { foreach ($Key in $PrimaryKey) { if (-not $TemplateItem.Contains($Key) -or -not $ObjectItem.Contains($Key)) { continue } if ($TemplateItem.GetChildNode($Key).Value -eq $ObjectItem.GetChildNode($Key).Value) { $Item = MergeObject -Template $TemplateItem -Object $ObjectItem -PrimaryKey $PrimaryKey -MatchCase $MatchCase $Output.Add($Item) $FoundNode = $True $Null = $FoundIndices.Add($TemplateItem.Name) } } } } if (-not $FoundNode) { $Output.Add($ObjectItem.Value) } } foreach ($TemplateItem in $TemplateItems) { if (-not $FoundIndices.Contains($TemplateItem.Name)) { $Output.Add($TemplateItem.Value) } } if ($ObjectNode.Value.IsFixedSize) { $Output = @($Output) } ,$Output } elseif ($ObjectNode -is [PSMapNode] -and $TemplateNode -is [PSMapNode]) { if ($ObjectNode -is [PSDictionaryNode]) { $Dictionary = New-Object -TypeName $ObjectNode.ValueType } # The $InputObject defines the map type else { $Dictionary = [System.Collections.Specialized.OrderedDictionary]::new() } foreach ($ObjectItem in $ObjectNode.ChildNodes) { if ($TemplateNode.Contains($ObjectItem.Name)) { # The $InputObject defines the comparer $Value = MergeObject -Template $TemplateNode.GetChildNode($ObjectItem.Name) -Object $ObjectItem -PrimaryKey $PrimaryKey -MatchCase $MatchCase } else { $Value = $ObjectItem.Value } $Dictionary.Add($ObjectItem.Name, $Value) } foreach ($Key in $TemplateNode.Names) { if (-not $Dictionary.Contains($Key)) { $Dictionary.Add($Key, $TemplateNode.GetChildNode($Key).Value) } } if ($ObjectNode -is [PSDictionaryNode]) { $Dictionary } else { [PSCustomObject]$Dictionary } } else { return $ObjectNode.Value } } $TemplateNode = [PSNode]::ParseInput($Template, $MaxDepth) } process { $ObjectNode = [PSNode]::ParseInput($InputObject, $MaxDepth) MergeObject $TemplateNode $ObjectNode -PrimaryKey $PrimaryKey -MatchCase $MatchCase } } function Test-ObjectGraph { <# .SYNOPSIS Tests the properties of an object-graph. .DESCRIPTION Tests an object-graph against a schema object by verifying that the properties of the object-graph meet the constrains defined in the schema object. The schema object has the following major features: * Independent of the object notation (as e.g. [Json (JavaScript Object Notation)][2] or [PowerShell Data Files][3]) * Each test node is at the same level as the input node being validated * Complex node requirements (as mutual exclusive nodes) might be selected using a logical formula .EXAMPLE #Test whether a `$Person` object meats the schema requirements. $Person = [PSCustomObject]@{ FirstName = 'John' LastName = 'Smith' IsAlive = $True Birthday = [DateTime]'Monday, October 7, 1963 10:47:00 PM' Age = 27 Address = [PSCustomObject]@{ Street = '21 2nd Street' City = 'New York' State = 'NY' PostalCode = '10021-3100' } Phone = @{ Home = '212 555-1234' Mobile = '212 555-2345' Work = '212 555-3456', '212 555-3456', '646 555-4567' } Children = @('Dennis', 'Stefan') Spouse = $Null } $Schema = @{ FirstName = @{ '@Type' = 'String' } LastName = @{ '@Type' = 'String' } IsAlive = @{ '@Type' = 'Bool' } Birthday = @{ '@Type' = 'DateTime' } Age = @{ '@Type' = 'Int' '@Minimum' = 0 '@Maximum' = 99 } Address = @{ '@Type' = 'PSMapNode' Street = @{ '@Type' = 'String' } City = @{ '@Type' = 'String' } State = @{ '@Type' = 'String' } PostalCode = @{ '@Type' = 'String' } } Phone = @{ '@Type' = 'PSMapNode', $Null Home = @{ '@Match' = '^\d{3} \d{3}-\d{4}$' } Mobile = @{ '@Match' = '^\d{3} \d{3}-\d{4}$' } Work = @{ '@Match' = '^\d{3} \d{3}-\d{4}$' } } Children = @(@{ '@Type' = 'String', $Null }) Spouse = @{ '@Type' = 'String', $Null } } $Person | Test-Object $Schema | Should -BeNullOrEmpty .PARAMETER InputObject Specifies the object to test for validity against the schema object. The object might be any object containing embedded (or even recursive) lists, dictionaries, objects or scalar values received from a application or an object notation as Json or YAML using their related `ConvertFrom-*` cmdlets. .PARAMETER SchemaObject Specifies a schema to validate the JSON input against. By default, if any discrepancies, toy will be reported in a object list containing the path to failed node, the value whether the node is valid or not and the issue. If no issues are found, the output is empty. For details on the schema object, see the [schema object definitions][1] documentation. .PARAMETER ValidateOnly If set, the cmdlet will stop at the first invalid node and return the test result object. .PARAMETER Elaborate If set, the cmdlet will return the test result object for all tested nodes, even if they are valid or ruled out in a possible list node branch selection. .PARAMETER AssertTestPrefix The prefix used to identify the assert test nodes in the schema object. By default, the prefix is `AssertTestPrefix`. .PARAMETER MaxDepth The maximal depth to recursively test each embedded node. The default value is defined by the PowerShell object node parser (`[PSNode]::DefaultMaxDepth`, default: `20`). .LINK [1]: https://github.com/iRon7/ObjectGraphTools/blob/main/Docs/SchemaObject.md "Schema object definitions" #> [Alias('Test-Object', 'tso')] [CmdletBinding(DefaultParameterSetName = 'ResultList', HelpUri='https://github.com/iRon7/ObjectGraphTools/blob/main/Docs/Test-ObjectGraph.md')][OutputType([String])] param( [Parameter(ParameterSetName='ValidateOnly', Mandatory = $true, ValueFromPipeLine = $True)] [Parameter(ParameterSetName='ResultList', Mandatory = $true, ValueFromPipeLine = $True)] $InputObject, [Parameter(ParameterSetName='ValidateOnly', Mandatory = $true, Position = 0)] [Parameter(ParameterSetName='ResultList', Mandatory = $true, Position = 0)] $SchemaObject, [Parameter(ParameterSetName='ValidateOnly')] [Switch]$ValidateOnly, [Parameter(ParameterSetName='ResultList')] [Switch]$Elaborate, [Parameter(ParameterSetName='ValidateOnly')] [Parameter(ParameterSetName='ResultList')] [ValidateNotNullOrEmpty()][String]$AssertTestPrefix = 'AssertTestPrefix', [Parameter(ParameterSetName='ValidateOnly')] [Parameter(ParameterSetName='ResultList')] [Alias('Depth')][int]$MaxDepth = [PSNode]::DefaultMaxDepth ) begin { $Script:Yield = { $Name = "$Args" -Replace '\W' $Value = Get-Variable -Name $Name -ValueOnly -ErrorAction SilentlyContinue if ($Value) { "$args" } } $Script:Ordinal = @{$false = [StringComparer]::OrdinalIgnoreCase; $true = [StringComparer]::Ordinal } # The maximum schema object depth is bound by the input object depth (+1 one for the leaf test definition) $SchemaNode = [PSNode]::ParseInput($SchemaObject, ($MaxDepth + 2)) # +2 to be safe $Script:AssertPrefix = if ($SchemaNode.Contains($AssertTestPrefix)) { $SchemaNode.Value[$AssertTestPrefix] } else { '@' } function StopError($Exception, $Id = 'TestNode', $Category = [ErrorCategory]::SyntaxError, $Object) { if ($Exception -is [ErrorRecord]) { $Exception = $Exception.Exception } elseif ($Exception -isnot [Exception]) { $Exception = [ArgumentException]$Exception } $PSCmdlet.ThrowTerminatingError([ErrorRecord]::new($Exception, $Id, $Category, $Object)) } function SchemaError($Message, $ObjectNode, $SchemaNode, $Object = $SchemaObject) { $Exception = [ArgumentException]"$([String]$SchemaNode) $Message" $Exception.Data.Add('ObjectNode', $ObjectNode) $Exception.Data.Add('SchemaNode', $SchemaNode) StopError -Exception $Exception -Id 'SchemaError' -Category InvalidOperation -Object $Object } $Script:Tests = @{ Description = 'Describes the test node' References = 'Contains a list of assert references' Type = 'The node or value is of type' NotType = 'The node or value is not type' CaseSensitive = 'The (descendant) node are considered case sensitive' Required = 'The node is required' Unique = 'The node is unique' Minimum = 'The value is greater than or equal to' ExclusiveMinimum = 'The value is greater than' ExclusiveMaximum = 'The value is less than' Maximum = 'The value is less than or equal to' MinimumLength = 'The value length is greater than or equal to' Length = 'The value length is equal to' MaximumLength = 'The value length is less than or equal to' MinimumCount = 'The node count is greater than or equal to' Count = 'The node count is equal to' MaximumCount = 'The node count is less than or equal to' Like = 'The value is like' Match = 'The value matches' NotLike = 'The value is not like' NotMatch = 'The value not matches' Ordered = 'The nodes are in order' RequiredNodes = 'The node contains the nodes' AllowExtraNodes = 'Allow extra nodes' } $At = @{} $Tests.Get_Keys().Foreach{ $At[$_] = "$($AssertPrefix)$_" } function ResolveReferences($Node) { if ($Node.Cache.ContainsKey('TestReferences')) { return } } function GetReference($LeafNode) { $TestNode = $LeafNode.ParentNode $References = if ($TestNode) { if (-not $TestNode.Cache.ContainsKey('TestReferences')) { $Stack = [Stack]::new() while ($true) { $ParentNode = $TestNode.ParentNode if ($ParentNode -and -not $ParentNode.Cache.ContainsKey('TestReferences')) { $Stack.Push($TestNode) $TestNode = $ParentNode continue } $RefNode = if ($TestNode.Contains($At.References)) { $TestNode.GetChildNode($At.References) } $TestNode.Cache['TestReferences'] = [HashTable]::new($Ordinal[[Bool]$RefNode.CaseMatters]) if ($RefNode) { foreach ($ChildNode in $RefNode.ChildNodes) { if (-not $TestNode.Cache['TestReferences'].ContainsKey($ChildNode.Name)) { $TestNode.Cache['TestReferences'][$ChildNode.Name] = $ChildNode } } } $ParentNode = $TestNode.ParentNode if ($ParentNode) { foreach ($RefName in $ParentNode.Cache['TestReferences'].get_Keys()) { if (-not $TestNode.Cache['TestReferences'].ContainsKey($RefName)) { $TestNode.Cache['TestReferences'][$RefName] = $ParentNode.Cache['TestReferences'][$RefName] } } } if ($Stack.Count -eq 0) { break } $TestNode = $Stack.Pop() } } $TestNode.Cache['TestReferences'] } else { @{} } if ($References.Contains($LeafNode.Value)) { $AssertNode.Cache['TestReferences'] = $References $References[$LeafNode.Value] } else { SchemaError "Unknown reference: $LeafNode" $ObjectNode $LeafNode } } function MatchNode ( [PSNode]$ObjectNode, [PSNode]$TestNode, [Switch]$ValidateOnly, [Switch]$Elaborate, [Switch]$Ordered, [Nullable[Bool]]$CaseSensitive, [Switch]$MatchAll, $MatchedNames ) { $Violates = $null $Name = $TestNode.Name $ChildNodes = $ObjectNode.ChildNodes if ($ChildNodes.Count -eq 0) { return } $AssertNode = if ($TestNode -is [PSCollectionNode]) { $TestNode } else { GetReference $TestNode } if ($ObjectNode -is [PSMapNode] -and $TestNode.NodeOrigin -eq 'Map') { if ($ObjectNode.Contains($Name)) { $ChildNode = $ObjectNode.GetChildNode($Name) if ($Ordered -and $ChildNodes.IndexOf($ChildNode) -ne $TestNodes.IndexOf($TestNode)) { $Violates = "The node $Name is not in order" } } else { $ChildNode = $false } } elseif ($ChildNodes.Count -eq 1) { $ChildNode = $ChildNodes[0] } elseif ($Ordered) { $NodeIndex = $TestNodes.IndexOf($TestNode) if ($NodeIndex -ge $ChildNodes.Count) { $Violates = "Expected at least $($TestNodes.Count) (ordered) nodes" } $ChildNode = $ChildNodes[$NodeIndex] } else { $ChildNode = $null } if ($Violates) { if (-not $ValidateOnly) { $Output = [PSCustomObject]@{ ObjectNode = $ObjectNode SchemaNode = $AssertNode Valid = -not $Violates Issue = $Violates } $Output.PSTypeNames.Insert(0, 'TestResult') $Output } return } if ($ChildNode -is [PSNode]) { $Issue = $Null $TestParams = @{ ObjectNode = $ChildNode SchemaNode = $AssertNode Elaborate = $Elaborate CaseSensitive = $CaseSensitive ValidateOnly = $ValidateOnly RefInvalidNode = [Ref]$Issue } TestNode @TestParams if (-not $Issue) { $null = $MatchedNames.Add($ChildNode.Name) } } elseif ($null -eq $ChildNode) { $SingleIssue = $Null foreach ($ChildNode in $ChildNodes) { if ($MatchedNames.Contains($ChildNode.Name)) { continue } $Issue = $Null $TestParams = @{ ObjectNode = $ChildNode SchemaNode = $AssertNode Elaborate = $Elaborate CaseSensitive = $CaseSensitive ValidateOnly = $true RefInvalidNode = [Ref]$Issue } TestNode @TestParams if($Issue) { if ($Elaborate) { $Issue } elseif (-not $ValidateOnly -and $MatchAll) { if ($null -eq $SingleIssue) { $SingleIssue = $Issue } else { $SingleIssue = $false } } } else { $null = $MatchedNames.Add($ChildNode.Name) if (-not $MatchAll) { break } } } if ($SingleIssue) { $SingleIssue } } elseif ($ChildNode -eq $false) { $AssertResults[$Name] = $false } else { throw "Unexpected return reference: $ChildNode" } } function TestNode ( [PSNode]$ObjectNode, [PSNode]$SchemaNode, [Switch]$Elaborate, # if set, include the failed test results in the output [Nullable[Bool]]$CaseSensitive, # inherited the CaseSensitivity frm the parent node if not defined [Switch]$ValidateOnly, # if set, stop at the first invalid node $RefInvalidNode # references the first invalid node ) { $CallStack = Get-PSCallStack # if ($CallStack.Count -gt 20) { Throw 'Call stack failsafe' } if ($DebugPreference -in 'Stop', 'Continue', 'Inquire') { $Caller = $CallStack[1] Write-Host "$([ParameterColor]'Caller (line: $($Caller.ScriptLineNumber))'):" $Caller.InvocationInfo.Line.Trim() Write-Host "$([ParameterColor]'ObjectNode:')" $ObjectNode.Path "$ObjectNode" Write-Host "$([ParameterColor]'SchemaNode:')" $SchemaNode.Path "$SchemaNode" Write-Host "$([ParameterColor]'ValidateOnly:')" ([Bool]$ValidateOnly) } if ($SchemaNode -is [PSListNode] -and $SchemaNode.Count -eq 0) { return } # Allow any node $AssertValue = $ObjectNode.Value $RefInvalidNode.Value = $null # Separate the assert nodes from the schema subnodes $AssertNodes = [Ordered]@{} # $AssertNodes{<Assert Test name>] = $ChildNodes.@<Assert Test name> if ($SchemaNode -is [PSMapNode]) { $TestNodes = [List[PSNode]]::new() foreach ($Node in $SchemaNode.ChildNodes) { if ($Null -eq $Node.Parent -and $Node.Name -eq $AssertTestPrefix) { continue } if ($Node.Name.StartsWith($AssertPrefix)) { $TestName = $Node.Name.SubString($AssertPrefix.Length) if ($TestName -notin $Tests.Keys) { SchemaError "Unknown assert: '$($Node.Name)'" $ObjectNode $SchemaNode } $AssertNodes[$TestName] = $Node } else { $TestNodes.Add($Node) } } } elseif ($SchemaNode -is [PSListNode]) { $TestNodes = $SchemaNode.ChildNodes } else { $TestNodes = @() } if ($AssertNodes.Contains('CaseSensitive')) { $CaseSensitive = [Nullable[Bool]]$AssertNodes['CaseSensitive'] } $AllowExtraNodes = if ($AssertNodes.Contains('AllowExtraNodes')) { $AssertNodes['AllowExtraNodes'] } #Region Node validation $RefInvalidNode.Value = $false $MatchedNames = [HashSet[Object]]::new() $AssertResults = $Null foreach ($TestName in $AssertNodes.get_Keys()) { $AssertNode = $AssertNodes[$TestName] $Criteria = $AssertNode.Value $Violates = $null # is either a boolean ($true if invalid) or a string with what was expected if ($TestName -eq 'Description') { $Null } elseif ($TestName -eq 'References') { } elseif ($TestName -in 'Type', 'notType') { $FoundType = foreach ($TypeName in $Criteria) { if ($TypeName -in $null, 'Null', 'Void') { if ($null -eq $AssertValue) { $true; break } } elseif ($TypeName -is [Type]) { $Type = $TypeName } else { $Type = $TypeName -as [Type] if (-not $Type) { SchemaError "Unknown type: $TypeName" $ObjectNode $SchemaNode } } if ($ObjectNode -is $Type -or $AssertValue -is $Type) { $true; break } } $Not = $TestName.StartsWith('Not', 'OrdinalIgnoreCase') if ($null -eq $FoundType -xor $Not) { $Violates = "The node or value is $(if (!$Not) { 'not ' })of type $AssertNode" } } elseif ($TestName -eq 'CaseSensitive') { if ($null -ne $Criteria -and $Criteria -isnot [Bool]) { SchemaError "The case sensitivity value should be a boolean: $Criteria" $ObjectNode $SchemaNode } } elseif ($TestName -in 'Minimum', 'ExclusiveMinimum', 'ExclusiveMaximum', 'Maximum') { if ($null -eq $AllowExtraNodes) { $AllowExtraNodes = $true } $ValueNodes = if ($ObjectNode -is [PSCollectionNode]) { $ObjectNode.ChildNodes } else { @($ObjectNode) } foreach ($ValueNode in $ValueNodes) { $Value = $ValueNode.Value if ($Value -isnot [String] -and $Value -isnot [ValueType]) { $Violates = "The value '$Value' is not a string or value type" } elseif ($TestName -eq 'Minimum') { $IsValid = if ($CaseSensitive -eq $true) { $Criteria -cle $Value } elseif ($CaseSensitive -eq $false) { $Criteria -ile $Value } else { $Criteria -le $Value } if (-not $IsValid) { $Violates = "The $(&$Yield '(case sensitive) ')value $Value is less or equal than $AssertNode" } } elseif ($TestName -eq 'ExclusiveMinimum') { $IsValid = if ($CaseSensitive -eq $true) { $Criteria -clt $Value } elseif ($CaseSensitive -eq $false) { $Criteria -ilt $Value } else { $Criteria -lt $Value } if (-not $IsValid) { $Violates = "The $(&$Yield '(case sensitive) ')value $Value is less than $AssertNode" } } elseif ($TestName -eq 'ExclusiveMaximum') { $IsValid = if ($CaseSensitive -eq $true) { $Criteria -cgt $Value } elseif ($CaseSensitive -eq $false) { $Criteria -igt $Value } else { $Criteria -gt $Value } if (-not $IsValid) { $Violates = "The $(&$Yield '(case sensitive) ')value $Value is greater than $AssertNode" } } else { # if ($TestName -eq 'Maximum') { $IsValid = if ($CaseSensitive -eq $true) { $Criteria -cge $Value } elseif ($CaseSensitive -eq $false) { $Criteria -ige $Value } else { $Criteria -ge $Value } if (-not $IsValid) { $Violates = "The $(&$Yield '(case sensitive) ')value $Value is greater than $AssertNode" } } if ($Violates) { break } } } elseif ($TestName -in 'MinimumLength', 'Length', 'MaximumLength') { if ($null -eq $AllowExtraNodes) { $AllowExtraNodes = $true } $ValueNodes = if ($ObjectNode -is [PSCollectionNode]) { $ObjectNode.ChildNodes } else { @($ObjectNode) } foreach ($ValueNode in $ValueNodes) { $Value = $ValueNode.Value if ($Value -isnot [String] -and $Value -isnot [ValueType]) { $Violates = "The value '$Value' is not a string or value type" break } $Length = "$Value".Length if ($TestName -eq 'MinimumLength') { if ($Length -lt $Criteria) { $Violates = "The string length of '$Value' ($Length) is less than $AssertNode" } } elseif ($TestName -eq 'Length') { if ($Length -ne $Criteria) { $Violates = "The string length of '$Value' ($Length) is not equal to $AssertNode" } } else { # if ($TestName -eq 'MaximumLength') { if ($Length -gt $Criteria) { $Violates = "The string length of '$Value' ($Length) is greater than $AssertNode" } } if ($Violates) { break } } } elseif ($TestName -in 'Like', 'NotLike', 'Match', 'NotMatch') { if ($null -eq $AllowExtraNodes) { $AllowExtraNodes = $true } $Negate = $TestName.StartsWith('Not', 'OrdinalIgnoreCase') $Match = $TestName.EndsWith('Match', 'OrdinalIgnoreCase') $ValueNodes = if ($ObjectNode -is [PSCollectionNode]) { $ObjectNode.ChildNodes } else { @($ObjectNode) } foreach ($ValueNode in $ValueNodes) { $Value = $ValueNode.Value if ($Value -isnot [String] -and $Value -isnot [ValueType]) { $Violates = "The value '$Value' is not a string or value type" break } $Found = $false foreach ($AnyCriteria in $Criteria) { $Found = if ($Match) { if ($true -eq $CaseSensitive) { $Value -cMatch $AnyCriteria } elseif ($false -eq $CaseSensitive) { $Value -iMatch $AnyCriteria } else { $Value -Match $AnyCriteria } } else { # if ($TestName.EndsWith('Link', 'OrdinalIgnoreCase')) { if ($true -eq $CaseSensitive) { $Value -cLike $AnyCriteria } elseif ($false -eq $CaseSensitive) { $Value -iLike $AnyCriteria } else { $Value -Like $AnyCriteria } } if ($Found) { break } } $IsValid = $Found -xor $Negate if (-not $IsValid) { $Not = if (-Not $Negate) { ' not' } $Violates = if ($Match) { "The $(&$Yield '(case sensitive) ')value $Value does$not match $AssertNode" } else { "The $(&$Yield '(case sensitive) ')value $Value is$not like $AssertNode" } } } } elseif ($TestName -in 'MinimumCount', 'Count', 'MaximumCount') { if ($ObjectNode -isnot [PSCollectionNode]) { $Violates = "The node $ObjectNode is not a collection node" } elseif ($TestName -eq 'MinimumCount') { if ($ChildNodes.Count -lt $Criteria) { $Violates = "The node count ($($ChildNodes.Count)) is less than $AssertNode" } } elseif ($TestName -eq 'Count') { if ($ChildNodes.Count -ne $Criteria) { $Violates = "The node count ($($ChildNodes.Count)) is not equal to $AssertNode" } } else { # if ($TestName -eq 'MaximumCount') { if ($ChildNodes.Count -gt $Criteria) { $Violates = "The node count ($($ChildNodes.Count)) is greater than $AssertNode" } } } elseif ($TestName -eq 'Required') { } elseif ($TestName -eq 'Unique' -and $Criteria) { if (-not $ObjectNode.ParentNode) { SchemaError "The unique assert can't be used on a root node" $ObjectNode $SchemaNode } if ($Criteria -eq $true) { $UniqueCollection = $ObjectNode.ParentNode.ChildNodes } elseif ($Criteria -is [String]) { if (-not $UniqueCollections.Contains($Criteria)) { $UniqueCollections[$Criteria] = [List[PSNode]]::new() } $UniqueCollection = $UniqueCollections[$Criteria] } else { SchemaError "The unique assert value should be a boolean or a string" $ObjectNode $SchemaNode } $ObjectComparer = [ObjectComparer]::new([ObjectComparison][Int][Bool]$CaseSensitive) foreach ($UniqueNode in $UniqueCollection) { if ([object]::ReferenceEquals($ObjectNode, $UniqueNode)) { continue } # Self if ($ObjectComparer.IsEqual($ObjectNode, $UniqueNode)) { $Violates = "The node is equal to the node: $($UniqueNode.Path)" break } } if ($Criteria -is [String]) { $UniqueCollection.Add($ObjectNode) } } elseif ($TestName -eq 'AllowExtraNodes') {} elseif ($TestName -in 'Ordered', 'RequiredNodes') { if ($ObjectNode -isnot [PSCollectionNode]) { $Violates = "The '$($AssertNode.Name)' is not a collection node" } } else { SchemaError "Unknown assert node: $TestName" $ObjectNode $SchemaNode } if ($DebugPreference -in 'Stop', 'Continue', 'Inquire') { if (-not $Violates) { Write-Host -ForegroundColor Green "Valid: $TestName $Criteria" } else { Write-Host -ForegroundColor Red "Invalid: $TestName $Criteria" } } if ($Violates -or $Elaborate) { $Issue = if ($Violates -is [String]) { $Violates } elseif ($Criteria -eq $true) { $($Tests[$TestName]) } else { "$($Tests[$TestName] -replace 'The value ', "The value $ObjectNode ") $AssertNode" } $Output = [PSCustomObject]@{ ObjectNode = $ObjectNode SchemaNode = $SchemaNode Valid = -not $Violates Issue = $Issue } $Output.PSTypeNames.Insert(0, 'TestResult') if ($Violates) { $RefInvalidNode.Value = $Output if ($ValidateOnly) { return } } if (-not $ValidateOnly -or $Elaborate) { <# Write-Output #> $Output } } } #EndRegion Node validation if ($Violates) { return } #Region Required nodes $ChildNodes = $ObjectNode.ChildNodes if ($TestNodes.Count -and -not $AssertNodes.Contains('Type')) { if ($SchemaNode -is [PSListNode] -and $ObjectNode -isnot [PSListNode]) { $Violates = "The node $ObjectNode is not a list node" } if ($SchemaNode -is [PSMapNode] -and $ObjectNode -isnot [PSMapNode]) { $Violates = "The node $ObjectNode is not a map node" } } if (-Not $Violates) { $RequiredNodes = $AssertNodes['RequiredNodes'] $CaseSensitiveNames = if ($ObjectNode -is [PSMapNode]) { $ObjectNode.CaseMatters } $AssertResults = [HashTable]::new($Ordinal[[Bool]$CaseSensitiveNames]) if ($RequiredNodes) { $RequiredList = [List[Object]]$RequiredNodes.Value } else { $RequiredList = [List[Object]]::new() } foreach ($TestNode in $TestNodes) { $AssertNode = if ($TestNode -is [PSCollectionNode]) { $TestNode } else { GetReference $TestNode } if ($AssertNode -is [PSMapNode] -and $AssertNode.GetValue($At.Required)) { $RequiredList.Add($TestNode.Name) } } foreach ($Requirement in $RequiredList) { $LogicalFormula = [LogicalFormula]$Requirement $Enumerator = $LogicalFormula.Terms.GetEnumerator() $Stack = [Stack]::new() $Stack.Push(@{ Enumerator = $Enumerator Accumulator = $null Operator = $null Negate = $null }) $Term, $Operand, $Accumulator = $null While ($Stack.Count -gt 0) { # Accumulator = Accumulator <operation> Operand # if ($Stack.Count -gt 20) { Throw 'Formula stack failsafe'} $Pop = $Stack.Pop() $Enumerator = $Pop.Enumerator $Operator = $Pop.Operator if ($null -eq $Operator) { $Operand = $Pop.Accumulator } else { $Operand, $Accumulator = $Accumulator, $Pop.Accumulator } $Negate = $Pop.Negate $Compute = $null -notin $Operand, $Operator, $Accumulator while ($Compute -or $Enumerator.MoveNext()) { if ($Compute) { $Compute = $false} else { $Term = $Enumerator.Current if ($Term -is [LogicalVariable]) { $Name = $Term.Value if (-not $AssertResults.ContainsKey($Name)) { if (-not $SchemaNode.Contains($Name)) { SchemaError "Unknown test node: $Term" $ObjectNode $SchemaNode } $MatchCount0 = $MatchedNames.Count $MatchParams = @{ ObjectNode = $ObjectNode TestNode = $SchemaNode.GetChildNode($Name) Elaborate = $Elaborate ValidateOnly = $ValidateOnly Ordered = $AssertNodes['Ordered'] CaseSensitive = $CaseSensitive MatchAll = $false MatchedNames = $MatchedNames } MatchNode @MatchParams $AssertResults[$Name] = $MatchedNames.Count -gt $MatchCount0 } $Operand = $AssertResults[$Name] } elseif ($Term -is [LogicalOperator]) { if ($Term.Value -eq 'Not') { $Negate = -Not $Negate } elseif ($null -eq $Operator -and $null -ne $Accumulator) { $Operator = $Term.Value } else { SchemaError "Unexpected operator: $Term" $ObjectNode $SchemaNode } } elseif ($Term -is [LogicalFormula]) { $Stack.Push(@{ Enumerator = $Enumerator Accumulator = $Accumulator Operator = $Operator Negate = $Negate }) $Accumulator, $Operator, $Negate = $null $Enumerator = $Term.Terms.GetEnumerator() continue } else { SchemaError "Unknown logical operator term: $Term" $ObjectNode $SchemaNode } } if ($null -ne $Operand) { if ($null -eq $Accumulator -xor $null -eq $Operator) { if ($Accumulator) { SchemaError "Missing operator before: $Term" $ObjectNode $SchemaNode } else { SchemaError "Missing variable before: $Operator $Term" $ObjectNode $SchemaNode } } $Operand = $Operand -Xor $Negate $Negate = $null if ($Operator -eq 'And') { $Operator = $null if ($Accumulator -eq $false -and -not $AllowExtraNodes) { break } $Accumulator = $Accumulator -and $Operand } elseif ($Operator -eq 'Or') { $Operator = $null if ($Accumulator -eq $true -and -not $AllowExtraNodes) { break } $Accumulator = $Accumulator -Or $Operand } elseif ($Operator -eq 'Xor') { $Operator = $null $Accumulator = $Accumulator -xor $Operand } else { $Accumulator = $Operand } $Operand = $Null } } if ($null -ne $Operator -or $null -ne $Negate) { SchemaError "Missing variable after $Operator" $ObjectNode $SchemaNode } } if ($Accumulator -eq $False) { $Violates = "The required node condition $LogicalFormula is not met" break } } } #EndRegion Required nodes #Region Optional nodes if (-not $Violates) { foreach ($TestNode in $TestNodes) { if ($MatchedNames.Count -ge $ChildNodes.Count) { break } if ($AssertResults.Contains($TestNode.Name)) { continue } $MatchCount0 = $MatchedNames.Count $MatchParams = @{ ObjectNode = $ObjectNode TestNode = $TestNode Elaborate = $Elaborate ValidateOnly = $ValidateOnly Ordered = $AssertNodes['Ordered'] CaseSensitive = $CaseSensitive MatchAll = -not $AllowExtraNodes MatchedNames = $MatchedNames } MatchNode @MatchParams if ($AllowExtraNodes -and $MatchedNames.Count -eq $MatchCount0) { $Violates = "When extra nodes are allowed, the node $ObjectNode should be accepted" break } $AssertResults[$TestNode.Name] = $MatchedNames.Count -gt $MatchCount0 } if (-not $AllowExtraNodes -and $MatchedNames.Count -lt $ChildNodes.Count) { $Count = 0; $LastName = $Null $Names = foreach ($Name in $ChildNodes.Name) { if ($MatchedNames.Contains($Name)) { continue } if ($Count++ -lt 4) { if ($ObjectNode -is [PSListNode]) { [CommandColor]$Name } else { [StringColor][PSKeyExpression]::new($Name, [PSSerialize]::MaxKeyLength)} } else { $LastName = $Name } } $Violates = "The following nodes are not accepted: $($Names -join ', ')" if ($LastName) { $LastName = if ($ObjectNode -is [PSListNode]) { [CommandColor]$LastName } else { [StringColor][PSKeyExpression]::new($LastName, [PSSerialize]::MaxKeyLength) } $Violates += " .. $LastName" } } } #EndRegion Optional nodes if ($Violates -or $Elaborate) { $Output = [PSCustomObject]@{ ObjectNode = $ObjectNode SchemaNode = $SchemaNode Valid = -not $Violates Issue = if ($Violates) { $Violates } else { 'All the child nodes are valid'} } $Output.PSTypeNames.Insert(0, 'TestResult') if ($Violates) { $RefInvalidNode.Value = $Output } if (-not $ValidateOnly -or $Elaborate) { <# Write-Output #> $Output } } } } process { $ObjectNode = [PSNode]::ParseInput($InputObject, $MaxDepth) $Script:UniqueCollections = @{} $Invalid = $Null $TestParams = @{ ObjectNode = $ObjectNode SchemaNode = $SchemaNode Elaborate = $Elaborate ValidateOnly = $ValidateOnly RefInvalidNode = [Ref]$Invalid } TestNode @TestParams if ($ValidateOnly) { -not $Invalid } } } #EndRegion Cmdlet #Region Alias Set-Alias -Name 'ConvertFrom-Expression' -Value 'cfe' Set-Alias -Name 'Copy-ObjectGraph' -Value 'Copy-Object' Set-Alias -Name 'Copy-ObjectGraph' -Value 'cpo' Set-Alias -Name 'ConvertTo-Expression' -Value 'cto' Set-Alias -Name 'Export-ObjectGraph' -Value 'epo' Set-Alias -Name 'Export-ObjectGraph' -Value 'Export-Object' Set-Alias -Name 'Get-ChildNode' -Value 'gcn' Set-Alias -Name 'Get-Node' -Value 'gn' Set-Alias -Name 'Import-ObjectGraph' -Value 'imo' Set-Alias -Name 'Import-ObjectGraph' -Value 'Import-Object' Set-Alias -Name 'Merge-ObjectGraph' -Value 'Merge-Object' Set-Alias -Name 'Merge-ObjectGraph' -Value 'mgo' Set-Alias -Name 'Get-SortObjectGraph' -Value 'Sort-ObjectGraph' Set-Alias -Name 'Get-SortObjectGraph' -Value 'sro' Set-Alias -Name 'Test-ObjectGraph' -Value 'Test-Object' Set-Alias -Name 'Test-ObjectGraph' -Value 'tso' #EndRegion Alias #Region Format if (-not (Get-FormatData 'PSNode' -ErrorAction Ignore)) { Update-FormatData -PrependPath $PSScriptRoot\Source\Formats\PSNode.Format.ps1xml } if (-not (Get-FormatData 'TestResult' -ErrorAction Ignore)) { Update-FormatData -PrependPath $PSScriptRoot\Source\Formats\TestResultTable.Format.ps1xml } if (-not (Get-FormatData 'XdnName' -ErrorAction Ignore)) { Update-FormatData -PrependPath $PSScriptRoot\Source\Formats\XdnName.Format.ps1xml } if (-not (Get-FormatData 'XdnPath' -ErrorAction Ignore)) { Update-FormatData -PrependPath $PSScriptRoot\Source\Formats\XdnPath.Format.ps1xml } #EndRegion Format #Region Export $ModuleMembers = @{ Function = 'Compare-ObjectGraph', 'ConvertFrom-Expression', 'ConvertTo-Expression', 'Copy-ObjectGraph', 'Export-ObjectGraph', 'Get-ChildNode', 'Get-Node', 'Get-SortObjectGraph', 'Import-ObjectGraph', 'Merge-ObjectGraph', 'Test-ObjectGraph' Alias = 'cfe', 'cto', 'Copy-Object', 'cpo', 'Export-Object', 'epo', 'gcn', 'gn', 'Sort-ObjectGraph', 'sro', 'Import-Object', 'imo', 'Merge-Object', 'mgo', 'Test-Object', 'tso' } Export-ModuleMember @ModuleMembers #EndRegion Export |