4

Say I have JSON like:

  {
    "a" : {
        "b" : 1,
        "c" : 2
        }
  }

Now ConvertTo-Json will happily create PSObjects out of that. I want to access an item I could do $json.a.b and get 1 - nicely nested properties.

Now if I have the string "a.b" the question is how to use that string to access the same item in that structure? Seems like there should be some special syntax I'm missing like & for dynamic function calls because otherwise you have to interpret the string yourself using Get-Member repeatedly I expect.

mklement0
  • 382,024
  • 64
  • 607
  • 775
cyborg
  • 5,638
  • 1
  • 19
  • 25

3 Answers3

15

No, there is no special syntax, but there is a simple workaround, using iex, the built-in alias[1] for the Invoke-Expression cmdlet:

$propertyPath = 'a.b'

# Note the ` (backtick) before $json, to prevent premature expansion.
iex "`$json.$propertyPath" # Same as: $json.a.b

# You can use the same approach for *setting* a property value:
$newValue = 'foo'
iex "`$json.$propertyPath = `$newValue" # Same as: $json.a.b = $newValue

Caveat: Do this only if you fully control or implicitly trust the value of $propertyPath.
Only in rare situation is Invoke-Expression truly needed, and it should generally be avoided, because it can be a security risk.

Note that if the target property contains an instance of a specific collection type and you want to preserve it as-is (which is not common) (e.g., if the property value is a strongly typed array such as [int[]], or an instance of a list type such as [System.Collections.Generic.List`1]), use the following:

# "," constructs an aux., transient array that is enumerated by
# Invoke-Expression and therefore returns the original property value as-is.
iex ", `$json.$propertyPath"

Without the , technique, Invoke-Expression enumerates the elements of a collection-valued property and you'll end up with a regular PowerShell array, which is of type [object[]] - typically, however, this distinction won't matter.

Note: If you were to send the result of the , technique directly through the pipeline, a collection-valued property value would be sent as a single object instead of getting enumerated, as usual. (By contrast, if you save the result in a variable first and the send it through the pipeline, the usual enumeration occurs). While you can force enumeration simply by enclosing the Invoke-Expression call in (...), there is no reason to use the , technique to begin with in this case, given that enumeration invariably entails loss of the information about the type of the collection whose elements are being enumerated.

Read on for packaged solutions.


Note:

  • The following packaged solutions originally used Invoke-Expression combined with sanitizing the specified property paths in order to prevent inadvertent/malicious injection of commands. However, the solutions now use a different approach, namely splitting the property path into individual property names and iteratively drilling down into the object, as shown in Gyula Kokas's helpful answer. This not only obviates the need for sanitizing, but turns out to be faster than use of Invoke-Expression (the latter is still worth considering for one-off use).

    • The no-frills, get-only, always-enumerate version of this technique would be the following function:

      # Sample call: propByPath $json 'a.b'
      function propByPath { param($obj, $propPath) foreach ($prop in $propPath.Split('.')) { $obj = $obj.$prop }; $obj }
      
    • What the more elaborate solutions below offer: parameter validation, the ability to also set a property value by path, and - in the case of the propByPath function - the option to prevent enumeration of property values that are collections (see next point).

  • The propByPath function offers a -NoEnumerate switch to optionally request preserving a property value's specific collection type.

  • By contrast, this feature is omitted from the .PropByPath() method, because there is no syntactically convenient way to request it (methods only support positional arguments). A possible solution is to create a second method, say .PropByPathNoEnumerate(), that applies the , technique discussed above.

Helper function propByPath:

function propByPath {

  param(
    [Parameter(Mandatory)] $Object,
    [Parameter(Mandatory)] [string] $PropertyPath,
    $Value,               # optional value to SET
    [switch] $NoEnumerate # only applies to GET
  )

  Set-StrictMode -Version 1

  # Note: Iteratively drilling down into the object turns out to be *faster*
  #       than using Invoke-Expression; it also obviates the need to sanitize
  #       the property-path string.
  
  $props = $PropertyPath.Split('.') # Split the path into an array of property names.
  if ($PSBoundParameters.ContainsKey('Value')) { # SET
    $parentObject = $Object
    if ($props.Count -gt 1) {
      foreach ($prop in $props[0..($props.Count-2)]) { $parentObject = $parentObject.$prop }
    }
    $parentObject.($props[-1]) = $Value
  }
  else { # GET
    $value = $Object
    foreach ($prop in $props) { $value = $value.$prop }
    if ($NoEnumerate) {
      , $value
    } else {
      $value
    }
  }

}

Instead of the Invoke-Expression call you would then use:

# GET
propByPath $obj $propertyPath

# GET, with preservation of the property value's specific collection type.
propByPath $obj $propertyPath -NoEnumerate


# SET
propByPath $obj $propertyPath 'new value'

You could even use PowerShell's ETS (extended type system) to attach a .PropByPath() method to all [pscustomobject] instances (PSv3+ syntax; in PSv2 you'd have to create a *.types.ps1xml file and load it with Update-TypeData -PrependPath):

'System.Management.Automation.PSCustomObject',
'Deserialized.System.Management.Automation.PSCustomObject' |
  Update-TypeData -TypeName { $_ } `
                  -MemberType ScriptMethod -MemberName PropByPath -Value  {                  #`

                    param(
                      [Parameter(Mandatory)] [string] $PropertyPath,
                      $Value
                    )
                    Set-StrictMode -Version 1

                    
                    $props = $PropertyPath.Split('.') # Split the path into an array of property names.
                    if ($PSBoundParameters.ContainsKey('Value')) { # SET
                        $parentObject = $this
                        if ($props.Count -gt 1) {
                          foreach ($prop in $props[0..($props.Count-2)]) { $parentObject = $parentObject.$prop }
                        }
                        $parentObject.($props[-1]) = $Value
                    }
                    else { # GET
                      # Note: Iteratively drilling down into the object turns out to be *faster*
                      #       than using Invoke-Expression; it also obviates the need to sanitize
                      #       the property-path string.
                      $value = $this
                      foreach ($prop in $PropertyPath.Split('.')) { $value = $value.$prop }
                      $value
                    }

                  }

You could then call $obj.PropByPath('a.b') or $obj.PropByPath('a.b', 'new value')

Note: Type Deserialized.System.Management.Automation.PSCustomObject is targeted in addition to System.Management.Automation.PSCustomObject in order to also cover deserialized custom objects, which are returned in a number of scenarios, such as using Import-CliXml, receiving output from background jobs, and using remoting.

.PropByPath() will be available on any [pscustomobject] instance in the remainder of the session (even on instances created prior to the Update-TypeData call [2]); place the Update-TypeData call in your $PROFILE (profile file) to make the method available by default.


[1] Note: While it is generally advisable to limit aliases to interactive use and use full cmdlet names in scripts, use of iex to me is acceptable, because it is a built-in alias and enables a concise solution.

[2] Verify with (all on one line) $co = New-Object PSCustomObject; Update-TypeData -TypeName System.Management.Automation.PSCustomObject -MemberType ScriptMethod -MemberName GetFoo -Value { 'foo' }; $co.GetFoo(), which outputs foo even though $co was created before Update-TypeData was called.

mklement0
  • 382,024
  • 64
  • 607
  • 775
  • You don't need to escape newlines within a scriptblock and I'm unsure if your tick behind a comment actually *does* anything. – Maximilian Burszley Aug 15 '18 at 20:33
  • 1
    @TheIncorrigible1: The sole reason the off-screen `#\`` is there is to mitigate the broken SO syntax highlighting. I could have used splatting to avoid this, but I didn't want to confuse matters by introducing unrelated concepts. – mklement0 Aug 15 '18 at 20:35
  • @TheIncorrigible1: And, yes, a `\`` placed after `#` (starting a to-end-of-line comment) never has any effect in PowerShell (which is the intent here). – mklement0 Aug 15 '18 at 21:01
  • 1
    Was not aware of ETS and that certainly seems like it'd be extremely useful for a lot of the stuff I'm trying to do. Essentially I'm trying to have a way to point to a particular point of the structure. For XML that was XPath and that's already part of the API. – cyborg Aug 16 '18 at 08:49
  • 2
    @cyborg: The reason that it doesn't work with _deserialized_ custom objects is that they have a different type name: `Deserialized.System.Management.Automation.PSCustomObject` - I've updated the answer to add an ETS method to that type name also. – mklement0 Aug 20 '18 at 15:38
  • 1
    @mklement0 I'll try it out - tables not an issue so much since the property name structure is essentially the key the way the JSON is parsed. – cyborg Aug 21 '18 at 09:10
  • This reads fine, but, writes only one level deep. propByPath -Object $json -PropertyPath 'solution.features.version' -Value '1.0.0.29' fails with "The property 'version' cannot be found on this object. Verify that the property exists and can be set." at the 'solution.version' level this works as expected (both read & write). Using iex "`$json.$propertyPath = `$newValue"; at the 'solution.features.version' level it reads fine, but, also fails on the write. Likewise, the Update-TypeData implimentation (Which I love btw) has the same limitation. Is there a good way to fully recurse? – Joe Johnston Mar 15 '23 at 15:45
  • @JoeJohnston, I don't see the problem with nested writes; e.g. `propByPath ($o=ConvertFrom-Json '{"a":{"b":{"c":"foo"}}}') a.b.c bar; $o.a.b.c` works as expected. Note that - by design - writing only allows you to update _existing_ properties. – mklement0 Mar 15 '23 at 15:57
  • Here is the json I was using it with. I abbreviated it as much as I could while keeping the structure. Maybe I am missing something pretty key. https://raw.githubusercontent.com/cyberjetx/ABP/master/package-solution.json – Joe Johnston Mar 16 '23 at 16:52
  • 1
    @JoeJohnston, the problem with that data isn't _nesting_, it is that the property you're trying to assign to is _inside an array_. The solution is to use `(propByPath $fromJson solution.features)[0].version = '42.0.0'`, for instance. This is a somewhat unfortunate, but by-design asymmetry in PowerShell's implementation of its _member-access enumeration_ feature - see [this answer](https://stackoverflow.com/a/75025094/45375) for details. – mklement0 Mar 16 '23 at 17:25
  • 1
    Thanks for this. I appreciate your time. Interesting how the read worked. I will apply this tomorrow. Sharing Update-TypeData was a real learning point for me. Easy to abuse prototyping like this but so good to know. – Joe Johnston Mar 16 '23 at 20:43
2

This workaround is maybe useful to somebody.

The result goes always deeper, until it hits the right object.

$json=(Get-Content ./json.json | ConvertFrom-Json)

$result=$json
$search="a.c"
$search.split(".")|% {$result=$result.($_) }
$result


Gyula Kokas
  • 141
  • 6
  • Nicely done, though a bit unwieldy for repeated use. You can speed it up by using a `foreach` loop, and you can package it up as a function as follows: `function propByPath { param($obj, $propPath) foreach ($prop in $propPath.Split('.')) { $obj = $obj.$prop }; $obj }`. As it turns out, despite the use of a loop, this technique is faster than `Invoke-Expression` (though that would only matter with a large number of repetitions of applying the technique), and it is also safe by default. – mklement0 Aug 19 '21 at 16:40
1

You can have 2 variables.

$json = '{
    "a" : {
        "b" : 1,
        "c" : 2
        }
  }' | convertfrom-json
$a,$b = 'a','b'
$json.$a.$b
1
mklement0
  • 382,024
  • 64
  • 607
  • 775
js2010
  • 23,033
  • 6
  • 64
  • 66