I am looking into iterating json attributes and values from a json file w/out prior knowledge of the schema. I have code that works with many of the json files or downloads I have tested, but draw blanks when it comes to iterating nested values. I have looked at various posts that use Get-Member and .psobject.name and NoteProperty... but all seemed to have prior knowledge of the json content. I tested this
{
"FoodChoices": [
{
"id": 1,
"name": "TakeOut",
"variableGroups": [],
"variables": {
"Location": {
"value": "Fast Food"
},
"Beverage": {
"value": "Soda or Lemondade"
},
"Tip": {
"value": "No Way"
}
}
},
{
"id": 2,
"name": "Gourmet",
"variableGroups": [],
"variables": {
"Location": {
"value": "Nice Resturant"
},
"Beverage": {
"value": "Dirty Martini"
},
"Tip": {
"value": "Maybe"
}
}
}
]
}
but had to kludge the iteration
$jfile = "$PSScriptRoot\FoodChoice.json"
$file = Get-Content $jFile -Raw
$parse = ($file.substring(0,1) -ne "[")
$json = $file | ConvertFrom-Json
$parent = $json | Get-Member -MemberType NoteProperty | Select-Object -ExpandProperty Name
if ($parse -eq $true)
{
foreach ($p in $parent)
{
$d = $json.$p
if ($d.length -gt 1) { $data = $d }
}
}
else {$data = $json }
$out = "Key" + "`t" + "Value" + "`n"
$keys = $data | get-member -type properties | Foreach-Object name
foreach ($d in $data)
{
foreach ($k in $keys)
{
$name = $d.$k
if ($name -match "@")
{
$out += "$k`t`n"
$members = Get-Member -InputObject $name
foreach ($m in $members)
{
if ($m -match "System.Management.Automation.PSCustomObject")
{
$m1 = $m.ToString()
$m1 = $m1.Replace("System.Management.Automation.PSCustomObject","")
if ($m1 -match "@")
{
$m1 = $m1.Replace("@{","")
$m1 = $m1.Replace("}","")
$m1 = $m1 -split "="
$m2 = $m1[0]
$m3 = $m1[2]
$out += "$m2`t$m3`n"
}
else {$out += "$name`t$m1`n"}
}
}
}
else {$out += "$k`t$name`n"}
}
}
$out
Exit
Hopefully there is a generic way to parse out nested attributes with type of System.Management.Automation.PSCustomObject.
The script outputs
Key Value
id 1
name TakeOut
variableGroups
variables
Beverage Soda or Lemondade
Location Fast Food
Tip No Way
id 2
name Gourmet
variableGroups
variables
Beverage Dirty Martini
Location Nice Resturant
Tip Maybe
Before answering your question, I like to start with a few comments and asides:
Your variable naming is somewhat confusing, what you call $json
($file | ConvertFrom-Json
) has no relation anymore with Json, it is a PowerShell Object(-Graph) containing collections as arrays (@()
) and PSCustomObjects(1) ([PSCustomObject]@{}
) or HashTables (@{}
) that contain other collections or scalars as strings and primitives. For a similar reason is the $file
variable somewhat confusing as that has no relation anymore with file except for the content (consider: $Content
or even explicit the Json string (actually $Json
).
PSCustomObject
, see:Using the increase assignment operator (+=
) to build a string is inefficient, see this answer and Performance Considerations / String Addition
If you using PowerShell 6 or newer, there is no need to use PSCustomObject
s, instead you might use the -AsHashTable
parameter which returns HashTable
s. A HashTable
is a little easier to handle than a PSCustomObject
.
This last comment actually shows that there isn't much difference between a PSCustomObject
and a HashTable
(or even any dictionary) if it comes to an object-graph. Meaning, when converting an object-graph back to Json
string (using e.g.: ConvertTo-Json -Depth 99
) you will get the same results. Hance my propose to: Make PSCustomObject more Dictionary alike (#20591
)
Btw, a similar behavior exists for arrays where ConvertTo-Json
converts anything that supports the IList
interface to an Json array.
So basically there exist only 3 types of node structures:
Map
nodesIDictionary
interface, (as HashTable
, Ordered
and Dictionary[string,string]
) and certain objects (as the PSCustomObject
and a ComponentModel.Component
).List
nodesIList
interface.
Note that ConvertFrom-Json
cmdlet returns a object-graph based on fixed size arrays which are awkward if it comes to modifying to modifying them, yet the ConvertTo-Json
cmdlet simply supports List
objects instead of array
s. Hence the propose: Add -AsList
switch to ConvertFrom-Json
(#24010
)Leaf
nodesstring
or a primitive) that resides at the end of each (embedded) collection branch.In other words: a "generic way to parse out nested attributes" should in my opinion accept just these 3 structures without being strict on the underlying .Net type.
One of my first concepts resides in this answer which you might use to recursively iterate to the whole Object-Graph tree yourself if you do not want to rely on a custom module.
Anyways, the ObjectGraphTools set might already be helpful for a exploring an object-graph were e.g. the convenient Member-Access Enumeration feature often lead to a pitfall during recursive iteration. For this, you might use the Get-ChildNode
cmdlet:
$Object = $Json | ConvertFrom-Json
$Object | Get-ChildNode -Recurse -Leaf
Path Name Depth Value
---- ---- ----- -----
FoodChoices[0].id id 3 1
FoodChoices[0].name name 3 TakeOut
FoodChoices[0].variables.Location.value value 5 Fast Food
FoodChoices[0].variables.Beverage.value value 5 Soda or Lemondade
FoodChoices[0].variables.Tip.value value 5 No Way
FoodChoices[1].id id 3 2
FoodChoices[1].name name 3 Gourmet
FoodChoices[1].variables.Location.value value 5 Nice Resturant
FoodChoices[1].variables.Beverage.value value 5 Dirty Martini
FoodChoices[1].variables.Tip.value value 5 Maybe
Or to build a PowerShell expression template using the Copy-ObjectGraph
and ConvertTo-Expression
cmdlets:
$Object | Copy-ObjectGraph -ListAs Collections.Generic.List[object] -MapAs Ordered |
ConvertTo-Expression -LanguageMode Full
[ordered]@{
FoodChoices = [System.Collections.Generic.List[System.Object]]@(
[ordered]@{
id = [long]1
name = [string]'TakeOut'
variableGroups = [System.Collections.Generic.List[System.Object]]@()
variables = [ordered]@{
Location = [ordered]@{ value = [string]'Fast Food' }
Beverage = [ordered]@{ value = [string]'Soda or Lemondade' }
Tip = [ordered]@{ value = [string]'No Way' }
}
},
[ordered]@{
id = [long]2
name = [string]'Gourmet'
variableGroups = [System.Collections.Generic.List[System.Object]]@()
variables = [ordered]@{
Location = [ordered]@{ value = [string]'Nice Resturant' }
Beverage = [ordered]@{ value = [string]'Dirty Martini' }
Tip = [ordered]@{ value = [string]'Maybe' }
}
}
)
}
And in case you do want to adopt this module, you could iterate through the whole object-graph in several ways:
By creating a function that calls itself, e.g.:
function Iterate([PSNode]$Node) { # Basic iterator
if ($Node -is [PSLeafNode]) {
"{0}{1}: {2}" -f (' ' * $Node.Depth), $Node.Name, $Node.Value
}
else {
$Node.ChildNodes.foreach{ Iterate $_ }
}
}
$Object = $Json | ConvertFrom-Json
$PSNode = [PSNode]::ParseInput($Object)
Iterate $PSNode
id: 1
name: TakeOut
value: Fast Food
value: Soda or Lemondade
value: No Way
id: 2
name: Gourmet
value: Nice Resturant
value: Dirty Martini
value: Maybe
Knowing that PowerShell functions are somewhat expensive (see this post), you might consider to do a "flat iteration" instead (which has an equal output):
$LeafNodes = $Object | Get-ChildNode -Recurse -Leaf
foreach ($Node in $LeafNodes) {
"{0}{1}: {2}" -f (' ' * $Node.Depth), $Node.Name, $Node.Value
}
In some cases you may not need to iterate at all, e.g. where want to lookup the tip of all "Fast Food"
locations. For this I designed a Extended Dot Notation (Xdn) similar to XPath for XML objects which you might use together with the Get-Node
cmdlet:
$Object | Get-Node ~*="Fast Food"...Tip.Value
Path Name Depth Value
---- ---- ----- -----
FoodChoices[0].variables.Tip.value value 5 No Way
To modify the concerned node value (in your example), you could do this:
($Object | Get-Node ~*="Fast Food"...Tip.Value).Value = 'Never'
Or in case it concerns multiple Tip.Value
nodes, it would be:
$Object | Get-Node ~*="Fast Food"...Tip.Value | ForEach-Object { $_.Value = 'Never' }
Where
$Object | ConvertTo-Json -Depth 9 # Results in:
{
"FoodChoices": [
{
"id": 1,
"name": "TakeOut",
"variableGroups": [],
"variables": {
"Location": {
"value": "Fast Food"
},
"Beverage": {
"value": "Soda or Lemondade"
},
"Tip": {
"value": "Never"
}
}
},
{
"id": 2,
"name": "Gourmet",
"variableGroups": [],
"variables": {
"Location": {
"value": "Nice Resturant"
},
"Beverage": {
"value": "Dirty Martini"
},
"Tip": {
"value": "Maybe"
}
}
}
]
}