Keith Twombley
Keith Twombley

Reputation: 1688

When my powershell cmdlet parameter accepts ValueFromPipelineByPropertyName and I have an alias, how can I get the original property name?

How can a function tell if a parameter was passed in as an alias, or an object in the pipeline's property was matched as an alias? How can it get the original name?

Suppose my Powershell cmdlet accepts pipeline input and I want to use ValueFromPipelineByPropertyName. I have an alias set up because I might be getting a few different types of objects, and I want to be able to do something slightly different depending on what I receive.

This does not work

function Test-DogOrCitizenOrComputer
{
    [CmdletBinding()]
    Param
    (
        # Way Overloaded Example
        [Parameter(Mandatory=$true,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true,
                   Position=0)]
        [Alias("Country", "Manufacturer")] 
        [string]$DogBreed,

        [Parameter(Mandatory=$true,
                   ValueFromPipelineByPropertyName=$true,
                   Position=1)]
        [string]$Name

    )
    # For debugging purposes, since the debugger clobbers stuff
    $foo = $MyInvocation
    $bar = $PSBoundParameters

    # This always matches.
    if ($MyInvocation.BoundParameters.ContainsKey('DogBreed')) {
        "Greetings, $Name, you are a good dog, you cute little $DogBreed"
    }
    # These never do.
    if ($MyInvocation.BoundParameters.ContainsKey('Country')) {
        "Greetings, $Name, proud citizen of $Country"
    }
    if ($MyInvocation.BoundParameters.ContainsKey('Manufacturer')) {
        "Greetings, $Name, future ruler of earth, created by $Manufacturer"
    }

}

Executing it, we see problems

At first, it seems to work:

PS> Test-DogOrCitizenOrComputer -Name Keith -DogBreed Basset
Greetings, Keith, you are a good dog, you cute little Basset

The problem is apparent when we try an Alias:

PS> Test-DogOrCitizenOrComputer -Name Calculon -Manufacturer HP
Greetings, Calculon, you are a good dog, you cute little HP

Bonus fail, doesn't work via pipeline:

PS> New-Object PSObject -Property @{'Name'='Fred'; 'Country'='USA'} | Test-DogOrCitizenOrComputer
Greetings, Fred, you are a good dog, you cute little USA

PS> New-Object PSObject -Property @{'Name'='HAL'; 'Manufacturer'='IBM'} | Test-DogOrCitizenOrComputer
Greetings, HAL, you are a good dog, you cute little IBM

Both $MyInvocation.BoundParameters and $PSBoundParameters contain the defined parameter names, not any aliases that were matched. I don't see a way to get the real names of arguments matched via alias.

It seems PowerShell is not only being 'helpful' to the user by silently massaging arguments to the right parameters via aliases, but it's also being 'helpful' to the programmer by folding all aliased inputs into the main parameter name. That's fine, but I can't figure out how to determine the actual original parameter passed to the Cmdlet (or the object property passed in via pipeline)

How can a function tell if a parameter was passed in as an alias, or an object in the pipeline's property was matched as an alias? How can it get the original name?

Upvotes: 4

Views: 623

Answers (2)

FSCKur
FSCKur

Reputation: 1050

I banged my head quite hard on this, so I'd like to write down the state of my understanding. The solution is at the bottom (such as it is).

First, quickly: if you alias the command, you can get the alias easily with $MyInvocation.InvocationName. But that doesn't help with parameter aliases.


Works in some cases

You can get some joy by pulling the commandline that invoked you:

function Do-Stuff {
    [CmdletBinding()]param(
        [Alias('AliasedParam')]$Param
    )
    
    $InvocationLine = $MyInvocation.Line.Substring($MyInvocation.OffsetInLine - 1)    
    return $InvocationLine
}

$a = 42; Do-Stuff -AliasedParam $a; $b = 23
# Do-Stuff -AliasedParam $a; $b = 23

This will show the alias names. You could parse them with regex, but I'd suggest using the language parser:

$InvocationAst = [Management.Automation.Language.Parser]::ParseInput($InvocationLine, [ref]$null, [ref]$null)                
$InvocationAst.EndBlock.Statements[0].PipelineElements[0].CommandElements.ParameterName

That will get you a list of parameters as they were called. However, it's flimsy:

  • Doesn't work for splats
  • Doesn't work for ValueFromPipelineByPropertyName
  • Abbreviated param names will cause extra headache
  • Only works in the function body; in a dynamicparam block, the $MyInvocation properties are not yet populated

Doesn't work

I did a deep dive into ParameterBinderController - thanks to Rohn Edwards for some reflection snippets.

This is not going to get you anywhere. Why not? Because the relevant method has no side effects - it just moves seamlessly from canonical param names to aliases. Reflection ain't enough; you would need to attach a debugger, which I do not consider to be a code solution.

This is why Trace-Command never shows the alias resolution. If it did, you might be able to hook the trace provider.


Doesn't work

Register-ArgumentCompleter takes a scriptblock which accepts a CommandAst. This AST holds the aliased param names as tokens. But you won't get far in a script, because argument completers are only invoked when you interactively tab-complete an argument.

There are several completer classes that you could hook into; this limitation applies to them all.


Doesn't work

I messed about with custom parameter attributes, e.g. class HookAttribute : System.Management.Automation.ArgumentTransformationAttribute. These receive an EngineIntrinsics argument. Unfortunately, you get no new context; parameter binding has already been done when attributes are invoked, and the bindings you'll find with reflection are all referring to the canonical parameter name.

The Alias attribute itself is a sealed class.


Works

Where you can get joy is with the PreCommandLookupAction hook. This lets you intercept command resolution. At that point, you have the args as they were written.

This sample returns the string AliasedParam whenever you use the param alias. It works with abbreviated param names, colon syntax, and splatting.

$ExecutionContext.InvokeCommand.PreCommandLookupAction = {
    param ($CommandName, $EventArgs)

    if ($CommandName -eq 'Do-Stuff' -and $EventArgs.CommandOrigin -eq 'Runspace')
    {
        $EventArgs.CommandScriptBlock = {
            # not sure why, but Global seems to be required
            $Global:_args = $args
            & $CommandName @args
            Remove-Variable _args -Scope Global
        }.GetNewClosure()
        $EventArgs.StopSearch = $true
    }
}


function Do-Stuff
{
    [CmdletBinding()]
    param
    (
        [Parameter()]
        [Alias('AliasedParam')]
        $Param
    )

    $CalledParamNames = @($_args) -match '^-' -replace '^-' -replace ':$'
    $CanonParamNames = $MyInvocation.BoundParameters.Keys
    $AliasParamNames = $CanonParamNames | ForEach-Object {$MyInvocation.MyCommand.Parameters[$_].Aliases}

    # Filter out abbreviations that could match canonical param names (they take precedence over aliases)
    $CalledParamNames = $CalledParamNames | Where-Object {
        $CalledParamName = $_
        -not ($CanonParamNames | Where-Object {$_.StartsWith($CalledParamName)} | Select-Object -First 1)
    }

    # Param aliases that would bind, so we infer that they were used
    $BoundAliases = $AliasParamNames | Where-Object {
        $AliasParamName = $_
        $CalledParamNames | Where-Object {$AliasParamName.StartsWith($_)} | Select-Object -First 1
    }

    $BoundAliases
}

# Do-Stuff -AliasP 42
# AliasedParam

If the Global variable offends you, you could use a helper parameter instead:

$EventArgs.CommandScriptBlock = {
    & $CommandName @args -_args $args
}.GetNewClosure()

[Parameter(DontShow)]
$_args

The drawback is that some fool might actually use the helper parameter, even though it's hidden with DontShow.

You could develop this approach further by doing a dry-run call of the parameter binding mechanism in the function body or the CommandScriptBlock.

Upvotes: 0

Mark Wragg
Mark Wragg

Reputation: 23355

I don't think there is any way for a Function to know if an Alias has been used, but the point is it shouldn't matter. Inside the function you should always refer to the parameter as if its used by it's primary name.

If you need the parameter to act different depending on whether it's used an Alias that is not what an Alias is for and you should instead use different parameters, or a second parameter that acts as a switch.

By the way, if you're doing this because you want to use multiple parameters as ValueFromPipelineByPropertyName, you already can with individual parameters and you don't need to use Aliases to achieve this.

Accepting value from the pipeline by Value does need to be unique, for each different input type (e.g only one string can be by value, one int by value etc.). But accepting pipeline by Name can be enabled for every parameter (because each parameter name is unique).

Upvotes: 4

Related Questions