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