Source/Cmdlets/Test-ObjectGraph.ps1
using module .\..\..\..\ObjectGraphTools 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. 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 } } |