Dynamic Parameter Validation
I want to continue exploring parameter validation with a new technique introduced in PowerShell 7. If you are still on Windows PowerShell, don't run away. I'll also share an alternative approach that works in both versions of PowerShell.
Traditional Validation with Sets
One of the parameter validations I covered recently is [ValidateSet()]
. This is an easy way to ensure the user enter's a value from a pre-defined set of values. Here is an example:
Function Measure-FileExtension {
[CmdletBinding()]
param(
[Parameter(HelpMessage = 'Enter the path to analyze')]
[string]$Path = '.',
[ValidateSet('ps1', 'ps1xml', 'txt', 'json', 'xml')]
[string]$Extension = 'ps1',
[switch]$Recurse
)
$stats = Get-ChildItem -File -Path $Path -Filter "*.$Extension" -Recurse:$Recurse |
Measure-Object -Property Length -Sum -Average -Maximum -Minimum
[PSCustomObject]@{
PSTypeName = 'FileExtensionStats'
Path = Convert-Path $Path
Extension = $Extension
Files = $stats.Count
TotalSize = $stats.Sum
Average = $stats.Average
}
}
I want to ensure that the user enters a valid file extension from the list. If they don't, PowerShell will throw an error. As a bonus, the user gets tab completion for the valid values.
The example is a complete and working function if you want to try it out.
You can use [ValidateSet()]
in Windows PowerShell and PowerShell 7. However, there is one limitation: the values are static. You must enter the values directly in the attribute. What if you want something more dynamic?
System.Management.Automation.IValidateSetValuesGenerator
This is where a new feature comes into the picture. You can define a PowerShell class that can dynamically populate the validation values. You will create a class that is inherited from System.Management.Automation.IValidateSetValuesGenerator
. This class has a GetValidValues()
method that will define the valid values for the parameter.
Here is an based on my previous example:
Class FileExtension : System.Management.Automation.IValidateSetValuesGenerator {
#there no class properties
#the GetValidValues method has no parameters
[string[]] GetValidValues() {
#the script block contains the code to generate the valid values
#I'm defining the array of valid values here
$FileExtension = @('ps1', 'ps1xml', 'txt', 'json', 'xml', 'yml', 'zip', 'md', 'csv')
#you must use the return keyword
return [string[]] $FileExtension
}
}
The name of my class is FileExtension
. The colon indicates that it inherits settings from the System.Management.Automation.IValidateSetValuesGenerator
class. The class has a single inherited method GetValidValues()
. The method will return an array of strings. The code in the script block is defining those values. This is not an enumeration, it is an object class with a method.
I can validate this in PowerShell.
PS C:\> $fe = [FileExtension]::new()
PS C:\> $fe.GetValidValues()
ps1
ps1xml
txt
json
xml
yml
zip
md
csv
If I want to change the values, I can revise the class and reload it into my session.
Using the Custom Class
To use the class, use it in the ValidateSet
attribute.
[ValidateSet([FileExtension])]
Here's the complete revised function.
Function Measure-FileExtension {
[CmdletBinding()]
param(
[Parameter(HelpMessage = 'Enter the path to analyze')]
[string]$Path = '.',
[ValidateSet([FileExtension])]
[string]$Extension = 'ps1',
[switch]$Recurse
)
$stats = Get-ChildItem -File -Path $Path -Filter "*.$Extension" -Recurse:$Recurse |
Measure-Object -Property Length -Sum -Average -Maximum -Minimum
[PSCustomObject]@{
PSTypeName = 'FileExtensionStats'
Path = Convert-Path $Path
Extension = $Extension
Files = $stats.Count
TotalSize = $stats.Sum
Average = $stats.Average
}
}
If you want to test, you'll need to make sure you have the FileExtension
class loaded in your session. You can copy and paste the class definition into your session or save it to a file and dot-source it.
I get the same tab completion as before, but this time the value are coming from the class.
Going Dynamic
Here's the fun part. The validation attribute invokes the GetValidValues()
method every time you run the command. Instead of hardcoding the values, you can make the class more dynamic.
Class FileExtension : System.Management.Automation.IValidateSetValuesGenerator {
[string[]] GetValidValues() {
$FileExtension = Get-Content c:\scripts\ExtensionList.txt
return [string[]] $FileExtension
}
}
Now, I'm getting the values from a text file. If I update the file between running the command, the new values will be available the next time I run the command. I'll put the file and class definition in a script file.
#requires -version 7.4
#Measure-FileExtension.ps1
Class FileExtension : System.Management.Automation.IValidateSetValuesGenerator {
#no properties
[string[]] GetValidValues() {
#the text file must be in the same directory as the script
$FileExtension = Get-Content $PSScriptRoot\ExtensionList.txt
return [string[]] $FileExtension
}
}
Function Measure-FileExtension {
[CmdletBinding()]
param(
[Parameter(HelpMessage = 'Enter the path to analyze')]
[string]$Path = '.',
[ValidateSet([FileExtension])]
[string]$Extension = 'ps1',
[switch]$Recurse
)
$stats = Get-ChildItem -File -Path $Path -Filter "*.$Extension" -Recurse:$Recurse |
Measure-Object -Property Length -Sum -Average -Maximum -Minimum
[PSCustomObject]@{
PSTypeName = 'FileExtensionStats'
Path = Convert-Path $Path
Extension = $Extension
Files = $stats.Count
TotalSize = $stats.Sum
Average = $stats.Average
}
}
The text file needs to be in the same directory. Dot source the script file. If I change the text file, I'll get the new values the next time I run the command. Are you beginning to see the possibilities?
Here's another example to re-enforce the concept.
Class ProcessName : System.Management.Automation.IValidateSetValuesGenerator {
[string[]] GetValidValues() {
$Names = (Get-Process | Select-Object -Property name -Unique).Name
return [string[]] $Names
}
}
Can you determine what values this class will return?
PS C:\> $p = [ProcessName]::new()
PS C:\> $p.GetValidValues() | Select-Object -First 5
1Password
1Password-BrowserSupport
AggregatorHost
ApplicationFrameHost
backgroundTaskHost
Here's a complete script file you can test.
#requires -version 7.4
#Measure-Process.ps1
Class ProcessName : System.Management.Automation.IValidateSetValuesGenerator {
#no properties
[string[]] GetValidValues() {
$Names = (Get-Process | Select-Object -Property name -Unique).Name
return [string[]] $Names
}
}
Function Measure-Process {
[CmdletBinding()]
param(
[Parameter(Position = 0,HelpMessage = 'Enter the process name')]
[ValidateSet([ProcessName])]
[string]$Name
)
$m = Get-Process -Name $Name | Measure-Object -Property WS, NPM, PM, VM -Sum
[PSCustomObject]@{
PSTypeName = 'ProcessMemory'
Name = $Name
Count = $m.Count
WorkingSet = $m.where({ $_.property -eq 'WS' }).sum
NonPagedMemory = $m.where({ $_.property -eq 'NPM' }).sum
PagedMemory = $m.where({ $_.property -eq 'PM' }).sum
VirtualMemory = $m.where({ $_.property -eq 'VM' }).sum
Computername = $env:COMPUTERNAME
}
}
My recommendation is that whatever code you use to get the values should run very quickly. I would not try to enumerate thousands of Active Directory organizational unit names by querying a domain controller.
Summary
I think I've given you enough to think about and test. Don't feel you have to use what I've shown you if a traditional [ValidateSet()]
attribute gets the job done. However, it is nice to know there are other options available to you.
I still want show you an alternative for Windows PowerShell but I'll save that for next time.