_Temp/Test-ObjectGraph3.ps1
using module .\..\..\ObjectGraphTools.psm1 using namespace System.Management.Automation using namespace System.Management.Automation.Language using namespace System.Collections using namespace System.Collections.Generic <# .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. Statements: * @RequiredNodes defines the required nodes and the order of the nodes to be tested * Any child node that isn't listed in the `@Required` condition (even negated, as e.g.: `-Not NodeName`) is considered optional * @AllowExtraNodes: when set, optional nodes are required at least once and any additional (undefined) node is unconditional excepted #> [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')] [Alias('All')][Switch]$IncludeAll, [Parameter(ParameterSetName='ValidateOnly')] [Parameter(ParameterSetName='ResultList')] $AssertPrefix = '@', [Parameter(ParameterSetName='ValidateOnly')] [Parameter(ParameterSetName='ResultList')] [Alias('Depth')][int]$MaxDepth = [PSNode]::DefaultMaxDepth ) begin { # JsonSchema Properties # Schema properties: [NewtonSoft.Json.Schema.JsonSchema]::New() | Get-Member # https://www.newtonsoft.com/json/help/html/Properties_T_Newtonsoft_Json_Schema_JsonSchema.htm Enum UniqueType { None; Node; Match } # if a node isn't unique the related option isn't uniquely matched either Enum CompareType { Scalar; OneOf; AllOf } $Script:Ordinal = @{$false = [StringComparer]::OrdinalIgnoreCase; $true = [StringComparer]::Ordinal } 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]"$($SchemaNode.Synopsys) $Message" $Exception.Data.Add('ObjectNode', $ObjectNode) $Exception.Data.Add('SchemaNode', $SchemaNode) StopError -Exception $Exception -Id 'SchemaError' -Category InvalidOperation -Object $Object } $LimitTests = [Ordered]@{ ExclusiveMaximum = 'The value is less than' Maximum = 'The value is less than or equal to' ExclusiveMinimum = 'The value is greater than' Minimum = 'The value is greater than or equal to' } $MatchTests = [Ordered]@{ Like = 'The value is like' Match = 'The value matches' NotLike = 'The value is not like' NotMatch = 'The value not matches' } $Script:Tests = [Ordered]@{ Title = 'Title' References = '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' } + $LimitTests + $MatchTests + [Ordered]@{ Ordered = 'The nodes are in order' RequiredNodes = 'The node contains the nodes' AllowExtraNodes = 'Allow undefined child nodes' } function ResolveReferences($Node) { if ($Node.Cache.ContainsKey('TestReferences')) { return } $Stack = [Stack]::new() while ($true) { $ParentNode = $Node.ParentNode if ($ParentNode -and -not $ParentNode.Cache.ContainsKey('TestReferences')) { $Stack.Push($Node) $Node = $ParentNode continue } $RefNode = if ($Node.Contains('@References')) { $Node.GetChildNode('@References') } $Node.Cache['TestReferences'] = [HashTable]::new($Ordinal[[Bool]$RefNode.IsCaseSensitive]) if ($RefNode) { foreach ($ChildNode in $RefNode.ChildNodes) { if (-not $Node.Cache['TestReferences'].ContainsKey($ChildNode.Name)) { $Node.Cache['TestReferences'][$ChildNode.Name] = $ChildNode } } } $ParentNode = $Node.ParentNode if ($ParentNode) { foreach ($RefName in $ParentNode.Cache['TestReferences'].get_Keys()) { if (-not $Node.Cache['TestReferences'].ContainsKey($RefName)) { $Node.Cache['TestReferences'][$RefName] = $ParentNode.Cache['TestReferences'][$RefName] } } } if ($Stack.Count -eq 0) { break } $Node = $Stack.Pop() } } function MatchNode ( [PSNode]$ObjectNode, [PSNode]$TestNode, [Switch]$ValidateOnly, [Switch]$IncludeAll, [Switch]$Ordered, [Nullable[Bool]]$CaseSensitive, [Switch]$MatchAll, $MatchedNames ) { $ChildNode, $Violates = $null $Name = $TestNode.Name if ($TestNode -is [PSLeafNode]) { $ParentNode = $TestNode.ParentNode $References = if ($ParentNode) { if (-not $ParentNode.Cache.ContainsKey('TestReferences')) { ResolveReferences $ParentNode } $ParentNode.Cache['TestReferences'] } else { @{} } if ($References.Contains($TestNode.Value)) { $AssertNode = $References[$TestNode.Value] $AssertNode.Cache['TestReferences'] = $References } else { SchemaError "Unknown reference: $($TestNode.Value)" $ObjectNode $TestNode } } else { $AssertNode = $TestNode } $ChildNodes = $ObjectNode.ChildNodes 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 = "Node $Name should be 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 = "Should contain at least $($TestNodes.Count) nodes" } $ChildNode = $ChildNodes[$NodeIndex] } if ($Violates) { if (-not $ValidateOnly) { $Output = [PSCustomObject]@{ ObjectNode = $ObjectNode SchemaNode = $AssertNode Valid = -not $Violates Condition = $Condition } $Output.PSTypeNames.Insert(0, 'TestResult') $Output } return } else { if ($ChildNode -is [PSNode]) { $Violates = $Null $TestParams = @{ ObjectNode = $ChildNode SchemaNode = $AssertNode IncludeAll = $IncludeAll CaseSensitive = $CaseSensitive ValidateOnly = $ValidateOnly RefInvalidNode = [Ref]$Violates } TestNode @TestParams if (-not $Violates) { $null = $MatchedNames.Add($ChildNode.Name) } } elseif ($null -eq $ChildNode) { foreach ($ChildNode in $ChildNodes) { if ($MatchedNames.Contains($ChildNode.Name)) { continue } $Violates = $Null $TestParams = @{ ObjectNode = $ChildNode SchemaNode = $AssertNode IncludeAll = $IncludeAll CaseSensitive = $CaseSensitive ValidateOnly = $true RefInvalidNode = [Ref]$Violates } TestNode @TestParams if (-not $Violates) { $null = $MatchedNames.Add($ChildNode.Name) if (-not $MatchAll) { break } } elseif ($IncludeAll) { $Violates } } } elseif ($ChildNode -eq $false) { $AssertResults[$Name] = $false } else { throw "Unexpected return reference: $ChildNode" } } } function TestNode ( [PSNode]$ObjectNode, [PSNode]$SchemaNode, [Switch]$IncludeAll, # if set, include the failed test results in the output [Nullable[Bool]]$CaseSensitive, # inherited the CaseSensitivity from 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 "$([ANSI]::ParameterColor)Caller (line: $($Caller.ScriptLineNumber))$([ANSI]::ResetColor):" $Caller.InvocationInfo.Line.Trim() Write-Host "$([ANSI]::ParameterColor)ObjectNode:$([ANSI]::ResetColor)" $ObjectNode.Path "$ObjectNode" Write-Host "$([ANSI]::ParameterColor)SchemaNode:$([ANSI]::ResetColor)" $SchemaNode.Path "$SchemaNode" Write-Host "$([ANSI]::ParameterColor)ValidateOnly:$([ANSI]::ResetColor)" ([Bool]$ValidateOnly) } if ($SchemaNode -is [PSListNode] -and $SchemaNode.Count -eq 0) { return } # Allow any node $Value = $ObjectNode.Value $RefInvalidNode.Value = $null # Separate the assert nodes from the schema subnodes $At = [Ordered]@{} # $At{<Assert Test name>] = $ChildNodes.@<Assert Test name> if ($SchemaNode -is [PSMapNode]) { $TestNodes = [List[PSNode]]::new() foreach ($Node in $SchemaNode.ChildNodes) { if ($Node.Name.StartsWith($AssertPrefix)) { $TestName = $Node.Name.SubString($AssertPrefix.Length) if ($TestName -notin $Tests.Keys) { SchemaError "Unknown assert: '$($Node.Name)'" $ObjectNode $SchemaNode } $At[$TestName] = $Node } else { $TestNodes.Add($Node) } } } elseif ($SchemaNode -is [PSListNode]) { $TestNodes = $SchemaNode.ChildNodes } else { $TestNodes = @() } if ($At.Contains('CaseSensitive')) { $CaseSensitive = [Nullable[Bool]]$At['CaseSensitive'] } #Region Node validation $RefInvalidNode.Value = $false $MatchedNames = [HashSet[Object]]::new() $AssertResults = $Null foreach ($TestName in $Tests.Keys) { if ($TestName -notin $At.Keys) { continue } # Check if ordered test are still required !!! $AssertNode = $At[$TestName] $Criteria = $AssertNode.Value $Violates = $null # is either a boolean ($true if invalid) or a string with what was expected if ($TestName -eq 'Title') { $Null } elseif ($TestName -eq 'References') { if ($ObjectNode -isnot [PSCollectionNode]) { $Violates = "The '$($AssertNode.Name)' assert requires a collection node" } } elseif ($TestName -in 'Type', 'notType') { $FoundType = foreach ($TypeName in $Criteria) { if ($TypeName -in $null, 'Null', 'Void') { if ($null -eq $Value) { $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 $Value -is $Type) { $true; break } } $Violates = $null -eq $FoundType -xor $TestName -eq 'notType' } elseif ($TestName -eq 'CaseSensitive') { if ($null -ne $Criteria -and $Criteria -isnot [Bool]) { SchemaError "Invalid case sensitivity value: $Criteria" $ObjectNode $SchemaNode } } elseif ($TestName -eq 'ExclusiveMinimum') { $Violates = if ($CaseSensitive -eq $true) { $Criteria -cge $Value } elseif ($CaseSensitive -eq $false) { $Criteria -ige $Value } else { $Criteria -ge $Value } } elseif ($TestName -eq 'Minimum') { $Violates = if ($CaseSensitive -eq $true) { $Criteria -cgt $Value } elseif ($CaseSensitive -eq $false) { $Criteria -igt $Value } else { $Criteria -gt $Value } } elseif ($TestName -eq 'ExclusiveMaximum') { $Violates = if ($CaseSensitive -eq $true) { $Criteria -cle $Value } elseif ($CaseSensitive -eq $false) { $Criteria -ile $Value } else { $Criteria -le $Value } } elseif ($TestName -eq 'Maximum') { $Violates = if ($CaseSensitive -eq $true) { $Criteria -clt $Value } elseif ($CaseSensitive -eq $false) { $Criteria -ilt $Value } else { $Criteria -lt $Value } } elseif ($TestName -in 'Like', 'NotLike', 'Match', 'NotMatch') { $Match = foreach ($AnyCriteria in $Criteria) { $IsMatch = if ($TestName.EndsWith('Like', 'OrdinalIgnoreCase')) { if ($true -eq $CaseSensitive) { $Value -cLike $AnyCriteria } elseif ($false -eq $CaseSensitive) { $Value -iLike $AnyCriteria } else { $Value -Like $AnyCriteria } } else { # if ($TestName.EndsWith('Match', 'OrdinalIgnoreCase')) { if ($true -eq $CaseSensitive) { $Value -cMatch $AnyCriteria } elseif ($false -eq $CaseSensitive) { $Value -iMatch $AnyCriteria } else { $Value -Match $AnyCriteria } } if ($IsMatch) { $true; break } } $Violates = -not $Match -xor $TestName.StartsWith('Not', 'OrdinalIgnoreCase') } elseif ($TestName -eq 'Required') { } elseif ($TestName -eq 'Unique') { $ParentNode = $ObjectNode.ParentNode if (-not $ParentNode) { SchemaError "The unique assert can't be used on a root node" $ObjectNode $SchemaNode } $ObjectComparer = [ObjectComparer]::new([ObjectComparison][Int][Bool]$CaseSensitive) foreach ($SiblingNode in $ParentNode.ChildNodes) { if ($ObjectNode.Name -ceq $SiblingNode.Name) { continue } # Self if ($ObjectComparer.IsEqual($ObjectNode, $SiblingNode)) { $Violates = $true break } } } elseif ($TestName -in 'Ordered', 'RequiredNodes', 'AllowExtraNodes') { if ($ObjectNode -isnot [PSCollectionNode]) { $Violates = "The '$($AssertNode.Name)' assert requires 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 $IncludeAll) { $Condition = if ($Violates -is [String]) { $Violates } elseif ($Criteria -eq $true) { $($Tests[$TestName]) } else { "$($Tests[$TestName]) $(@($Criteria).foreach{ [PSSerialize]$_ } -Join ', ')" } $Output = [PSCustomObject]@{ ObjectNode = $ObjectNode SchemaNode = $SchemaNode Valid = -not $Violates Condition = $Condition } $Output.PSTypeNames.Insert(0, 'TestResult') if ($Violates) { $RefInvalidNode.Value = $Output if ($ValidateOnly) { return } } if (-not $ValidateOnly -or $IncludeAll) { <# Write-Output #> $Output } } } #EndRegion Node validation if ($Violates) { return } #Region Required nodes $ChildNodes = $ObjectNode.ChildNodes $CaseSensitiveNames = if ($ObjectNode -is [PSMapNode]) { $ObjectNode.IsCaseSensitive } $AssertResults = [HashTable]::new($Ordinal[[Bool]$CaseSensitiveNames]) $RequiredNodes = $At['RequiredNodes'] $RequiredList = if ($RequiredNodes) { [List[Object]]$RequiredNodes.Value } else { [List[Object]]::new() } foreach ($ChildNode in $ChildNodes) { if ($ChildNode -is [PSCollectionNode] -and $ChildNode.GetValue('@Required')) { $RequiredNodes.Add($ChildNode.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) IncludeAll = $IncludeAll ValidateOnly = $ValidateOnly Ordered = $At['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 $At['AllowExtraNodes']) { break } $Accumulator = $Accumulator -and $Operand } elseif ($Operator -eq 'Or') { $Operator = $null if ($Accumulator -eq $true -and -not $At['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 = "Meets the conditions of the nodes $LogicalFormula" 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 IncludeAll = $IncludeAll ValidateOnly = $ValidateOnly Ordered = $At['Ordered'] CaseSensitive = $CaseSensitive MatchAll = -not $At['AllowExtraNodes'] MatchedNames = $MatchedNames } MatchNode @MatchParams if ($At['AllowExtraNodes'] -and $MatchedNames.Count -eq $MatchCount0) { $Violates = "When extra nodes are allowed, the node $($TestNode.Name) should be accepted" break } $AssertResults[$TestNode.Name] = $MatchedNames.Count -gt $MatchCount0 } if (-not $At['AllowExtraNodes'] -and $MatchedNames.Count -lt $ChildNodes.Count) { $Extra = $ChildNodes.Name.where{ -not $MatchedNames.Contains($_) }.foreach{ [PSSerialize]$_ } -Join ', ' $Violates = "All the child nodes should be accepted, including the nodes: $Extra" } } #EndRegion Optional nodes if ($Violates -or $IncludeAll) { $Output = [PSCustomObject]@{ ObjectNode = $ObjectNode SchemaNode = $SchemaNode Valid = -not $Violates Condition = if ($Violates) { $Violates } else { 'All the child nodes should be accepted'} } $Output.PSTypeNames.Insert(0, 'TestResult') if ($Violates) { $RefInvalidNode.Value = $Output } if (-not $ValidateOnly -or $IncludeAll) { <# Write-Output #> $Output } } } $SchemaNode = [PSNode]::ParseInput($SchemaObject) } process { $ObjectNode = [PSNode]::ParseInput($InputObject, $MaxDepth) $Invalid = $Null $TestParams = @{ ObjectNode = $ObjectNode SchemaNode = $SchemaNode IncludeAll = $IncludeAll ValidateOnly = $ValidateOnly RefInvalidNode = [Ref]$Invalid } TestNode @TestParams if ($ValidateOnly) { -not $Invalid } } |