Dynamic Parameter Validation - Part 2
Last time I demonstrated a technique you can use in PowerShell 7 as an alternative to the static [ValidateSet()]
parameter validation attribute. There is an alternative that comes close to the same functionality and it will also work in Windows PowerShell. You'll have to decide if it is appropriate for your situation.
Using an Enum
As I mentioned last time, [ValidateSet([class])]
technique requires PowerShell 7. As an alternative for Windows PowerShell, you can use an enumeration
. An enumeration is a programmatic way of defining a set of values. This is a common thing in PowerShell. The values for Write-Host
colors are defined in a [ConsoleColor]
enumeration.
PS C:\> [enum]::GetValues("ConsoleColor")
Black
DarkBlue
DarkGreen
DarkCyan
DarkRed
DarkMagenta
DarkYellow
Gray
DarkGray
Blue
Green
Cyan
Red
Magenta
Yellow
White
Here is the same example using an enumeration for the FileExtension
class I demonstrated last time.
enum FileExtension {
ps1
ps1xml
txt
json
xml
yml
zip
md
csv
}
You can enter this directly in your PowerShell session if you want to test. I can use my Get-TypeMember
function from the PSScriptTools module to get the values.
PS C:\> Get-TypeMember FileExtension
Type: FileExtension
Name MemberType ResultType IsStatic IsEnum
---- ---------- ---------- -------- ------
csv Field FileExtension True
json Field FileExtension True
md Field FileExtension True
ps1 Field FileExtension True
ps1xml Field FileExtension True
txt Field FileExtension True
xml Field FileExtension True
yml Field FileExtension True
zip Field FileExtension True
GetType Method Type
HasFlag Method Boolean
ToString Method String
This approach using an enum will also work in PowerShell 7. In your function, set the parameter type to the enumeration.
[FileExtension]$Extension = 'ps1'
> Make sure any default values are defined in the enumeration.
Here's the revised function using the enumeration.
Function Measure-FileExtension {
[CmdletBinding()]
param(
[Parameter(HelpMessage = 'Enter the path to analyze')]
[string]$Path = '.',
[ValidateNotNullOrEmpty()]
[FileExtension]$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
}
}
We won't use ValidateSet
in this situation. The enumeration will provide the tab-completion and validation. If the user enters a value not defined in the enum, PowerShell will throw an error.
The enum is a static list of values. If I want to change values, I have to edit the code and have the user reload everything.
A Dynamic Enum Option
That said, I have a hack that skirts this limitation. Instead of writing the enum in the script, I can create a C# enum definition and add it to the PowerShell session.
I'm going to revisit my Measure-FileExtension
function. At the beginning of my script file which will eventually be dot-sourced, I'll use code like this:
# Step 1: Define your list of values
$list = Get-Content $PSScriptRoot\ExtensionList.txt
# Step 2: Create the Enum definition string
$enumDefinition = 'public enum FileExtension {'
foreach ($item in $list) {
$enumDefinition += "$item,"
}
# Remove the trailing comma and close the enum definition
$enumDefinition = $enumDefinition.TrimEnd(',') + '}'
# Step 3: Use Add-Type to create the Enum
#You will get an error if you try to dot-source this again in the same session
#AND the enum has changed.
Try {
Add-Type -TypeDefinition $enumDefinition -ErrorAction Stop
}
Catch {
Write-Warning 'The [FileExtension] enum already exists and cannot be updated. Please re-run this script in a new PowerShell session.'
}
The code is creating a text block that defines the enumeration. The values are pulled from the text list. After Step 2, $enumDefinition
will look like this:
public enum FileExtension {ps1,ps1xml,txt,json,xml,yml,zip,md,csv}
Step 3 adds the enumeration to the session using Add-Type
. Due to the way .NET works, you'll get an error if you re-run the code with different values. .NET can't overwrite an existing enum. You'll need to start a new PowerShell session if there is a change.
The script file uses the enum as the type for the parameter.
Function Measure-FileExtension {
[CmdletBinding()]
Param(
[Parameter(HelpMessage = 'Enter the path to analyze')]
[string]$Path = '.',
[ValidateNotNullOrEmpty()]
[FileExtension]$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 end result is the same as using a static enum. This approach has a little more flexibility and I like that it separates the coded to define the enum from the enum data.
Using Type
There is another approach that also provides similar functionality as [ValidateSet()]
. You are hopefully defining function parameters as types like [String]
or [Int32]
. But you might want to consider other types based on your function.
Function Get-ServiceStatus {
[cmdletbinding()]
Param(
[Parameter(Position = 0, Mandatory)]
[ArgumentCompleter({(Get-Service).Name })]
[ValidateNotNullOrEmpty()]
[ValidateScript({ $Null -NE $_.Name }, ErrorMessage = 'The specified service not found on this system.')]
[System.ServiceProcess.ServiceController]$Service
)
$cimSplat = @{
ClassName = 'Win32_Service'
Filter = "Name='$($Service.name)'"
Property = 'ProcessId', 'StartName', 'Description', 'PathName'
}
$cimInfo = Get-CimInstance @cimSplat
[PSCustomObject]@{
Name = $Service.Name
DisplayName = $Service.DisplayName
Status = $Service.Status
StartType = $Service.StartType
ProcessID = $cimInfo.ProcessId
AccountName = $cimInfo.StartName
Description = $cimInfo.Description
Path = $cimInfo.PathName
Computername = $env:COMPUTERNAME
}
}
Notice the parameter definition.
[System.ServiceProcess.ServiceController]$Service
Instead of making it a string and then getting the service object in the function, I'm letting the user specify a service object. Not every class will work this way. The user still has to type something so you need to make sure the type can create an instance of the object from the name. Run code like this to test:
PS C:\> [System.ServiceProcess.ServiceController]"bits"
Status Name DisplayName
------ ---- -----------
Stopped bits Background Intelligent Transfer Servi…
PS C:\> "bits" -as [System.ServiceProcess.ServiceController]
Status Name DisplayName
------ ---- -----------
Stopped bits Background Intelligent Transfer Servi…
Running the command will give me tab-completion for the Service
parameter. I can test by starting the command and then pressing Ctrl+Space
.
PS C:\> Get-ServiceStatus -Service
Display all 335 possibilities? (y or n) _
The user can type the name of a service and the function will create the object.
PS C:\> Get-ServiceStatus -Service wsearch
Name : wsearch
DisplayName : Windows Search
Status : Running
StartType : Automatic
ProcessID : 15572
AccountName : LocalSystem
Description : Provides content indexing, property caching, and search results
for files, e-mail, and other content.
Path : C:\WINDOWS\system32\SearchIndexer.exe /Embedding
Computername : JEFFDESK
Notice that I have additional parameter validation.
[ValidateNotNullOrEmpty()]
[ValidateScript({ $Null -NE $_.Name }, ErrorMessage = 'The specified service not found on this system.')]
The [ValidateScript()]
attribute is using the PowerShell feature for custom error messages. If the user enters an invalid service name, the service object Name property will be $Null
and the script block will return $False
. The error message will be displayed.
Here's one more example function.
Function Get-EventLogInfo {
[cmdletbinding()]
Param(
[Parameter(Position = 0, Mandatory)]
[ValidateNotNullOrEmpty()]
[System.Diagnostics.Eventing.Reader.EventLogConfiguration]$EventLogName
)
$LogPath = $EventLogName.LogFilePath.Replace('%SystemRoot%', $env:SystemRoot)
$Size = (Get-Item $LogPath).Size
[PSCustomObject]@{
PSTypeName = 'EventLogInfo'
Name = $EventLogName.LogName
LogType = $EventLogName.LogType
IsClassicLog = $EventLogName.IsClassicLog
Mode = $eventLogName.LogMode
Path = $LogPath
Size = $Size
MaximumSize = $EventLogName.MaximumSizeInBytes
Utilization = [math]::Round(($Size / $EventLogName.MaximumSizeInBytes) * 100, 2)
Computername = $env:COMPUTERNAME
}
}
This works when I know the event log name.
PS C:\> Get-EventLogInfo Microsoft-windows-backup
Name : Microsoft-windows-backup
LogType : Operational
IsClassicLog : False
Mode : Circular
Path : C:\WINDOWS\System32\Winevt\Logs\Microsoft-Windows-Backup.evtx
Size : 69632
MaximumSize : 1052672
Utilization : 6.61
Computername : JEFFDESK
If you noticed, I removed the [ValidateScript()]
attribute. Unlike the service object which will create an empty object if the name is invalid, the event log object will throw an error if the name is invalid.
PS C:\> Get-EventLogInfo foo
Get-EventLogInfo: Cannot process argument transformation on parameter 'EventLogName'. Cannot convert value "foo" to type "System.Diagnostics.Eventing.Reader.EventLogConfiguration". Error: "The specified channel could not be found."
The type name handles the validation.
Don't equate validation with parameter completion. This type name doesn't provide tab-completion like the service type. If I want to provide tab-completion, I'll need to define it.
Param(
[Parameter(Position = 0, Mandatory)]
[ArgumentCompleter({(Get-WinEvent -ListLog * -ErrorAction SilentlyContinue).LogName})]
[ValidateNotNullOrEmpty()]
[System.Diagnostics.Eventing.Reader.EventLogConfiguration]$EventLogName
)
There is other argument completer syntax you could use but I don't want to go down that rabbit hold now. This gives me tab-complete and parameter validation.
Summary
As I mentioned last time, these techniques aren't for every function.But they can provide the flexibility when you need it and make your commands easy to use.
I look forward to your comments and questions.