jsonpowershellpscustomobjectobject-graph

Iterating Json attributes and PSCustom Objects


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

Solution

  • Before answering your question, I like to start with a few comments and asides:

    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 . 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:

    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:

    Reclusive iteration

    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
    

    Flat iteration

    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 
    }
    

    No iteration

    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"
        }
      }
    }
      ]
    }