Last active
May 2, 2022 19:04
-
-
Save sean-m/743eea329cc29176c6cbf9caf67b8812 to your computer and use it in GitHub Desktop.
Semantic comparison of deserialized JSON in PowerShell. Purposefully ignores array element ordering. I think this works. Needs testing on a larger corpus.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#Requires -Modules Pester | |
function Test-SemanticEquality { | |
<# | |
.Synopsis | |
When comparing two JSON records, comparing the string representation of one | |
to the other can fail for any nubmer of reasons. This function takes parsed | |
JSON records and compares their structure/values to tell if the sources | |
are semantically equivalent. | |
.DESCRIPTION | |
This is specifically intended to test results returned by REST APIs where | |
array ordering may not matter but content does. Like this: | |
{ | |
'options':[ {'foo':1}, {'bar':2} ] | |
} | |
vs. | |
{ | |
'options':[ {'bar':2}, {'foo':1} ] | |
} | |
The order of the 'options' array changed but the content did not. This is | |
important when comparing cloud service policies against reference policies | |
in source control. The ordering of an element may have changed but the | |
meaning has not so they should be equivalent. | |
.EXAMPLE | |
# Taken from Pester test cases | |
$docTwo = "{'Foo':'Bar', 'Baz':[1,2,3,4], 'Pip': true,'Self':{'Foo':'Bar', 'Baz':[1,2,3,4]}}" | |
$two = $docTwo | ConvertFrom-Json | |
$docThree = "{'Foo':'Bar', 'Baz':[4,1,2,3], 'Pip': true, 'Self':{'Foo':'Bar', 'Baz':[1,2,3,4]}}" | |
$three = $docThree | ConvertFrom-Json | |
if (Test-SemanticEquality $two $three) { Write-Host -ForegroundColor Green "They match!" } | |
else { Write-Host -ForegroundColor Red "They don't match!" } | |
.EXAMPLE | |
# Records with Mofified times one minute apart, otherwise the same | |
$one = "{'Foo':'Bar', 'Baz':[1,2,3], 'Modified':'2022-05-02T16:24:02.423Z'}" | ConvertFrom-Json | |
$two = "{'Foo':'Bar', 'Baz':[1,2,3], 'Modified':'2022-05-02T16:25:02.423Z'}" | ConvertFrom-Json | |
$ignore = @('Modified') | |
# With ignore, should match | |
Write-Host -NoNewLine Ignoring: | |
if (Test-SemanticEquality $one $two $ignore) { Write-Host -ForegroundColor Green " they match!" } | |
else { Write-Host -ForegroundColor Red " they don't match!" } | |
# Without ignore, should not match | |
Write-Host -NoNewLine Not ignoring: | |
if (Test-SemanticEquality $one $two) { Write-Host -ForgroundColor Green " they match!" } | |
else { Write-Host -ForegroundColor Red " they don't match!" } | |
#> | |
[CmdletBinding()] | |
[Alias()] | |
[OutputType([bool])] | |
param ( | |
[Parameter(Position=0)] | |
[object]$Left, | |
[Parameter(Position=1)] | |
[object]$Right, | |
[Parameter(Position=2)] | |
[string[]] | |
$IgnoreProperties=@() | |
) | |
begin { | |
# Property path | |
$propPath = '' | |
function SemanticObjectCompare { | |
param ( | |
$Left, | |
$Right, | |
[switch] | |
$DoRecurse, | |
$PropPath | |
) | |
$_propPath = $PropPath | |
Write-Debug "PropPath: $_propPath" | |
if ($IgnoreProperties | where { $_propPath -match $_ }) { | |
Write-Debug "IgnoreProperty match found. Returning." | |
return | |
} | |
if (-not $Left -and -not $Right) { | |
return $true | |
} | |
elseif (-not $Left -or -not $Right) { | |
return $false | |
} | |
switch ($Left.GetType().Name) { | |
Object[] { | |
if ($Right.GetType() -ne [System.Object[]]) { | |
Write-Debug "Left is an array but Right isn't. Type mismatch failure." | |
return $false | |
} | |
$leftCount = ($Left | Measure-Object).Count | |
$rightCount = ($Right | Measure-Object).Count | |
if ($leftCount -ne $rightCount) { | |
Write-Debug "Left and Right list have different length. Size comparison failed." | |
return $false | |
} | |
$theMatches = $Left.Where({ | |
$x = $_ | |
$Right.Where({ | |
SemanticObjectCompare $x $_ -PropPath '' | |
},'First')}) | |
if ($theMatches.Count -ne $leftCount) { | |
Write-Debug "Nested complex list comparison failed." | |
return $false | |
} | |
return $true | |
} | |
String { | |
if (-not ($Left -eq $Right)) { | |
Write-Debug "Object comparison failed." | |
return $false | |
} | |
return $true | |
} | |
PSCustomObject { | |
## test PSObject properties | |
$lp = $Left.PSObject.Properties | |
$rp = $Right.PSObject.Properties | |
## This test is only valid if not properties are ignored during evaluation | |
if (($lp | Measure-Object).Count -ne ($rp | Measure-Object).Count -and -not ($IgnoreProperties)) { | |
Write-Debug "Property counts differ. Objects don't match." | |
return $false | |
} | |
$result = @() | |
$result += $lp | ForEach-Object { | |
$local:_propPath = $PropPath | |
$lpi = $_ | |
$rpi = $rp.Where({ $_.Name -like $lpi.Name }, 'First') | |
if (-not $rpi) { | |
Write-Debug "Existing right property instance not found. objects don't match." | |
$result += $false | |
} | |
if (-not [String]::Equals($rpi.TypeNameOfValue, $lpi.TypeNameOfValue)) { | |
$result += $false | |
} | |
Write-Debug "`$lpi Name: $($lpi.Name)`tTypeNameOfValue: $($lpi.TypeNameOfValue)" | |
return (SemanticObjectCompare ($lpi.Value) ($rpi.Value) -PropPath ($local:_propPath, $lpi.Name -join '.').TrimStart('.')) | |
} | |
} | |
default { | |
if ($Left.GetType() -ne $Right.GetType()) { | |
Write-Debug "Left and right type mismatch." | |
return $false | |
} | |
Write-Debug "Got to the default case.`n`t> $Left`n`t> $Right" | |
if (-not ($Left -eq $Right)) { | |
Write-Debug "Object comparison failed." | |
return $false | |
} | |
return $true | |
} | |
} ## switch ($Left.GetType().Name) | |
if (-not $result.Contains($false) -and $DoRecurse) { | |
SemanticObjectCompare -Left $Right -Right $Left | |
} | |
return (-not $result.Contains($false)) | |
} | |
} | |
process { | |
$result = @(SemanticObjectCompare -Left $Left -Right $Right -DoRecurse) | |
if ($null -eq $result) { | |
$false | |
} else { | |
-not $result.Contains($false) | |
} | |
} | |
} | |
Describe 'Test-SemanticEquality' { | |
BeforeAll { | |
#Set-StrictMode -Version 5 | |
$docOne = "{'Foo':'Bar', 'Baz':[1,2,3]}" | |
$one = $docOne | ConvertFrom-Json | |
$oneFoo = $docOne | ConvertFrom-Json | |
$docTwo = "{'Foo':'Bar', 'Baz':[1,2,3,4], 'Pip': true, 'Self':{'Foo':'Bar', 'Baz':[1,2,3,4]}}" | |
$two = $docTwo | ConvertFrom-Json | |
$docThree = "{'Foo':'Bar', 'Baz':[4,1,2,3], 'Pip': true, 'Self':{'Foo':'Bar', 'Baz':[1,2,3,4]}}" | |
$three = $docThree | ConvertFrom-Json | |
$docFour = "{'Foo':'Bar', 'Baz':[4,1,2,3], 'Pip': false, 'Self':{'Foo':'Bar', 'Baz':[1,2,3,4]}}" | |
$four = $docFour | ConvertFrom-Json | |
$docFive = "{'Foo':'Bar', 'Baz':[4,1,2,3,4], 'Pip': false, 'Self':{'Foo':'Bar', 'Baz':[1,2,3,4]}}" | |
$five = $docFive | ConvertFrom-Json | |
$docSix = "[{'a':1},{'b':2}]" | |
$six = $docSix | ConvertFrom-Json | |
$seven = "[{'b':2},{'a':1}]" | ConvertFrom-Json | |
$eight = "[{'c':3},{'b':2},{'a':1}]" | ConvertFrom-Json | |
$nine = "[{'c':3},{'a':1}]" | ConvertFrom-Json | |
$simpleObjA = "{'a':1}" | ConvertFrom-Json | |
$simpleObjB = "{'b':1}" | ConvertFrom-Json | |
$simpleObjC = "{'b':2}" | ConvertFrom-Json | |
$simpleObjD = "{'a':'Foo'}" | ConvertFrom-Json | |
$simpleObjE = "{'a':'Bar'}" | ConvertFrom-Json | |
$simpleArrA = "['a']" | ConvertFrom-Json | |
$simpleArrB = "['b']" | ConvertFrom-Json | |
$simpleArrC = "['a','b']" | ConvertFrom-Json | |
$simpleArrD = "['a','c']" | ConvertFrom-Json | |
$simpleArrE = "['a','b','c']" | ConvertFrom-Json | |
$simpleArr1 = "[1]" | ConvertFrom-Json | |
$simpleArr2 = "[2]" | ConvertFrom-Json | |
$simpleArr12 = "[1,2]" | ConvertFrom-Json | |
$simpleArr21 = "[2,1]" | ConvertFrom-Json | |
$simpleNestedArrA = "[ ['a'], ['a'] ]" | ConvertFrom-Json | |
$simpleNestedArrB = "[ ['b'], ['b'] ]" | ConvertFrom-Json | |
$simpleNestedArrAB = "[ ['a','a'], ['b','b'] ]" | ConvertFrom-Json | |
$simpleNestedArrBA = "[ ['b','b'], ['a','a'] ]" | ConvertFrom-Json | |
# Taken from https://www.json.org/json-en.html, modified slightly. | |
$complexObjA = @" | |
{ | |
"glossary": { | |
"title": "example glossary", | |
"GlossDiv": { | |
"title": "S", | |
"GlossList": { | |
"GlossEntry": { | |
"ID": "SGML", | |
"SortAs": "SGML", | |
"GlossTerm": "Standard Generalized Markup Language", | |
"Acronym": "SGML", | |
"Abbrev": "ISO 8879:1986", | |
"GlossDef": { | |
"para": "A meta-markup language, used to create markup languages such as DocBook.", | |
"GlossSeeAlso": ["GML", "XML"] | |
}, | |
"GlossSee": "markup" | |
} | |
} | |
} | |
} | |
} | |
"@ | ConvertFrom-Json | |
$complexObjB = @" | |
{ | |
"glossary": { | |
"title": "example glossary", | |
"GlossDiv": { | |
"title": "S", | |
"GlossList": { | |
"GlossEntry": { | |
"ID": "SGML", | |
"SortAs": "SGML", | |
"GlossTerm": "Standard Generalized Markup Language", | |
"Acronym": "SGML", | |
"Abbrev": "ISO 8879:1986", | |
"GlossDef": { | |
"para": "A meta-markup language, used to create markup languages such as DocBook.", | |
"GlossSeeAlso": ["XML","GML"] | |
}, | |
"GlossSee": "markup" | |
} | |
} | |
} | |
} | |
} | |
"@ | ConvertFrom-Json | |
$complexObjC = @" | |
{ | |
"glossary": { | |
"title": "example glossary", | |
"GlossDiv": { | |
"title": "S", | |
"GlossList": { | |
"GlossEntry": { | |
"ID": "SGML", | |
"SortAs": "SGML", | |
"GlossTerm": "Standard Generalized Markup Language", | |
"Acronym": "SGML", | |
"Abbrev": "ISO 8879:1986", | |
"GlossDef": { | |
"para": "A meta-markup language, used to create markup languages such as DocBook.", | |
"GlossSeeAlso": ["XML","GML","HTML"] | |
}, | |
"GlossSee": "markup" | |
} | |
} | |
} | |
} | |
} | |
"@ | ConvertFrom-Json | |
$complexObjD = @" | |
{ | |
"glossary": { | |
"title": "example glossary", | |
"GlossDiv": { | |
"title": "S", | |
"GlossList": { | |
"GlossEntry": { | |
"ID": "SGML", | |
"SortAs": "SGML", | |
"GlossTerm": "Standard Generalized Markup Language", | |
"Acronym": "SGML", | |
"Abbrev": "ISO 8879:1986", | |
"GlossDef": { | |
"para": "A meta-markup language, used to create markup languages such as DocBook.", | |
"GlossSeeAlso": [ | |
"XML", | |
"GML", | |
"HTML" | |
] | |
}, | |
"GlossSee": "markup" | |
} | |
} | |
}, | |
"modifiedTime": "\/Date(1651507352048)\/" | |
} | |
} | |
"@ | ConvertFrom-Json | |
$complexObjE = @" | |
{ | |
"glossary": { | |
"title": "example glossary", | |
"GlossDiv": { | |
"title": "S", | |
"GlossList": { | |
"GlossEntry": { | |
"ID": "SGML", | |
"SortAs": "SGML", | |
"GlossTerm": "Standard Generalized Markup Language", | |
"Acronym": "SGML", | |
"Abbrev": "ISO 8879:1986", | |
"GlossDef": { | |
"para": "A meta-markup language, used to create markup languages such as DocBook.", | |
"GlossSeeAlso": [ | |
"XML", | |
"GML", | |
"HTML" | |
] | |
}, | |
"GlossSee": "markup" | |
} | |
} | |
}, | |
"modifiedTime": "\/Date(1651507779138)\/" | |
} | |
} | |
"@ | ConvertFrom-Json | |
} | |
It "Different simple objects are not equal." { | |
Test-SemanticEquality $simpleObjA $simpleObjB | Should -Be $false | |
Test-SemanticEquality $simpleObjB $simpleObjC | Should -Be $false | |
Test-SemanticEquality $simpleObjD $simpleObjE | Should -Be $false | |
} | |
It "Simple arrays with the same elemenets are equal." { | |
Test-SemanticEquality $simpleArrA $simpleArrA | Should -Be $true | |
Test-SemanticEquality $simpleArrE $simpleArrE | Should -Be $true | |
Test-SemanticEquality $simpleArr1 $simpleArr1 | Should -Be $true | |
Test-SemanticEquality $simpleArr12 $simpleArr21 | Should -Be $true | |
} | |
It "Simple arrays with different elements are not equal." { | |
Test-SemanticEquality $simpleArr1 $simpleArr2 | Should -Be $false | |
Test-SemanticEquality $simpleArrA $simpleArrB | Should -Be $false | |
Test-SemanticEquality $simpleArrB $simpleArrC | Should -Be $false | |
Test-SemanticEquality $simpleArrC $simpleArrD | Should -Be $false | |
Test-SemanticEquality $simpleArrD $simpleArrE | Should -Be $false | |
} | |
It "Nested simple arrays with the same lists in different order are equal." { | |
Test-SemanticEquality $simpleNestedArrA $simpleNestedArrA | Should -Be $true | |
Test-SemanticEquality $simpleNestedArrAB $simpleNestedArrBA | Should -Be $true | |
} | |
It "Nested simple arrays with different lists are not equal." { | |
Test-SemanticEquality $simpleNestedArrA $simpleNestedArrB | Should -Be $false | |
} | |
It "Complex array elements should be compared for semantic equality." { | |
Test-SemanticEquality $six $seven | Should -Be $true | |
Test-SemanticEquality $seven $eight | Should -Be $false | |
Test-SemanticEquality $seven $nine | Should -Be $false | |
} | |
It "Objects with different numbers of properties shouldn't be equal." { | |
Test-SemanticEquality $two $one | Should -Be $false | |
} | |
It "An object compared to itself should be equal." { | |
Test-SemanticEquality $one $one | Should -Be $true | |
Test-SemanticEquality $one $oneFoo | Should -Be $true | |
} | |
It "Objects with arrays who contain the same elements in a different order are equal." { | |
Test-SemanticEquality $two $three | Should -Be $true | |
Test-SemanticEquality $complexObjA $complexObjB | Should -Be $true | |
Test-SemanticEquality $complexObjB $complexObjA | Should -Be $true | |
} | |
It "Objects with arrays that contain different elements are not equal." { | |
Test-SemanticEquality $complexObjA $complexObjC | Should -Be $false | |
Test-SemanticEquality $complexObjB $complexObjC | Should -Be $false | |
Test-SemanticEquality $complexObjC $complexObjB | Should -Be $false | |
} | |
It "Objects with differing boolean values are not equal." { | |
Test-SemanticEquality $three $four | Should -Be $false | |
} | |
It "Objects with different array contents are not equal even if distinct contents are equal (check for dupes)." { | |
Test-SemanticEquality $four $five | Should -Be $false | |
} | |
It "Certain properties can be ignored during evaluation by full name." { | |
$ignore = @( | |
'glossary.GlossDiv.GlossList.GlossEntry.GlossDef.GlossSeeAlso', | |
'glossary.modifiedTime' | |
) | |
Test-SemanticEquality $complexObjA $complexObjC -IgnoreProperties $ignore | Should -Be $true | |
Test-SemanticEquality $complexObjD $complexObjE -IgnoreProperties $ignore | Should -Be $true | |
Test-SemanticEquality $complexObjA $complexObjC | Should -Be $false | |
Test-SemanticEquality $complexObjD $complexObjE | Should -Be $false | |
$ignore = @( | |
'Baz', | |
'Pip', | |
'Self' | |
) | |
Test-SemanticEquality $one $two -IgnoreProperties $ignore | Should -Be $true | |
Test-SemanticEquality $one $two | Should -Be $false | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment