Custom Parameter Coercion with the ArgumentTransformationAttribute

Posted: March 29, 2017 in PowerShell
Tags: , ,

Introduction

Today I want to talk about a pretty cool way to transform an input parameter of one type into a different type automatically. Of course, PowerShell does this already with all sorts of types for you. If you have a function that takes an [int] as input, you can provide a number as a [string], and the engine will take care of converting, or coercing, the string into the proper type. Another example is [datetime] coercion:

function Test-Coercion {
    [CmdletBinding()]
    param(
        [datetime] $Date
    )

    $Date
}

Besides providing actual [datetime] objects, you can provide a [string] or an [Int64]:

PS C:\> Test-Coercion -Date 3/1/17

Wednesday, March 1, 2017 12:00:00 AM

PS C:\> Test-Coercion -Date 2017-03-01

Wednesday, March 1, 2017 12:00:00 AM

PS C:\> Test-Coercion -Date 636239232000000000

Wednesday, March 1, 2017 12:00:00 AM

Examples of coercion are all over the place, and you could spend lots of time going over all the details, so I don’t want to talk about that. Instead, I want to talk about how you can control this process to either change or extend it for custom functions (or variables).

Let’s look back at Test-Coercion, and how it changed ‘3/1/17’ into ‘March 1, 2017’. That makes perfect sense to me since I’m used to the US format, but some cultures would consider that to be ‘January 3, 2017’. If you pass that string to [datetime]::Parse(), you’ll get a culture-specific result, depending on your active culture (run Get-Culture to see yours). If you cast the string to a [datetime], though, you’ll get ‘March 1, 2017’, no matter what your culture is. What if you wanted to be able to pass a string that’s parsed using your culture’s format, and you didn’t want to change the parameter’s type to [string]?

What if you also wanted to be able to provide some “friendly” text, like ‘Today’, ‘Yesterday’, ‘1 week ago’, etc? You could make the parameter’s type a [string], and handle testing whether or not the user passed a valid string inside your function’s code, but I don’t like that because you’d be on the hook for throwing an error when an invalid string is provided. I would rather have Get-Command and Get-Help show that the parameter is a [datetime], and mention in the command’s documentation that, oh, by the way, you can provide these “friendly” strings in addition to [datetime] objects and strings that get coerced already. That way, if someone doesn’t read the help, but they look at the syntax, they’ll know that the command expects [datetime] objects.

You can handle both of those scenarios by implementing your own special PowerShell class called an ArgumentTransformationAttribute. That might sound complicated, but it actually only takes a few lines of boilerplate code when using PowerShell classes. If you don’t have PSv5+, you can still handle it with C# code, but that’s obviously going to be a little bit more complicated (it’s still not that bad, it just looks worse).

In the examples that follow, we’ll go over how to create your own ArgumentTransformationAttributes using either way, so you should be able to use this for any version of PowerShell (I have no idea if it will work in PSv2 or lower, though).

A Simple ArgumentTransformationAttribute Example

Let’s start by adding a new parameter to Test-Coercion from above that parses strings in a culture-specific way, and that allows a few hard-coded strings that normally wouldn’t be coerced into [datetime] objects. We’ll do that by creating a [SpecialDateTime()] attribute:

class SpecialDateTimeAttribute : System.Management.Automation.ArgumentTransformationAttribute {
    [object] Transform([System.Management.Automation.EngineIntrinsics] $engineIntrinsics, [object] $inputData) {

        $DateTime = [datetime]::Now
        $SpecialStrings = @{
            Now = { Get-Date }
            Today = { (Get-Date).Date }
            Yesterday = { (Get-Date).Date.AddDays(-1) }
        }

        if ($inputData -is [datetime]) {
            # Already [datetime], so send it on
            return $inputData
        }
        elseif ([datetime]::TryParse($inputData, [ref] $DateTime)) {
            # String that can turn into a valid [datetime]
            return $DateTime
        }
        elseif ($inputData -in $SpecialStrings.Keys) {
            return & $SpecialStrings[$inputData]
        }
        else {
            # Send the original input back out, and let PS handle showing the user an error
            return $inputData
        }
    }
}

function Test-Coercion {
    [CmdletBinding()]
    param(
        [SpecialDateTime()]
        [datetime] $Date
    )

    $Date
}

The important parts:

  • You have to extend the ArgumentTransformationAttribute class, which is what putting : System.Management.Automation.ArgumentTransformationAttribute after your class name does
  • You have to implement the Transform() method with the signature you see above. $inputData is the object that was passed in that you have the option of modifying. If you’re using PSv5, you can pretty much ignore the $engineIntrinsics. I’ll talk more about it below, because it is useful for C# implementations.
  • You have to return something. I usually just return the original $inputData if the code doesn’t know what to do with whatever input was provided, which will let the normal parameter binding process handle coercion or erroring out.
  • You need to decorate your parameter with the attribute you created. In the example above, that’s where the [SpecialDateTime()] comes in.

Once you do that, whatever a user passes into the -Date parameter will go through the code in the [SpecialDateTime()] first. Note that you may have to change the order of the parameter attributes in some cases. If I recall correctly, I used to have to put the [datetime] before the transformation attribute, but that doesn’t seem to matter in PSv5+.

Let’s see what happens when we run Test-Coercion now:

PS C:\> Test-Coercion Today

Friday, March 24, 2017 12:00:00 AM

PS C:\> Test-Coercion Yesterday

Thursday, March 23, 2017 12:00:00 AM

PS C:\> & {
    [System.Threading.Thread]::CurrentThread.CurrentCulture = 'en-GB'
    Test-Coercion 3/1
}

03 January 2017 00:00:00

It knows what ‘Today’ and ‘Yesterday’ mean, and when I switch the culture to en-GB, ‘3/1’ is interpreted as ‘January 3rd’.

A Reusable Transformation Attribute (with C#, too)

Creating these transformation attributes doesn’t seem to get a lot of attention. Out of the examples I have seen, all of them are created to do a specific job, and can’t really be reused for something else. PowerShell classes make it so that’s not too bad to handle, but I used to use these when you had to make a C# class, and creating, then testing, special classes wasn’t fun. For that reason, I made a generic one that lets you pass a scriptblock that lets you define how to transform the input while building your param() block:

Add-Type @'
    using System.Collections;    // Needed for IList
    using System.Management.Automation;
    using System.Collections.Generic;
    namespace Test {
        public sealed class TransformScriptAttribute : ArgumentTransformationAttribute {
            string _transformScript;
		    public TransformScriptAttribute(string transformScript) {
                _transformScript = string.Format(@"
                    # Assign $_ variable
                    $_ = $args[0]

                    # The return value of this needs to match the C# return type so no coercion happens
                    $FinalResult = New-Object System.Collections.ObjectModel.Collection[psobject]
                    $ScriptResult = {0}

                    # Add the result and output the collection
                    $FinalResult.Add((,$ScriptResult))
                    $FinalResult", transformScript);
            }

		    public override object Transform(EngineIntrinsics engineIntrinsics, object inputData) {
                var results = engineIntrinsics.InvokeCommand.InvokeScript(
                    _transformScript,
                    true,   // Run in its own scope
                    System.Management.Automation.Runspaces.PipelineResultTypes.None,  // Just return as PSObject collection
                    null,
                    inputData
                );
                if (results.Count > 0) {
                    return results[0].ImmediateBaseObject;
                }
                return inputData;  // No transformation
            }
	    }
    }
'@

# Equivalent PowerShell class version:
class PSTransformScriptAttribute : System.Management.Automation.ArgumentTransformationAttribute {

    PSTransformScriptAttribute([string] $ScriptBlock) {
        $this.ScriptBlock = [scriptblock]::Create(@"
`$_ = `$args[0]
$ScriptBlock
"@)
    }

    [scriptblock] $ScriptBlock

    [object] Transform([System.Management.Automation.EngineIntrinsics] $engineIntrinsics, [object] $inputData) {
        return & $this.ScriptBlock $inputData
    }
}

The important parts:

  • You need to provide a constructor so a script can be passed to the attribute
  • The C# version needs to use engineIntrinsics to invoke the script. The PowerShell class version doesn’t need this (even though it wouldn’t hurt to use it). To play around with the options for engineIntrinsics, you can use the $ExecutionContext automatic variable that’s available in your PowerShell session.

Those examples don’t do any error checking, so if an exception is thrown inside your script, it’s going to bubble up to the user of your function. You can add error handling to suppress those errors if you’d like.

You can add anything you want to the user-provided script. I automatically assign the $inputData contents to $_ so that you can use $_ in the attribute.

Let’s add some more dummy parameters to Test-Coercion to demo some simple examples of what’s possible with these attributes:

function Test-Coercion {
    [CmdletBinding()]
    param(
        [SpecialDateTime()]
        [datetime] $Date,
        [Test.TransformScript({
            $_ | foreach ToString | foreach ToUpper
        })]
        [string[]] $UpperCaseStrings,
        [PSTransformScript({
            $_ | foreach ToString | foreach ToUpper
        })]
        [string[]] $PsUpperCaseStrings,
        [Test.TransformScript({
            $_ | ConvertTo-Json
        })]
        [string] $JsonRepresentation,
        [PSTransformScript({
            $_ | ConvertTo-Json
        })]
        [string] $PSJsonRepresentation
    )

    $PSBoundParameters
}

And some examples of running it:


PS C:\> Test-Coercion -UpperCaseString some, strings, to, transform -PsUpperCaseStrings more, strings

Key                Value                         
---                -----                         
UpperCaseStrings   {SOME, STRINGS, TO, TRANSFORM}
PsUpperCaseStrings {MORE, STRINGS}               



PS C:\> Test-Coercion -JsonRepresentation @{Key1 = 'Value'; Key2 = 'Value2'}, @{Key3 = 'Value'} -PSJsonRepresentation (dir hklm:\ -ErrorAction SilentlyContinue | select Name, PSChildName) | ft -Wrap

Key                  Value                                                                                                                                                                                                                                       
---                  -----                                                                                                                                                                                                                                       
JsonRepresentation   [                                                                                                                                                                                                                                           
                         {                                                                                                                                                                                                                                       
                             "Key1":  "Value",                                                                                                                                                                                                                   
                             "Key2":  "Value2"                                                                                                                                                                                                                   
                         },                                                                                                                                                                                                                                      
                         {                                                                                                                                                                                                                                       
                             "Key3":  "Value"                                                                                                                                                                                                                    
                         }                                                                                                                                                                                                                                       
                     ]                                                                                                                                                                                                                                           
PSJsonRepresentation [                                                                                                                                                                                                                                           
                         {                                                                                                                                                                                                                                       
                             "Name":  "HKEY_LOCAL_MACHINE\\HARDWARE",                                                                                                                                                                                            
                             "PSChildName":  "HARDWARE"                                                                                                                                                                                                          
                         },                                                                                                                                                                                                                                      
                         {                                                                                                                                                                                                                                       
                             "Name":  "HKEY_LOCAL_MACHINE\\SAM",                                                                                                                                                                                                 
                             "PSChildName":  "SAM"                                                                                                                                                                                                               
                         },                                                                                                                                                                                                                                      
                         {                                                                                                                                                                                                                                       
                             "Name":  "HKEY_LOCAL_MACHINE\\SOFTWARE",                                                                                                                                                                                            
                             "PSChildName":  "SOFTWARE"                                                                                                                                                                                                          
                         },                                                                                                                                                                                                                                      
                         {                                                                                                                                                                                                                                       
                             "Name":  "HKEY_LOCAL_MACHINE\\SYSTEM",                                                                                                                                                                                              
                             "PSChildName":  "SYSTEM"                                                                                                                                                                                                            
                         }                                                                                                                                                                                                                                       
                     ]                                                                                                                                                                                                                                           

A note about scope

While the PowerShell class implementation of the generic script transform attribute above was much simpler to create and easier to follow than the C# version, it seems to have some problems when it comes to executing in the expected scope. Basically, I’ve had issues being able to use private module functions when these attributes are used to decorate public functions exported by a module. The C# version works fine, but the PowerShell version seems to use the wrong scope. That happens even if I use the $EngineIntrinsics value passed into Transform(). I’m hoping to dive a little deeper into this to figure out if this behavior is a bug, or if I’m just doing something wrong and/or misusing the classes (sounds like a potential blog post). For now, though, I’m going to recommend the C# [TransformScript()] version of the generic transform attribute.

Let’s wrap up with a few more self-contained examples.

Example: Friendly DateTime Strings

This is just a more fleshed out version of the first example above, along with an argument completer, all tucked away in a module. The helper function that understands the text can obviously be extended to work with even more types of words/phrases.

$DateTimeMod = New-Module -Name DateTime {
    function Test-DateTimeCompleter {
        param(
            [datetime]
            [Test.TransformScript({
                $_ | DateTimeConverter
            })]
            $DateTime1,
            [datetime[]]
            [Test.TransformScript({
                $_ | DateTimeConverter
            })]
            $DateTime2
        )

        $PSBoundParameters
    }
    Export-ModuleMember -Function Test-DateTimeCompleter

    function DateTimeConverter {

        [CmdletBinding(DefaultParameterSetName='NormalConversion')]
        param(
            [Parameter(ValueFromPipeline, Mandatory, Position=0, ParameterSetName='NormalConversion')]
            [AllowNull()]
            $InputObject,
            [Parameter(Mandatory, ParameterSetName='ArgumentCompleterMode')]
            [AllowEmptyString()]
            [string] $wordToComplete
        )

        begin {
            $RegexInfo = @{
                Intervals = echo Minute, Hour, Day, Week, Month, Year   # Regex would need to be redesigned if one of these can't be made plural with a simple 's' at the end
                Separators = echo \., \s, _
                Adverbs = echo Ago, FromNow
                GenerateRegex = {
                    $Definition = $RegexInfo
                    $Separator = '({0})?' -f ($Definition.Separators -join '|')   # ? makes separators optional
                    $Adverbs = '(?<adverb>{0})' -f ($Definition.Adverbs -join '|')
                    $Intervals = '((?<interval>{0})s?)' -f ($Definition.Intervals -join '|')
                    $Number = '(?<number>-?\d+)'

                    '^{0}{1}{2}{1}{3}$' -f $Number, $Separator, $Intervals, $Adverbs
                }
            }
            $DateTimeStringRegex = & $RegexInfo.GenerateRegex

            $DateTimeStringShortcuts = @{
                Now = { Get-Date }
                Today = { (Get-Date).ToShortDateString() }
                'This Month' = { $Now = Get-Date; Get-Date -Month $Now.Month -Day 1 -Year $Now.Year }
                'Last Month' = { $Now = Get-Date; (Get-Date -Month $Now.Month -Day 1 -Year $Now.Year).AddMonths(-1) }
                'Next Month' = { $Now = Get-Date; (Get-Date -Month $Now.Month -Day 1 -Year $Now.Year).AddMonths(1) }
            }
        }

        process {
            switch ($PSCmdlet.ParameterSetName) {

                NormalConversion {
                    foreach ($DateString in $InputObject) {

                        if ($DateString -as [datetime]) {
                            # No need to do any voodoo if it can already be coerced to a datetime
                            $DateString
                        }
                        elseif ($DateString -match $DateTimeStringRegex) {
                            $Multiplier = 1  # Only changed if 'week' is used
                            switch ($Matches.interval) {
                                <#                                     Allowed intervals: minute, hour, day, week, month, year                                     Of those, only 'week' doesn't have a method, so handle it special. The                                     others can be handled in the default{} case                                 #>

                                week {
                                    $Multiplier = 7
                                    $MethodName = 'AddDays'
                                }

                                default {
                                    $MethodName = "Add${_}s"
                                }

                            }

                            switch ($Matches.adverb) {
                                fromnow {
                                    # No change needed
                                }

                                ago {
                                    # Multiplier needs to be negated
                                    $Multiplier *= -1
                                }
                            }

                            try {
                                (Get-Date).$MethodName.Invoke($Multiplier * $matches.number)
                                continue
                            }
                            catch {
                                Write-Error $_
                                return
                            }
                        }
                        elseif ($DateTimeStringShortcuts.ContainsKey($DateString)) {
                            (& $DateTimeStringShortcuts[$DateString]) -as [datetime]
                            continue
                        }
                        else {
                            # Just return what was originally input; if this is used as an argument transformation, the binder will
                            # throw it's localized error message
                            $DateString
                        }
                    }

                }

                ArgumentCompleterMode {
                    $CompletionResults = New-Object System.Collections.Generic.List[System.Management.Automation.CompletionResult]

                    $DoQuotes = {
                        if ($args[0] -match '\s') {
                            "'{0}'" -f $args[0]
                        }
                        else {
                            $args[0]
                        }
                    }

                    # Check for any shortcut matches:
                    foreach ($Match in ($DateTimeStringShortcuts.Keys -like "*${wordToComplete}*")) {
                        $EvaluatedValue = & $DateTimeStringShortcuts[$Match]
                        $CompletionResults.Add((New-Object System.Management.Automation.CompletionResult (& $DoQuotes $Match), $Match, 'ParameterValue', "$Match [$EvaluatedValue]"))
                    }

                    # Check to see if they've typed anything that could resemble valid friedly text
                    if ($wordToComplete -match "^(-?\d+)(?<separator>$($RegexInfo.Separators -join '|'))?") {

                        $Length = $matches[1]
                        $Separator = " "
                        if ($matches.separator) {
                            $Separator = $matches.separator
                        }

                        $IntervalSuffix = 's'
                        if ($Length -eq '1') {
                            $IntervalSuffix = ''
                        }

                        foreach ($Interval in $RegexInfo.Intervals) {
                            foreach ($Adverb in $RegexInfo.Adverbs) {
                                $Text = "${Length}${Separator}${Interval}${IntervalSuffix}${Separator}${Adverb}"
                                if ($Text -like "*${wordToComplete}*") {
                                    $CompletionResults.Add((New-Object System.Management.Automation.CompletionResult (& $DoQuotes $Text), $Text, 'ParameterValue', $Text))
                                }
                            }
                        }
                    }

                    $CompletionResults
                }

                default {
                    # Shouldn't happen. Just don't return anything...
                }
            }
        }
    }

    Add-Type @'
        using System.Collections;    // Needed for IList
        using System.Management.Automation;
        using System.Collections.Generic;
        namespace Test {
            public sealed class TransformScriptAttribute : ArgumentTransformationAttribute {
                string _transformScript;
                public TransformScriptAttribute(string transformScript) {
                    _transformScript = string.Format(@"
                        # Assign $_ variable
                        $_ = $args[0]

                        # The return value of this needs to match the C# return type so no coercion happens
                        $FinalResult = New-Object System.Collections.ObjectModel.Collection[psobject]
                        $ScriptResult = {0}

                        # Add the result and output the collection
                        $FinalResult.Add((,$ScriptResult))
                        $FinalResult", transformScript);
                }

                public override object Transform(EngineIntrinsics engineIntrinsics, object inputData) {
                    var results = engineIntrinsics.InvokeCommand.InvokeScript(
                        _transformScript,
                        true,   // Run in its own scope
                        System.Management.Automation.Runspaces.PipelineResultTypes.None,  // Just return as PSObject collection
                        null,
                        inputData
                    );
                    if (results.Count > 0) {
                        return results[0].ImmediateBaseObject;
                    }
                    return inputData;  // No transformation
                }
            }
        }
'@

    echo DateTime1, DateTime2 | ForEach-Object {
        Register-ArgumentCompleter -CommandName Test-DateTimeCompleter -ParameterName $_ -ScriptBlock { DateTimeConverter -wordToComplete $args[2] }
    }
}

Run this and try it out. Here’s an example of something to try to get you started (you should get tab completion at this point, so press Ctrl+Space if you’re not in the ISE):


Test-DateTimeConverter -DateTime1 1.

Example: Shadow PSBoundParameters

OK, this is a trimmed down example of one of my favorite uses for this. Some background: I’ve got a module that I use to help build commands that build dynamic SQL queries using a DSL. When you describe a column, you provide a type for it, e.g., [string], [datetime], [int], etc, and a command is created that has parameters of those types that, when specified, end up modifying the command’s internal WHERE clause. If you call Get-Command, you see their real types, but you can pass $null or a hashtable to specify advanced per-parameter options, e.g., @{Value='String'; Negate=$true} (think about how Select-Object’s -Property parameter usually takes strings, but you can provide calculated properties). Obviously, I can just make all of those commands take [object[]] types, but I prefer to let the help system and IntelliSense notify the user of what’s normally expected, and if they are aware of the advanced options, they can optionally use the other syntax.

While this isn’t the exact code I use, the concept is the same. What this will do is create a hashtable in the function’s scope to put the ‘real’ value provided into a $ShadowPSBoundParameters variable that can be accessed inside the function. It does this by using Get-Variable and Set-Variable to look into the parent scope (if you use engineIntrinsics to call InvokeScript() without creating a new scope, then the scope number will be different). NOTE: I make no claims to whether or not this is a good idea, but I think it’s a cool example showing what’s possible:

$ShadowParamMod = New-Module -Name ShadowParamMod {
    function Test-ShadowParams {
        [CmdletBinding()]
        param(
            [Test.TransformScript({
                 PopulateShadowParams -InputObject $_ -ParameterName Date -DefaultValue (Get-Date)
            })]
            [datetime] $Date,
            [Parameter(ValueFromPipeline)]
            [Test.TransformScript({
                PopulateShadowParams -InputObject $_ -ParameterName Strings -DefaultValue ''
            })]
            [string[]] $Strings,
            [Test.TransformScript({
                PopulateShadowParams -InputObject $_ -ParameterName Int -DefaultValue 0
            })]
            [int] $Int
        )

        process {

            'Inside Process {} block:'
            foreach ($Key in $PSBoundParameters.Keys) {
                [PSCustomObject] @{
                    Parameter = $Key
                    PSBoundParamValue = $PSBoundParameters[$Key]
                    ShadowPsBoundParamValue = $ShadowPsBoundParameters[$Key]
                }
            }
        }
    }
    Export-ModuleMember -Function Test-ShadowParams

    function PopulateShadowParams {
    <# NOTE: This is assuming you're using a C# transformation attribute, and you pass $true to the
        InvokeScript() argument for running code in a new scope. If not, you need to change the
         -ScopeDepth default parameter, or modify the function to look for some sort of anchor to search
        for in parent scopes ($PSCmdlet would probably work)     #>

        [CmdletBinding()]
        param(
            [Parameter(Mandatory, ValueFromPipeline)]
            [object] $InputObject,
            [Parameter(Mandatory)]
            [string] $ParameterName,
            [Parameter(Mandatory)]
            [object] $DefaultValue,
            # Function can actually walk the scope chain to figure this out. Scopes:
            #   0 - This function's scope
            #   1 - The attribute's scope (assuming engineIntrinsics is using new scope)
            #   2 - The function's scope that owns this attribute's parameter
            $ScopeDepth = 2
        )

        begin {
            $ShadowTableName = 'ShadowPsBoundParameters'
        }
        process {
            $ParamHashTable = try {
                Get-Variable -Scope $ScopeDepth -Name $ShadowTableName -ValueOnly -ErrorAction Stop
            }
            catch {
                @{}
            }

            $ParamHashTable[$ParameterName] = $InputObject

            Set-Variable -Name $ShadowTableName -Value $ParamHashTable -Scope $ScopeDepth

            # This is so normal parameter binding will still work. If the parameter is the proper type,
            # $PSBoundParameters will reflect the right value. If it's not of the proper type,
            # $PSBoundParameters will show a "default" value, but the $ShadowPsBoundParameters hashtable
            # will show the right value
            if ($InputObject -is $DefaultValue.GetType()) {
                $InputObject
            }
            else {
                $DefaultValue
            }
        }
    }

    Add-Type @'
        using System.Collections;    // Needed for IList
        using System.Management.Automation;
        using System.Collections.Generic;
        namespace Test {
            public sealed class TransformScriptAttribute : ArgumentTransformationAttribute {
                string _transformScript;
                public TransformScriptAttribute(string transformScript) {
                    _transformScript = string.Format(@"
                        # Assign $_ variable
                        $_ = $args[0]

                        # The return value of this needs to match the C# return type so no coercion happens
                        $FinalResult = New-Object System.Collections.ObjectModel.Collection[psobject]
                        $ScriptResult = {0}

                        # Add the result and output the collection
                        $FinalResult.Add((,$ScriptResult))
                        $FinalResult", transformScript);
                }

                public override object Transform(EngineIntrinsics engineIntrinsics, object inputData) {
                    var results = engineIntrinsics.InvokeCommand.InvokeScript(
                        _transformScript,
                        true,   // Run in its own scope
                        System.Management.Automation.Runspaces.PipelineResultTypes.None,  // Just return as PSObject collection
                        null,
                        inputData
                    );
                    if (results.Count > 0) {
                        return results[0].ImmediateBaseObject;
                    }
                    return inputData;  // No transformation
                }
            }
        }
'@
}

PS C:\> 1..2 | Test-ShadowParams -Date today -Int @{Key = 'Value'}

Inside Process {} block:

Parameter PSBoundParamValue    ShadowPsBoundParamValue
--------- -----------------    -----------------------
Date      3/29/2017 2:53:44 PM today                  
Int       0                    {Key}                  
Strings   {}                   1                      

Inside Process {} block:
Date      3/29/2017 2:53:44 PM today                  
Int       0                    {Key}                  
Strings   {}                   2                      

In that example, we passed a [string] to the parameter that expected [datetime], a [hashtable] to the one that wanted an [int], and an [int] to the one that wanted a [string]. It’s confusing, but notice how the $ShadowPsBoundParameters shows the real, un-coerced values passed into the function. We made it past parameter binding with the raw values! That really has a ton of uses, even if this example doesn’t make it that obvious. To really use it, you would want to put some restrictions on it and not let just anything through like it currently does.

I’ll end it there, but feel free to leave a comment if you have questions.

Comments
  1. passenger says:

    Hi,
    ‘About Scope’, may be use SessionState :
    $PipelineObjectInScopeOfCaller=$PSCmdlet.SessionState.PSVariable.Get(“_”).Value

    • Rohn Edwards says:

      The issue I’m seeing is I can’t use the default SessionState assigned to the scriptblock that’s executed during Transform(), and I can’t use the SessionState that’s in the $EngineIntrinsics argument that’s passed (they’re the same). Those SessionStates aren’t the same as from inside the module (I only have this problem when trying to use a PS class ArgumentTransformationAttribute on a function parameter that’s inside a module where you need to execute helper commands that are internally scoped to the module).

      So I’m not having any issue with the ‘_’ variable, but you’re still right that the $PSCmdlet.SessionState seems to solve my problem (assuming the attribute is used in an advanced function). That does appear to give me access to the function’s internal module scope via InvokeCommand. I’m going to mess with it some more tonight, and I’ll probably add a note to that section mentioning your suggestion, along with a new example of the PS class.

      Thanks for pointing me in the right direction!

      • Rohn Edwards says:

        Forgot to reply to this again last week, but I think I figured out the problem. First, everything seemed to work at first because I was on a computer with PS 5.0. Sometime around 5.1 (and this is in 6.0, too), classes started to have the SessionState that was effective when the type was created saved, and that SessionState is used when the class methods are invoked. This is so that you can use those classes anywhere, and have access to module scoped data.

        In my examples, I was using New-Module to try to hide some helper functions, and I was avoiding Import-Module because I didn’t want to have anyone working through the examples to have to save code to disk. New-Module seems to use the global SessionState, and I think it’s because the ScriptBlock argument gets compiled before being passed into New-Module, and so the global SessionState is the effective one when the type gets created.

        If I save the code out and use Import-Module, it looks like the captured SessionState is correct, and the classes defined in the module have access to module scoped information.

        So, don’t use New-Module if you define classes that need access to module scoped data in PS 5.1 and 6.0 (at least up to 6.0.0.17).

  2. […] on March 29, 2017 submitted by /u/rschiefer [link] [comments] Leave a […]

Leave a comment