Dynamic parameters, ValidateSet and Enums

Good intentions often get muddled with very complex execution. The last time the government tried to make taxes easier, it created a 1040 EZ form with a 52-page help booklet.
— Brad D. Smith

I suppose that many of you have heard about Dynamic Parameters, but thought of them as too complicated to implement in real-life scenarios. Just look at the amount of code you have to write to add one simple parameter with dynamic ValidateSet argument.

Recently I had to write a fair amount of functions which use Enum‘s values as parameters (Special Folders, Access Rights, etc). Naturally, I’d like to have this parameters validated with ValidateSet and have tab-completion as a bonus. But this means to hardcode every enum’s member name in the ValidateSet argument. Today’s example is a function, that returns Special Folder path. It accepts one parameter Name, validates it values against all known folders names and returns filesystem paths. Here is how it looks with hardcoded ValidateSet:

function Get-SpecialFolderPath
{
    [CmdletBinding()]
    Param
    (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [ValidateSet(
            'Desktop', 'Programs', 'MyDocuments', 'Personal', 'Favorites', 'Startup', 'Recent', 'SendTo',
            'StartMenu', 'MyMusic', 'MyVideos', 'DesktopDirectory', 'MyComputer', 'NetworkShortcuts', 'Fonts',
            'Templates', 'CommonStartMenu', 'CommonPrograms', 'CommonStartup', 'CommonDesktopDirectory',
            'ApplicationData', 'PrinterShortcuts', 'LocalApplicationData', 'InternetCache', 'Cookies', 'History',
            'CommonApplicationData', 'Windows', 'System', 'ProgramFiles', 'MyPictures', 'UserProfile', 'SystemX86',
            'ProgramFilesX86', 'CommonProgramFiles', 'CommonProgramFilesX86', 'CommonTemplates', 'CommonDocuments',
            'CommonAdminTools', 'AdminTools', 'CommonMusic', 'CommonPictures', 'CommonVideos', 'Resources',
            'LocalizedResources', 'CommonOemLinks', 'CDBurning'
        )]
        [array]$Name
    )

    Process
    {
        $Name | ForEach-Object { [Environment]::GetFolderPath($_) }
    }
}

Not fancy, to say the least.


Sidenote: if you wonder, did I typed all this ValidateSet argument, the answer is no. Here is trick that I’ve used to get all enum’s members strings enclosed in single quotes and comma-separated. Just copy and paste this snippet to the PowerShell console and get formatted enum list in your clipboard:

PS C:\Users\beatcracker> "'$([Enum]::GetNames('System.Environment+SpecialFolder') -join "', '")'" | clip

As you see, the ValidateSet above is as bad as you can get: it’s large, it’s easy to make typo and it’s hardcoded. Whenever the new special folder is added to the Windows or it doesn’t exists in previous versions of OS this code will fail.

Let’s try to remedy this by using Dynamic Parameters. The following example is based on aforementioned TechNet article Dynamic ValidateSet in a Dynamic Parameter.

function Get-SpecialFolderPath
{
    [CmdletBinding()]
    Param()
    DynamicParam 
    {
        # Set the dynamic parameters name
        $ParameterName = 'Name'

        # Create the dictionary
        $RuntimeParameterDictionary = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary

        # Create the collection of attributes
        $AttributeCollection = New-Object System.Collections.ObjectModel.Collection[System.Attribute]

        # Create and set the parameters' attributes
        $ParameterAttribute = New-Object System.Management.Automation.ParameterAttribute
        $ParameterAttribute.ValueFromPipeline = $true
        $ParameterAttribute.ValueFromPipelineByPropertyName = $true
        $ParameterAttribute.Mandatory = $true
        $ParameterAttribute.Position = 0

        # Add the attributes to the attributes collection
        $AttributeCollection.Add($ParameterAttribute)

        # Generate and set the ValidateSet
        $arrSet = [Enum]::GetNames('System.Environment+SpecialFolder')
        $ValidateSetAttribute = New-Object System.Management.Automation.ValidateSetAttribute($arrSet)

        # Add the ValidateSet to the attributes collection
        $AttributeCollection.Add($ValidateSetAttribute)

        # Create and return the dynamic parameter
        $RuntimeParameter = New-Object System.Management.Automation.RuntimeDefinedParameter($ParameterName, [string], $AttributeCollection)
        $RuntimeParameterDictionary.Add($ParameterName, $RuntimeParameter)
        return $RuntimeParameterDictionary
    }

    Begin
    {
        # Bind the parameter to a friendly variable
        $Name = $PsBoundParameters[$ParameterName]
    }

    Process
    {
        $Name | ForEach-Object { [Environment]::GetFolderPath($_) }
    }
}

This version of function definitely better than the first one: no hardcoded values and it wouldn’t break on different versions of Windows. But it still feels clumsy to me and my inner perfectionist. The whole DynamicParam block is dedicated to the single parameter and modifying it to suit your needs may be a job worth a day.

What if you’d like to define multiple parameters dynamically, with different arguments (Mandatory, ValueFromPipeline, etc), belonging to different parameter sets and so on. Moreover, you’d like to do it in fast and efficient way.

The solution? Dynamically create dynamic parameters! Luckily, I was not a first preson to think about this and there are folks who have done tremendous job already: Justin Rich (blog, GitHub) and Warren F. (blog, GitHub):

So all I had to do is to improve their work a little. I’ve took a liberty to extend Warren’s New-DynamicParam function to support full range of attributes and made a recreation of the variables from the bound parameters a bit easier:

It drastically reduces the amount of hoops you’d have to jump through and makes your code clean and crisp. Let’s see how we can use it to create dynamic parameters from enum values in our Get-SpecialFolderPath function:

function Get-SpecialFolderPath
{
    [CmdletBinding()]
    Param()
    DynamicParam
    {
        # Get special folder names for ValidateSet attribute
        $SpecialFolders = [Enum]::GetNames('System.Environment+SpecialFolder')

        # Create new dynamic parameter
        New-DynamicParameter -Name Name -ValidateSet $SpecialFolders -Type ([array]) `
        -Position 0 -Mandatory -ValueFromPipeline -ValueFromPipelineByPropertyName -ValidateNotNull
    }

    Process
    {
        # Bind dynamic parameter to a friendly variable
        New-DynamicParameter -CreateVariables -BoundParameters $PSBoundParameters

        $Name | ForEach-Object { [Environment]::GetFolderPath($_) }
    }
}

As you see, it takes only three lines of code to create new dynamic parameter. But the more the merrier, so how about several dynamic parameters?

Here is the example taken directly from the help of the New-DynamicParameter function. It will create several dynamic parameters, with multiple Parameter Sets.

In this example three dynamic parameters are created. Two of the parameters are belong to the different parameter set, so they are mutually exclusive. One of the parameters belongs to both parameter sets.

  • The Drive‘s parameter ValidateSet is populated with all available volumes on the computer.
  • The DriveType‘s parameter ValidateSet is populated with all available drive types.
  • The Precision‘s parameter controls number of digits after decimal separator for Free Space percentage.

Usage:

PS C:\Users\beatcracker> Get-FreeSpace -Drive <tab> -Precision 2
PS C:\Users\beatcracker> Get-FreeSpace -DriveType <tab> -Precision 2

Parameters are defined in the array of hashtables, which is then piped through the New-Object to create PSObject and pass it to the New-DynamicParameter function.

If parameter with the same name already exist in the RuntimeDefinedParameterDictionary, a new Parameter Set is added to it.

Because of piping, New-DynamicParameter function is able to create all parameters at once, thus eliminating need for you to create and pass external RuntimeDefinedParameterDictionary to it.

function Get-FreeSpace
{
	[CmdletBinding()]
	Param()
	DynamicParam
	{
		# Array of hashtables that hold values for dynamic parameters
		$DynamicParameters = @(
			@{
				Name = 'Drive'
				Type = [array]
				Position = 0
				Mandatory = $true
				ValidateSet = ([System.IO.DriveInfo]::GetDrives()).Name
				ParameterSetName = 'Drive'
			},
			@{
				Name = 'DriveType'
				Type = [array]
				Position = 0
				Mandatory = $true
				ValidateSet = [System.Enum]::GetNames('System.IO.DriveType')
				ParameterSetName = 'DriveType'
			},
			@{
				Name = 'Precision'
				Type = [int]
				# This will add a Drive parameter set to the parameter
				Position = 1
				ParameterSetName = 'Drive'
			},
			@{
				Name = 'Precision'
				# Because the parameter already exists in the RuntimeDefinedParameterDictionary,
				# this will add a DriveType parameter set to the parameter.
				Position = 1
				ParameterSetName = 'DriveType'
			}
		)

		# Convert hashtables to PSObjects and pipe them to the New-DynamicParameter,
		# to create all dynamic parameters in one function call.
		$DynamicParameters | ForEach-Object {New-Object PSObject -Property $_} | New-DynamicParameter
	}
	Process
	{
		# Dynamic parameters don't have corresponding variables created,
		# you need to call New-DynamicParameter with CreateVariables switch to fix that.
		New-DynamicParameter -CreateVariables -BoundParameters $PSBoundParameters

		if($Drive)
		{
			$Filter = {$Drive -contains $_.Name}
		}
		elseif($DriveType)
		{
			$Filter = {$DriveType -contains  $_.DriveType}
		}

		if(!$Precision)
		{
			$Precision = 2
		}

		$DriveInfo = [System.IO.DriveInfo]::GetDrives() | Where-Object $Filter
		$DriveInfo |
			ForEach-Object {
				if(!$_.TotalFreeSpace)
				{
					$FreePct = 0
				}
				else
				{
					$FreePct = [System.Math]::Round(($_.TotalSize / $_.TotalFreeSpace), $Precision)
				}
				New-Object -TypeName psobject -Property @{
					Drive = $_.Name
					DriveType = $_.DriveType
					'Free(%)' = $FreePct
				}
			}
	}
} 

There is more examples in the help, that should get you started with Dynamic Parameters in no time. If something doesn’t work for you, feel free to drop me a note, I’ll be happy to fix it.

3 thoughts on “Dynamic parameters, ValidateSet and Enums

  1. Curious why you’d go through all this trouble for an enum. I think the best way is to just make the parameter the same type as the enum. You automatically get validation and tab completion that way, for example: param( [System.IO.DriveType] $DriveType )

    Liked by 2 people

    • Well, it’s obvious: because I totally overlooked it :). I’m glad you pointed that out, or I’d still be messing with DynamicParameters where there is no need for them. My technique can still be useful though, if you need to create custom parameter with filtered set of enum’s values, but that’s pretty rare case.

      Like

      • A direct enum has one other advantage too, it allows you to use the string name or the integer value and it will be cast appropriately. But I do like the idea of wrapping DynamicParam creation into a function since it can be tedious.

        Like

Leave a comment