A fundamental best practice for any programming or scripting language is do not trust your input parameters, always validate data users (but also other pieces of automation) pass into your program. It is easy it imagine how things can go badly wrong when a user by mistake passes a string where you are expecting an integer, or an array in place of a boolean, not to mention the security implications (and potential disaster) of, for example, accepting and running things such as a sql command or other shell commands malicious users may try to use to exploit your system.
In Powershell we can use Parameter Validation attributes to check the format or the type of an input parameter, or check for null or empty strings, or that the passed value falls within a certain range, or force the user to pass only a value selected from a restricted list.
This last type is called ValildateSet and allows the script author to decide the list of values the user can chose from and have Powershell throws an error if this constraint is not respected. I used it often in my scripts and modules, this is how a very simple script looks like:
[CmdletBinding()]
param (
[parameter()]
[ValidateSet('Arg1', 'Arg2', 'Arg3')]
[string]$Parameter
)
Write-Host "`$Parameter value is $Parameter"
The script has only one parameter and accepts only one value chosen from the list “Arg1”, “Arg2”, “Arg3”; if I run the script passing one of the expected values I get this:
PS > ./parameterSet.ps1 -Parameter
Arg2 $Parameter value is Arg2
If I pass a value that is not part of the ValidateSet, Powershell returns an error:
PS > ./parameterSet.ps1 -Parameter somethingElse
./parameterSet.ps1 : Cannot validate argument on parameter 'Parameter'. The argument "somethingElse" does not belong to the set "Arg1,Arg2,Arg3" specified by the ValidateSet attribute. Supply an argument that is in the set and then try the command again. At line:1 char:35 + ./parameterSet.ps1 -Parameter somethingElse + ~~~~~~~~~~~~~ + CategoryInfo : InvalidData: (:) [parameterSet.ps1], ParameterBindingValidationException + FullyQualifiedErrorId : ParameterArgumentValidationError,dynamicParameter.ps1
All good and expected but what happens if the list of acceptable values cannot be pre-determined, or if I need a different list depending on the value of some other parameter?
Even though not very elegant, the latter could be resolved using different ParameterSets but this cannot help if I do not know the list of acceptable values in advance. Let’s make a concrete example:
[CmdletBinding()]
param (
[parameter()]
[string]$FolderPath,
[parameter()]
[string]$FileName
)
Write-Host "Folder Selected $FolderPath"
Write-Host "You have selected the file $FileName"
What if I want the user to be able to select a list of file names contained in the folder passed as FolderPath? I cannot know in advance which folder the user will pass, therefore I cannot hardcode the list of file names in a ValidateSet. The example may seem far fetched but I actually used this approach in a few scripts, for example to submit special deployments and I want to have control on the files passed as parameters, or in scripts where one of the parameters is an xml file and the second parameter must be a value retrieved from within the same xml file.
Dynamic Parameters can help do exactly that:
Dynamic parameters are parameters of a cmdlet, function, or script that are available only under certain conditions.
For example, several provider cmdlets have parameters that are available only when the cmdlet is used in the provider drive, or in a particular path of the provider drive. For example, theEncoding
parameter is available on theAdd-Content
,Get-Content
, andSet-Content
cmdlets only when it is used in a file system drive.
You can also create a parameter that appears only when another parameter is used in the function command or when another parameter has a certain value.
So, with a carefully coded Dynamic Parameter we can add a ValidateSet attribute to the $FileName parameter, like this:
[CmdletBinding()] param ( [parameter()] [ValidateScript( { Test-Path -Path $_ })] [string]$FolderPath ) DynamicParam { if (Test-Path -Path $FolderPath) { $parameterName = 'FileName' $runtimeParameterDictionary = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary $attributeCollection = New-Object System.Collections.ObjectModel.Collection[System.Attribute] $parameterAttribute = New-Object System.Management.Automation.ParameterAttribute $attributeCollection.Add($parameterAttribute) $arrSet = Get-ChildItem -Path "$FolderPath/*.*" | Select-Object -ExpandProperty 'Name' $ValidateSetAttribute = New-Object System.Management.Automation.ValidateSetAttribute($arrSet) $attributeCollection.Add($ValidateSetAttribute) $RuntimeParameter = New-Object System.Management.Automation.RuntimeDefinedParameter($parameterName, [string[]], $AttributeCollection) $runtimeParameterDictionary.Add($parameterName, $RuntimeParameter) return $runtimeParameterDictionary } } process { Write-Host "Folder Selected $FolderPath" Write-Host "You have selected the file $($MyInvocation.BoundParameters["FileName"])" }
Notice the DynamicParameter block, it may seem complex but let’s break it down:
- Line 9: first of all I’m checking if $FolderPath is a valid folder
- Line 10: if $FolderPath is a valid then I define the parameter name (in this case, FileName)
- Lines 11-14:
- create a new RuntimeDefinedParameterDictionary (the object that will eventually hold the parameter values)
- create a new Collection to define the attribute to assign to the new parameter
- define the attribute to assign to the new parameter
- add the ParameterAttribute to the Collection I just created
- Line 16: this is where a lot of the magic happens. This is a very simple example so here I’m just retrieving the list of file names under $FolderPath, but this logic can be as complex as you need it to be. Note: be aware of the performance implications though, continue reading for more info
- Lines 17-18: since I want the new parameter to have a ValidateSet attribute, this is where it gets defined
- Line 21: this creates the actual parameter object and define its properties:
- parameter name
- parameter type
- parameter attributes
- Lines 22-24: finally, add the newly created parameter to the dictionary object and return it
- Line 31: this notation is not very common but in this case it is needed. Powershell does not really know about the $FileName parameter until the script is actually executed, only at that point its value is added to the BoundParameters dictionary
Let’s test it with a very simple folder structure:
PS /Users/carlo/Temp> Get-ChildItem -Recurse
Directory: /Users/carlo/Temp
Mode LastWriteTime Length Name
---- ------------- ------ ----
d----- 4/17/19 1:57 PM dynamicParameter
------ 4/17/19 3:59 PM 1272 dynamicParameter.ps1
Directory: /Users/carlo/Temp/dynamicParameter
Mode LastWriteTime Length Name
---- ------------- ------ ----
d----- 4/17/19 12:18 PM folder1
d----- 4/17/19 12:18 PM folder2
d----- 4/17/19 12:18 PM folder3
d----- 4/17/19 12:18 PM folder4
d----- 4/17/19 12:18 PM folder5
------ 4/17/19 1:57 PM 0 file1.txt
------ 4/17/19 1:57 PM 0 file2.txt
------ 4/17/19 1:57 PM 0 file3.txt
------ 4/17/19 1:57 PM 0 file4.txt
------ 4/17/19 1:57 PM 0 file5.txt
Notice the dynamicParameter folder contains both files and subfolders but I have defined my ValidateSet to return only files. If I run the script passing the “dynamicParameter” folder as -FolderPath and then use the TAB key to cycle through the possible autocompletion values, this is what I get:
PS /Users/carlo/Temp> ./dynamicParameter.ps1 -FolderPath ./dynamicParameter -FileName
file1.txt file2.txt file3.txt file4.txt file5.txt
As you can see, -FileName autocompletion proposes only the file names available under the /dynamicParameter folder. Here’s another example:
PS /Users/carlo/Temp> dir /tmp/test
Directory: /tmp/test
Mode LastWriteTime Length Name
---- ------------- ------ ----
------ 4/17/19 9:45 AM 0 08c446c5-8930-4907-a5e8-2460fb95e1a7.txt
------ 4/17/19 9:45 AM 0 1777f3fb-e30c-49df-bc4b-43d73e639c5e.txt
------ 4/17/19 9:45 AM 0 2fb7d190-2a8b-4c36-bd3a-53dc1518e4a6.txt
------ 4/17/19 9:45 AM 0 3c9fdd3c-e778-4b0d-8f31-119810250980.txt
------ 4/17/19 9:45 AM 0 485ba88e-a042-4a41-a798-8639191e3d7a.txt
------ 4/17/19 9:45 AM 0 5f5bbe43-b1df-4575-b8c7-aa8580bc1fe0.txt
------ 4/17/19 9:45 AM 0 a3f06130-0482-4e86-9d57-998ee153646b.txt
------ 4/17/19 9:45 AM 0 a735a5e3-4c0e-4363-a4e7-79c0cc7b6fb9.txt
------ 4/17/19 9:45 AM 0 a8896609-2719-477c-b832-23020439b710.txt
PS /Users/carlo/Temp> ./dynamicParameter.ps1 -FolderPath /tmp/test/ -FileName
08c446c5-8930-4907-a5e8-2460fb95e1a7.txt 3c9fdd3c-e778-4b0d-8f31-119810250980.txt a3f06130-0482-4e86-9d57-998ee153646b.txt
1777f3fb-e30c-49df-bc4b-43d73e639c5e.txt 485ba88e-a042-4a41-a798-8639191e3d7a.txt a735a5e3-4c0e-4363-a4e7-79c0cc7b6fb9.txt
2fb7d190-2a8b-4c36-bd3a-53dc1518e4a6.txt 5f5bbe43-b1df-4575-b8c7-aa8580bc1fe0.txt a8896609-2719-477c-b832-23020439b710.txt
Performance consideration
Performance is always very important for any application and script and this case is no exception. While my example above is very simple, the logic to build the parameter can be very complex (there is no inherent check blocking it), you need to be careful and consider how the script or module will be used at runtime.
The Dynamic Parameter block is executed every time the user interacts with its parameter: for example at the console I would type -Fil and hit TAB to autocomplete the parameter name. Right when I hit TAB the Dynamic Parameter block is executed. When I then try to cycle through the possible values the parameter accepts, the Dynamic Parameter block is executed again. Finally, when I’m ready to run the command and hit ENTER, the Dynamic Parameter block is executed once more. Here’s a quick video to show the behavior:
Here I first show how the Dynamic Parameter behaves in a normal execution, then I add a dummy while loop to simulate a long running operation: even a simple 5 seconds delay is noticeable and the prompt is temporarily blocked. It is easy to understand that if the logic here is too complex or takes too much time to return (imagine I was to return a list of records from a database or a list or resources from an Azure Subscription) the user experience will definitely be impacted, the script will feel unresponsive and will lead to certain frustration.
Talent hits a target no one else can hit; Genius hits a target no one else can see. – Arthur Schopenhauer |
2 Comments
Gerhard Brueckl
Hi,
very informative post – I just added dynamic parameters also to my cmdlets.
I further advanced this technique to use caching for all dynamic parameters to further speed this up
However, it feels like also the autocomplete for the cmdlets/functions is slowed down. Is that possible according to your experience?
kind regards,
-gerhard
carloc
Hey Gerhard, the DynamicParameter block is called multiple times during parameter parsing (even when you are typing other parameters, not only the dynamic one) therefore if the logic is complex it may slow down the overall autocompletion for that particular cmdlet