Fun with Select-Object (and ProxyCommand)

PowerShell's "calculated properties" and the Select-Object cmdlet makes transforming objects in the middle of an operation fairly easy, and I certainly use it all the time - but can we make the syntax a little more lean without loosing the flexibility it affords? Let's find out!

The magic of calculated properties

As many PowerShell users probably know, Select-Object allows you to explicitly select or exclude specific properties attached to an object.

Let's take the output from the Get-FileHash cmdlet as an example. We can use Get-Member to have a look at which properties the objects that it returns has:

PS C:\Users\iisresetme> $emptyFile = [System.IO.Path]::GetTempFileName()  
PS C:\Users\iisresetme> Get-FileHash -Path $emptyFile |Get-Member -MemberType Properties

   TypeName: Microsoft.Powershell.Utility.FileHash

Name      MemberType   Definition  
----      ----------   ----------
Algorithm NoteProperty string Algorithm=SHA256  
Hash      NoteProperty string Hash=E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855  
Path      NoteProperty string Path=C:\Users\iisresetme\AppData\Local\Temp\tmp1960.tmp  

Alright, so we've got three properties to work with, all strings - Algorithm, Hash, and Path.

Now, say we execute Get-FileHash against a set of executable files to obtain their MD5 checksum, and I want it to only return to me the Hash and Path values, but not the Algorithm itself. I might do something like this:

Get-FileHash .\bin\*.exe -Algorithm MD5 |Select-Object Hash,Path  

or, alternatively:

Get-FileHash .\bin\*.exe -Algorithm MD5 |Select-Object * -Exclude Algorithm  

Great! Select-Object removed the Algorithm property from the objects, and we can move straight on to exporting this data to a CSV file, or convert it to JSON, and then feed the checksums to another system if need be!

But wait! Now the boss comes back and demands an output that lists a Checksum, rather than a Hash.


Fortunately, Select-Object's -Property parameter supports a calculated property in place of any property name. Calculated properties are not exclusive to Select-Object, they behave like to pipeline-bound properties and they work with a number of other cmdlets in the Microsoft.PowerShell.Utility module! They come in two variants:

  • named, @{ Name = 'SyntheticProperty'; Expression = { $_.ExistingProperty } }
  • anonymous, @{ Expression = { $_.ExistingProperty }} or simply { $_.ExistingProperty }

Named calculated properties

For Select-Object, you'd almost always want a named calculated property, like in our case - we need the new Name to be Checksum:

Get-FileHash .\bin\*.exe -Algorithm MD5 |Select-Object @{Name='Checksum';Expression={$_.Hash}},Path  

The result will be identical, except the resulting objects now have a Checksum property instead of a Hash property:

PS C:\Users\iisresetme> Get-FileHash .\bin\*.exe -Algorithm MD5 |Select-Object @{Name='Checksum';Expression={$_.Hash}},Path

Checksum                         Path  
--------                         ----
D41D8CD98F00B204E9800998ECF8427E C:\Users\iisresetme\bin\empty.exe  

Anonymous calculated properties

Anonymous calculated properties are great with cmdlets like Sort-Object and Group-Object:

1..10 |Group-Object {$_ % 3}  
# exactly the same as
1..10 |Group-Object @{Expression={$_ % 3}}  

The Expression script block itself behaves like a pipeline-bound property so $_ always refers to the current pipeline item.

Pretty cool, huh?

I'd like it a bit more... lean

So, Select-Object and a calculated property essentially allowed us to rename a property in the output from Get-FileHash. That's extremely useful, but the verbosity of the hashtable with explicit keys takes up a lot of command line real estate:

Get-Item .\file |Select-Object Name,@{Name='Size';Expression={$_.Length}}  

I often use calculated properties while using PowerShell interactively, and writing this much unnecessary code becomes tiresome and increases the chance of me mistyping something. It would be much nicer if we could trim some characters. Fortunately, PowerShell will gladly accept a shortened key name in place of Name or Expression:

Get-Item .\file |Select-Object Name,@{N='Size';E={$_.Length}}  

That's already better - fewer keystrokes and not a lot of confusing occurrences of Name all over the place. PowerShell will also accept a Label key instead of Name, and I find it easier to reach the L key on my QWERTY keyboard so often find myself writing calculated properties like this instead:

Get-Item .\file |Select-Object Name,@{L='Size';E={$_.Length}}  

Since we need a specific property value as-is, we can also use a simple string in place of the pipeline-bound scriptblock:

Get-Item .\file |Select-Object Name,@{L='Size';E='Length'}  

This is pretty terse, but I still feel like we can shave down the number of characters we need to type. See, named calculated properties are always a hashtable with a pair of keys - we could as well express them as a single entry without losing any meaning:

# wouldn't this be nice and readable?
@{ Size = 'Length' }
# as opposed to this?
@{ N = 'Size'; E = 'Length' }

This should be doable if we write a function that accepts some pipeline input, one or more hashtables, and then we copy the names and values into separate Name and Expression entries respectively, and finally pass them off to Select-Object.

You could almost say we would need a... proxy function for Select-Object!

Proxy Commands to the rescue!

In my currently (as of September 2018) highest-upvoted answer on StackOverflow, I show how to create a simple proxy function for Test-Path, using the ProxyCommand class. We'll use a similar approach to generate a pipeline-aware no-op proxy function, and then build a cmdlet called Select-As from that:

$SelectObject = Get-Command Select-Object
$SelectObjectMetaData = [System.Management.Automation.CommandMetadata]::new($SelectObject)

[System.Management.Automation.ProxyCommand]::Create($SelectObjectMetaData)

To win, you must first parameterize...

The parameters you declare for a function says a lot about how you expect the user to interact with it, so let's start by looking at the param() block. The ProxyCommand.Create() call will result in a function body that has a param() block looking like this:

param(  
    [Parameter(ValueFromPipeline=$true)]
    [psobject]
    ${InputObject},

    [Parameter(ParameterSetName='DefaultParameter', Position=0)]
    [Parameter(ParameterSetName='SkipLastParameter', Position=0)]
    [System.Object[]]
    ${Property},

    [Parameter(ParameterSetName='DefaultParameter')]
    [Parameter(ParameterSetName='SkipLastParameter')]
    [string[]]
    ${ExcludeProperty},

    [Parameter(ParameterSetName='SkipLastParameter')]
    [Parameter(ParameterSetName='DefaultParameter')]
    [string]
    ${ExpandProperty},

    [switch]
    ${Unique},

    [Parameter(ParameterSetName='DefaultParameter')]
    [ValidateRange(0, 2147483647)]
    [int]
    ${Last},

    [Parameter(ParameterSetName='DefaultParameter')]
    [ValidateRange(0, 2147483647)]
    [int]
    ${First},

    [Parameter(ParameterSetName='DefaultParameter')]
    [ValidateRange(0, 2147483647)]
    [int]
    ${Skip},

    [Parameter(ParameterSetName='SkipLastParameter')]
    [ValidateRange(0, 2147483647)]
    [int]
    ${SkipLast},

    [Parameter(ParameterSetName='DefaultParameter')]
    [Parameter(ParameterSetName='IndexParameter')]
    [switch]
    ${Wait},

    [Parameter(ParameterSetName='IndexParameter')]
    [ValidateRange(0, 2147483647)]
    [int[]]
    ${Index})

Phew, that's a lot more parameters than I would've thought of if I had to write our proxy function by hand! Good thing I didn't!


To be honest though, I'm going to trim the parameter sets a bit. We're specifically targeting use cases for named calculated properties - that means that -ExpandProperty and -Index are unnecessary, so we can remove those and will end up with something like this instead:

param(  
    [Parameter(ValueFromPipeline=$true)]
    [psobject]
    ${InputObject},

    [Parameter(Mandatory=$true, Position=0)]
    [System.Object[]]
    ${Property},

    [string[]]
    ${ExcludeProperty},

    [switch]
    ${Unique},

    [Parameter(ParameterSetName='DefaultParameter')]
    [ValidateRange(0, 2147483647)]
    [int]
    ${Last},

    [Parameter(ParameterSetName='DefaultParameter')]
    [ValidateRange(0, 2147483647)]
    [int]
    ${First},

    [Parameter(ParameterSetName='DefaultParameter')]
    [ValidateRange(0, 2147483647)]
    [int]
    ${Skip},

    [Parameter(ParameterSetName='SkipLastParameter')]
    [ValidateRange(0, 2147483647)]
    [int]
    ${SkipLast},

    [Parameter(ParameterSetName='DefaultParameter')]
    [switch]
    ${Wait}
)

With -Index, and the IndexParameter parameter set removed, there's no reason to explicitly declare membership of the remaining parameter sets, meaning that we can remove a couple of superfluous [Parameter] attributes. I've also marked -Property mandatory, since the whole point of Select-As is to select named properties, so we want to force the caller to specify them.

Let the magic begin

With our param() block sorted, we can move on to the real fun - transforming our new lean property syntax:

$PSBoundParameters['Property'] = foreach($Prop in $Property){
    if ($Prop -is [System.Collections.IDictionary] -and $Prop.Count -eq 1){
        @{
            Name = "$(@($Prop.Keys)[0])"
            Expression = @($Prop.Values)[0]
        }
    }
    else {
        $Prop
    }
}

And that's it. That's literally all the code we need to translate our simpler property translation syntax into a real calculated property:

  • If the caller supplies a dictionary (the IDictionary interface allows us to support [ordered]@{} as well!), and it only has one entry, we expand that entry into a regular named calculated property.
  • If not, we just pass along the property value without touching it, allowing the caller to pass strings and actual calculated properties along as well.

We finally overwrite the -Property parameter with the new translated hashtables and values.

Now we just need to glue the code into the top of the begin block in our generated proxy, and we're good to go!

We're going to fail when someone supplies an unnamed calculated property with an Expression key, you say? What to do!?

Easy - we're simply not going to honor that special case of anonymous expressions! It might be tempting to detect the presence of a single Expression block, but as soon as we do, we'll have prevented the renaming of properties to the perfectly good name "Expression".

Time to take it for a spin!

Wrap the whole thing in function Select-As { ... }, run and then feast your eyes on a much simpler syntax!

Get-Item .\test.exe |Select-As Name,@{Size = 'Length'}  

or used in the context of our original Get-FileHash conundrum:

Get-FileHash .\bin\*.exe -Algorithm MD5 |Select-As @{Checksum = 'Hash'},Path  

I'm fairly content with the result, especially given the fact that it effectively only took 14 lines of code to write (and we got to delete almost as much from the generated proxy function!)

That's all the fun you're going to get for now, but I'm keen to hear what you use calculated properties for and whether a simpler syntax might be useful, so hit me up @IISResetMe on twitter!


The final Select-As proxy function can be found here