_Temp/Test-ObjectGraph2.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.
 
.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}
 
.PARAMETER InputObject
    The input object that will be compared with the reference object (see: [-Reference] parameter).
 
#>


[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]$IncludeValid,

    [Parameter(ParameterSetName='ResultList')]
    [Switch]$IncludeLatent,

    [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 }

    $Ordinal = @{$false = [StringComparer]::OrdinalIgnoreCase; $true = [StringComparer]::Ordinal }

    function StopError($Exception, $Id = 'TestObject', $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'
    # cExclusiveMaximum = 'The value is (case sensitive) less than'
    # iExclusiveMaximum = 'The value is (case insensitive) less than'
    # Maximum = 'The value is less than or equal to'
    # cMaximum = 'The value is (case sensitive) less than or equal to'
    # iMaximum = 'The value is (case insensitive) less than or equal to'
    # ExclusiveMinimum = 'The value is greater than'
    # cExclusiveMinimum = 'The value is (case sensitive) greater than'
    # iExclusiveMinimum = 'The value is (case insensitive) greater than'
    # Minimum = 'The value is greater than or equal to'
    # cMinimum = 'The value is (case sensitive) greater than or equal to'
    # iMinimum = 'The value is (case insensitive) greater than or equal to'
    # }

    # $MatchTests = [Ordered]@{
    # Like = 'The value is like'
    # iLike = 'The value is (case insensitive) like'
    # cLike = 'The value is (case sensitive) like'
    # Match = 'The value matches'
    # iMatch = 'The value (case insensitive) matches'
    # cMatch = 'The value (case sensitive) matches'
    # NotLike = 'The value is not like'
    # iNotLike = 'The value is not (case insensitive) like'
    # cNotLike = 'The value is not (case sensitive) like'
    # NotMatch = 'The value not matches'
    # iNotMatch = 'The value not (case insensitive) matches'
    # cNotMatch = 'The value not (case sensitive) matches'
    # }

    $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'
    }

    $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'
        Unique         = 'The node is unique'
        Exclusive      = 'The assert is exclusive'
    } +
    $LimitTests +
    $MatchTests +
    [Ordered]@{
        Ordered        = 'The nodes are in order'
        RequiredNodes  = 'The node contains the nodes'
        DenyExtraNodes = 'There no additional nodes left over'
    }

    function TestObject (
        [PSNode]$ObjectNode,
        [PSNode]$SchemaNode,
        [Switch]$IncludeValid,          # if set, include the valid test results in the output
        [Switch]$IncludeLatent,         # 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,
        $RefValid
    ) {
        $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)ValidOnly:$([ANSI]::ResetColor)" ([Bool]$ValidateOnly)
        }

        $Value = $ObjectNode.Value

        # Separate the assert nodes from the schema subnodes
        $AssertNodes = [Ordered]@{}
        if ($SchemaNode -is [PSMapNode]) {
            $TestNodes = [List[PSNode]]::new()
            foreach ($Node in $SchemaNode.ChildNodes) {
                if ($Node.Name.StartsWith($AssertPrefix)) { $AssertNodes[$Node.Name.SubString(1)] = $Node.Value }
                else { $TestNodes.Add($Node) }
            }
        }
        elseif ($SchemaNode -is [PSListNode]) { $TestNodes = $SchemaNode.ChildNodes }
        else { $TestNodes = @() }

        # Define the required nodes if not already defined
        if (-not $AssertNodes.Contains('RequiredNodes') -and $ObjectNode -is [PSCollectionNode]) {
            $AssertNodes['RequiredNodes'] = $TestNodes.Name
        }

        if ($AssertNodes.Contains('CaseSensitive')) {
            $CaseSensitive = [Nullable[Bool]]$AssertNodes['CaseSensitive']
        }
        $DenyExtraNodes = $AssertNodes['DenyExtraNodes']
        $Ordered = $AssertNodes['Ordered']

        $RefValid.Value = $true
        $ChildNodes = $AssertResults = $Null
        foreach ($TestName in $Tests.Keys) {
            if ($TestName -notin $AssertNodes.Keys) { continue }

            if ($TestName -notin $Tests.Keys) { SchemaError "Unknown test name: $TestName" $ObjectNode $SchemaNode }
            $Criteria = $AssertNodes[$TestName]
            $Violates = $null # is either a boolean ($true if invalid) or a string with what was expected
            if ($TestName -eq 'Title') { $Null }
            elseif ($TestName -in 'Type', 'notType') {
                $FoundType = foreach ($TypeName in $Criteria) {
                    if ($TypeName -in $null, 'Null', 'Void' -and $null -eq $Value) { $true; break }
                    if ($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 'Unique') {
                $ParentNode = $ObjectNode.ParentNode
                if ($ParentNode -isnot [PSCollectionNode]) {
                    $Violates = 'The unique assert requires a child node'
                }
                else {
                    $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 -eq 'Exclusive') { # the assert exclusivity is handled by the parent node
                if ($ObjectNode.ParentNode -isnot [PSCollectionNode]) {
                    $Violates = 'The exclusive assert requires a collection item'
                }
            }
            elseif ($TestName -eq 'Ordered') {
                if ($ObjectNode -isnot [PSCollectionNode]) {
                    $Violates = 'The ordered assert requires a collection node'
                }
            }

            elseif ($TestName -eq 'RequiredNodes') {
                if ($ObjectNode -isnot [PSCollectionNode]) {
                    $Violates = 'The requires assert requires a collection node'
                }
                else {
                    $ChildNodes = $ObjectNode.ChildNodes
                    $IsStrictCase = if ($ObjectNode -is [PSMapNode]) {
                        foreach ($ChildNode in $ChildNodes) {
                            $Name = $ChildNode.Name
                            $IsStrictCase = if ($Name -is [String] -and $Name -match '[a-z]') {
                                $Case = $Name.ToLower()
                                if ($Case -eq $Name) { $Case = $Name.ToUpper() }
                                -not $ObjectNode.Contains($Case) -or $ObjectNode.GetChildNode($Case).Name -ceq $Case
                                break
                            }
                        }
                    } elseif ($ObjectNode -is [PSCollectionNode]) { $false } else { $null }

                    $AssertResults = [HashTable]::new($Ordinal[[Bool]$IsStrictCase])
                    $ValidNodes = [HashSet[Object]]::new()
                    foreach ($Condition in $Criteria) {
                        $Term, $Accumulator, $Operand, $Operation, $Negate = $null
                        $LogicalFormula = [LogicalFormula]$Condition
                        $Enumerator = $LogicalFormula.Terms.GetEnumerator()
                        $Stack = [System.Collections.Stack]::new()
                        $Stack.Push(@{
                            Enumerator  = $Enumerator
                            Accumulator = $Null
                            Operator    = $Null
                            Negate      = $Null
                        })
                        $Accumulator = $Null
                        While ($Stack.Count -gt 0) {     # Accumulator = Accumulator <operation> Operand
                            if ($Stack.Count -gt 20) { Throw 'Formula stack failsafe'}
                            $Operand     = $Accumulator  # Resulted from sub expression
                            $Pop         = $Stack.Pop()
                            $Enumerator  = $Pop.Enumerator
                            $Accumulator = $Pop.Accumulator
                            $Operator    = $Pop.Operator
                            $Negate      = $Pop.Negate
                            while ($Enumerator.MoveNext()) {
                                $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
                                        }
                                        $TestNode = $SchemaNode.GetChildNode($Name)
                                        $Name = $TestNode.Name # get the exact node name
                                        $Mapped = $ObjectNode -is [PSMapNode] -and $SchemaNode -is [PSMapNode]
                                        if ($Mapped -or $Ordered) {
                                            $ChildNode = $Null
                                            if ($Mapped) {
                                                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"
                                                        $Stack.Clear(); break
                                                    }
                                                }
                                            }
                                            else {
                                                $TestNodeIndex = $TestNodes.IndexOf($TestNode)
                                                if ($TestNodeIndex -ge $ChildNodes.Count) {
                                                    $Violates = "Should contain at least $($TestNodes.Count) nodes"
                                                    $Stack.Clear(); break
                                                }
                                                $ChildNode = $ChildNodes[$NodeIndex]
                                            }
                                            if ($ChildNode) {
                                                $Valid = $Null
                                                $TestParams = @{
                                                    ObjectNode    = $ChildNode
                                                    SchemaNode    = $TestNode
                                                    IncludeValid  = $IncludeValid
                                                    IncludeLatent = $IncludeLatent
                                                    CaseSensitive = $CaseSensitive
                                                    ValidateOnly  = $ValidateOnly
                                                    RefValid      = [Ref]$Valid
                                                }
                                                TestObject @TestParams
                                                $AssertResults[$Name] = $Valid
                                            }
                                            else { $AssertResults[$Name] = $false }
                                        }
                                        else {
                                            $RestNames      = $AssertNodes.where{ -not $AssertResults.Contains($_.Name) }
                                            $UniqueNames    = $RestNames.where{ $AssertNodes[$_].GetValue('@Unique') }
                                            $ExclusiveNames = $RestNames.where{ $AssertNodes[$_].GetValue('@Exclusive') }
                                            if ($UniqueNames -or $ExclusiveNames) {
                                                $CheckNames = [List[String]]::new($UniqueNames)
                                                $ExclusiveNames.foreach{ if ($_ -notin $CheckNames) { $CheckNames.Add($_) } }
                                                $RestNames.foreach{ if ($_ -notin $CheckNames) { $CheckNames.Add($_) } }
                                            } else { $CheckNames = $ChildNodes.Name }
                                            $Found = $null
                                            $CountUnique = if ($UniqueNames) { @{} }
                                            $CountExclusive = if ($ExclusiveNames) { @{} }
                                            $CountNodes = $CountUnique -or $CountExclusive
                                            foreach ($ChildNode in $AssertNodes.get_Keys()) {

                                                if ($TestNode.GetValue('@Unique') -or $TestNode.GetValue('@Exclusive')) { continue }
                                                $Valid = $Null
                                                $TestParams = @{
                                                    ObjectNode    = $ChildNode
                                                    SchemaNode    = $TestNode
                                                    IncludeValid  = $IncludeValid
                                                    IncludeLatent = $IncludeLatent
                                                    CaseSensitive = $CaseSensitive
                                                    ValidateOnly  = $true
                                                    RefValid      = [Ref]$Valid
                                                }
                                                TestObject @TestParams
                                                if ($Valid) {
                                                    if ($CountNodes) {
                                                        if ($CountUnique) { $CountUnique[$ChildNode] += 1 }
                                                        if ($CountExclusive) { $CountExclusive[$ChildNode] += 1 }
                                                    } else { $Found = $ChildNode; break }
                                                }
                                            }
                                            if ($CountNodes) {
                                                foreach ($CheckNode in $CheckNames) {
                                                    $Found = $CheckNode
                                                    if ($CountUnique -and $CountUnique[$CheckNode] -ne 1) { $Found = $null }
                                                    if ($CountExclusive -and $CountExclusive[$CheckNode] -ne 1) { $Found = $null }
                                                    if ($Found) { break }
                                                }
                                            }
                                            if ($Found) {
                                                $AssertResults[$Name] = $true
                                                $null = $ValidNodes.Add($Found)
                                            } else { $AssertResults[$Name] = $false }
                                        }
                                    }
                                    $Operand = $AssertResults[$Name]
                                }
                                elseif ($Term -is [LogicalOperator]) {
                                    if ($Term -eq 'Not') { $Negate = -Not $Negate }
                                    if (
                                        $null -ne $Operation -or $null -eq $Accumulator) {
                                        SchemaError "Unexpected operator: $Term" $ObjectNode $SchemaNode
                                    }

                                }
                                elseif ($Term -is [List[Object]]) {
                                    $Stack.Push(@{
                                        Enumerator  = $Enumerator
                                        Accumulator = $Accumulator
                                        Operator    = $Operator
                                        Negate      = $Negate
                                    })
                                    $Accumulator = $null
                                    $Enumerator  = $Term.GetEnumerator()
                                    break
                                }
                                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
                                    if ($Operator -eq 'And') {
                                        if (-not $DenyExtraNodes -and $Accumulator -eq $false) { break }
                                        $Accumulator = $Accumulator -and $Operand
                                    }
                                    elseif ($Operator -eq 'Or') {
                                        if (-not $DenyExtraNodes -and $Accumulator -eq $true) { break }
                                        $Accumulator = $Accumulator -Or $Operand
                                    }
                                    elseif ($Operator -eq 'Xor') {
                                        $Accumulator = $Accumulator -xor $Operand
                                    }
                                    else { $Accumulator = $Operand }
                                    $Operand, $Operator, $Negate = $Null
                                }
                            }
                            if ($null -ne $Operator -or $null -ne $Negate) {
                                SchemaError "Missing variable after $Term" $ObjectNode $SchemaNode
                            }
                        }
                        if ($Accumulator -eq $False) {
                            $Violates = "Meets the conditions of the nodes $LogicalFormula"
                            if ($ValidateOnly) { continue }
                        }
                    }
                }
            }
            elseif ($AssertNodes['DenyExtraNodes']) {
                $ChildNames = if ($ChildNodes) { $ChildNodes.Name } else { $ObjectNode.ChildNodes.Name }
                $ResultNames = if ($AssertResults) { $AssertResults.get_Keys() }
                if ($ResultNames.Count -lt $ChildNames.Count) {
                    $Extra = $ChildNames.where{ $ResultNames -cne $_ }.foreach{ [PSSerialize]$_ } -Join ', '
                    $Violates = "Deny the extra node(s): $Extra"
                }
            }
            else { SchemaError "Unknown assert node: $TestName" $ObjectNode $SchemaNode }

            $RefValid.Value = -not $Violates
            if ($DebugPreference -in 'Stop', 'Continue', 'Inquire') {
                if ($RefValid.Value) { Write-Host -ForegroundColor Green "Valid: $TestName $Criteria" }
                else { Write-Host -ForegroundColor Red "Invalid: $TestName $Criteria" }
            }

            if ($IncludeLatent -or ($Violates -and -not $ValidateOnly) -or ($RefValid.Value -and $IncludeValid)) {
                $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      = $RefValid.Value
                    Condition  = $Condition
                }
                $Output.PSTypeNames.Insert(0, 'TestResultTable')
                Write-Output $Output
            }
            if ($ValidateOnly) { if ($RefValid.Value) { continue } else { return } }
        }
    }

    $SchemaNode = [PSNode]::ParseInput($SchemaObject)
}

process {
    $ObjectNode = [PSNode]::ParseInput($InputObject, $MaxDepth)
    $Valid = $Null
    $TestParams = @{
        ObjectNode    = $ObjectNode
        SchemaNode    = $SchemaNode
        IncludeValid  = $IncludeValid
        IncludeLatent = $IncludeLatent
        CaseSensitive = $CaseSensitive
        ValidateOnly  = $ValidateOnly
        RefValid      = [Ref]$Valid
    }
    TestObject @TestParams
    if ($ValidateOnly) { $Valid }
}