Skip to content

Instantly share code, notes, and snippets.

@sean-m
Last active May 2, 2022 19:04
Show Gist options
  • Save sean-m/743eea329cc29176c6cbf9caf67b8812 to your computer and use it in GitHub Desktop.
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.
#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