PowerShell – Build Advanced Functions

Advanced functions (also called Cmdlets in PowerShell) are basically regular functions on steroids. They offer everything normal functions do, but in addition they provide better input handling and validation, enables the function to work with the pipeline, and you get access to a set of standard parameters and functionality that all PowerShell cmdlets share.

Your goal should be to make all user-facing functions, advanced functions. Functions that are only being called by other functions, or scripts, are not that benefitial to make advanced.

However if this sounds a bit to foreign to you, fell free to check out my PowerShell beginner blog series.

Building an Advanced Function

Let’s start with a normal function and look on how we can turn it into an advanced function by adding more and more building blocks, that makes up advanced functions. Let’s make a function that checks the PowerShell version on computers remotely.

function Get-PSVersion {
    param (
        [string]$ComputerName
    )

    Invoke-Command -ComputerName $ComputerName -ScriptBlock {
        Write-Output $PSVersionTable.PSVersion.ToString()
    }
}

CmdletBinding

The first thing we should do now is to add a cmdletbinding attribute, that let’s PowerShell know that this is an advanced function, that should have access to a set of properties, and attributes that the native cmdlets implements.

function Get-PSVersion {
    [CmdletBinding()]
    param (
        [string]$ComputerName
    )

    Invoke-Command -ComputerName $ComputerName -ScriptBlock {
        Write-Output $PSVersionTable.PSVersion.ToString()
    }
}

Now we kan invoke the function with a set of extra parameters.

ParameterDescription
VerboseDisplay data written to the verbose stream by the Write-Verbose cmdlet.
DebugDisplay data written to the debug stream by the Write-Debug cmdlet.
InformationActionSet what action should be taken upon any data in the information stream. Should the data terminate the script (Stop), be displayed and continue with the script (Continue), or should it be ignored (SilentlyContinue)?
InformationVariableStores data from the information stream in a variable.
ErrorActionSet what action should be taken upon any non-terminating errors. Should the error terminate the script (Stop), be displayed and continue with the script (Continue), or should it be ignored (SilentlyContinue)?
ErrorVariableStore the any errors in a variable.
WarningAction Set what action should be taken upon any warning. Should the warning terminate the script (Stop), be displayed and continue with the script (Continue), or should it be ignored (SilentlyContinue)?
WarningVariableStore any warnings in a variable.
OutBufferDetermines the number of objects to accumulate in a buffer before any objects are sent through the pipeline. If you omit this parameter, objects are sent as they’re generated.
PipelineVariableStores the value of the current pipeline element as a variable, for any named command as it flows through the pipeline. It can then be access anywhere later in the same pipeline.
OutVariableStore the output in a variable, in addition to returning it to the pipeline.

Parameter Attributes

Now let’s lay down som groun rules for our parameter, by specifying the Parameter attribute, and it’s different options.

function Get-PSVersion {
    [CmdletBinding(DefaultParameterSetName = 'byComputerName')]
    param (
        [Parameter(
            Mandatory,
            Position = 0,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName,
            ParameterSetName = 'byComputerName'
        )]
        [string]$ComputerName
    )

    Invoke-Command -ComputerName $ComputerName -ScriptBlock {
        Write-Output $PSVersionTable.PSVersion.ToString()
    }
}

Let’s break down all the stuff that got added here..

Mandatory. This option specify that the parameter must have a value for the function to even execute. PowerShell will prompt you for this value, if missing.

Position. This allows us to pass the parameter value without specifying the parameter name. Positions after the function name are separated by a single space.

ValueFromPipeline. Allows us to pass the parameter value to the function, from the pipeline. PowerShell will match the incoming pipeline objects to a parameter based on the object type. But only if this option is enabled. Beware, if you have multiple parameters of the same object type, this will not work properly.

ValueFromPipelineByPropertName. Same as ValueFromPipeline, but this matches the pipeline object to a parameter based on the incoming objects parameter names, rather than the object type. The incoming parameter name, should match the name of your function parameter.

ParameterSetName. If you have multiple parameters, where the user can choose either one. You can separate them by assigning them to different parameter sets. You should always specify the default parameter set, otherwise PowerShell can get a bit confused..

Did you notice that when we passed two computer names from the pipeline, we only got one result? If we modify the function as the following, then we see that we only get the last object from the pipeline.

function Get-PSVersion {
    [CmdletBinding(DefaultParameterSetName = 'byComputerName')]
    param (
        [Parameter(
            Mandatory,
            Position = 0,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName,
            ParameterSetName = 'byComputerName'
        )]
        [string]$ComputerName
    )

    $ComputerName
}

That is because we don’t have all the components yet to fully support the pipeline.

Pipeline Support

To make the function work properly with the pipeline, we have to make the following changes:

function Get-PSVersion {
    [CmdletBinding(DefaultParameterSetName = 'byComputerName')]
    param (
        [Parameter(
            Mandatory,
            Position = 0,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName,
            ParameterSetName = 'byComputerName'
        )]
        [string]$ComputerName
    )

    begin {
        Write-Verbose -Message "Starting function.."
    }

    process {
        Write-Verbose -Message "Getting PowerShell version from: $($ComputerName -join ', ')"
        Invoke-Command -ComputerName $ComputerName -ScriptBlock {
            Write-Output $PSVersionTable.PSVersion.ToString()
        }
    }

    end {
        Write-Verbose -Message "Function finished!"
    }
}

Begin. The begin block is what’s get executed first, and it’s only executed once. This block does not have access to the pipeline objects.

Process. The process block get’s executed once for each objects passed through the pipeline.

End. The end block is what’s get executed last, and it’s only executed once.

OutputType

We should be a good citizen and specify what type of object our function returns. And while we’re at it, I want to output an object, and not just a string, so we can know which version belongs to which computer.

function Get-PSVersion {
    [CmdletBinding(DefaultParameterSetName = 'byComputerName')]
    [OutputType('System.Management.Automation.PSCustomObject')]
    param (
        [Parameter(
            Mandatory,
            Position = 0,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName,
            ParameterSetName = 'byComputerName'
        )]
        [string]$ComputerName
    )

    begin {
        Write-Verbose -Message "Starting function.."
    }

    process {
        Write-Verbose -Message "Getting PowerShell version from: $($ComputerName -join ', ')"
        $Version = Invoke-Command -ComputerName $ComputerName -ScriptBlock {
            Write-Output $PSVersionTable.PSVersion.ToString()
        }
        [PSCustomObject]@{
            ComputerName = $ComputerName
            Version = $Version
        }
    }

    end {
        Write-Verbose -Message "Function finished!"
    }
}

Parameter Set

Now let’s make use of that parameter set we started on, and introduce a new parameter. Let’s make it so that you can pass in a text-file with computer names instead.

function Get-PSVersion {
    [CmdletBinding(DefaultParameterSetName = 'byComputerName')]
    [OutputType('System.Management.Automation.PSCustomObject')]
    param (
        [Parameter(
            Mandatory,
            Position = 0,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName,
            ParameterSetName = 'byComputerName'
        )]
        [string[]]$ComputerName,
        [Parameter(
            Mandatory,
            Position = 0,
            ParameterSetName = 'byPath'
        )]
        [string]$Path
    )

    begin {
        Write-Verbose -Message "Starting function.."
        if ($PSBoundParameters.Path)
        {
            $ComputerName = Get-Content -Path $Path
        }
    }

    process {
        foreach ($Computer in $ComputerName)
        {
            Write-Verbose -Message "Getting PowerShell version from: $($Computer -join ', ')"
            $Version = Invoke-Command -ComputerName $Computer -ScriptBlock {
                Write-Output $PSVersionTable.PSVersion.ToString()
            }
            [PSCustomObject]@{
                Computer = $Computer
                Version = $Version
            }
        }
    }

    end {
        Write-Verbose -Message "Function finished!"
    }
}

Right away you’ll notice I have added a new parameter, path, along with a new parameter set. This parameter does not take pipeline input. Note that you cannot use both parameters at the same time, as they belong to different parameter sets. If we had a third parameter that should belong to all sets, we would simply just skip defining a set for that parameter. The text file is read in the begin block, as a preliminary task to what the function is supposed to do. In the process block I have added an foreach-loop, that is necessary whenever the parameter does not come from the pipeline, because the process block will only evaluate once.

The beauty of it, is that this will still work for passing computer names from the pipeline, so we have not lost any functionality.

Validation Attributes

By declaring validation attributes to parameters, we can restrict what values can be passed to the parameters, and that without having to implement any custom validation logic within the function itself.

function Get-PSVersion {
    [CmdletBinding(DefaultParameterSetName = 'byComputerName')]
    [OutputType('System.Management.Automation.PSCustomObject')]
    param (
        [Parameter(
            Mandatory,
            Position = 0,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName,
            ParameterSetName = 'byComputerName'
        )]
        [ValidateNotNullOrEmpty()]
        [string[]]$ComputerName,
        [Parameter(
            Mandatory,
            Position = 0,
            ParameterSetName = 'byPath'
        )]
        [ValidateScript({ Test-Path -Path $_ })]
        [string]$Path
    )

    begin {
        Write-Verbose -Message "Starting function.."
        if ($PSBoundParameters.Path)
        {
            $ComputerName = Get-Content -Path $Path
        }
    }

    process {
        foreach ($Computer in $ComputerName)
        {
            Write-Verbose -Message "Getting PowerShell version from: $($Computer -join ', ')"
            $Version = Invoke-Command -ComputerName $Computer -ScriptBlock {
                Write-Output $PSVersionTable.PSVersion.ToString()
            }
            [PSCustomObject]@{
                Computer = $Computer
                Version = $Version
            }
        }
    }

    end {
        Write-Verbose -Message "Function finished!"
    }
}

When passing values that does not conform to the validation rules, you get a familiar error message, which is kind enough to explain why it failed.

Validation AttributeDescription
ValidateCount(1, 10)Between 1 and 10 objects are allowed
ValidateLength(1, 10)Length of string or array
ValidateNotNullValue can not be $null
ValidateNotNullOrEmpty Value can not be $null or empty string
ValidateRange(5, 10)Value must be between 5 and 10
ValidateScript({ expression })Expression must evaluate to $true. Use $_ to refer to input value.
ValidateSet(‘abc’, ‘def’, ghi’)Value can be either abc, def, or ghi

Comment-based help

First in every advanced function, you should have helpful information about the function. In advanced functions, this is usually completed by adding comment-based help within the function. The comment-based help must be the first thing appearing within the function.

function Get-PSVersion {
<#
.SYNOPSIS
    Get PowerShell version

.DESCRIPTION
    Get the PowerShell version from one or more computers.
    Computer names can be supplied directly, from the pipeline, or through a file.
    Returns a PSCustomObject with ComputerName, and Version properties.

.PARAMETER ComputerName
    The computer(s) to check the PowerShell version on.

.PARAMETER Path
    Path to a text-file with a list of the computer(s) to check the PowerShell version on.

.EXAMPLE
    Get PSVersion from several computers
    Get-PSVersion -ComputerName 'Computer1', 'Computer2'

.EXAMPLE
    Get PSVersion from several computers
    'Computer1', 'Computer2' | Get-PSVersion -Verbose -OutVariable Result

.EXAMPLE
    Get PSVersion from several computers
    Get-PSVersion -Path Computers.txt

.LINK
    https://yourwebsite.com/myAdvancedFunction

.NOTES
    Anything extra you want to convey to your future audience.
#>
    [CmdletBinding(DefaultParameterSetName = 'byComputerName')]
    [OutputType('System.Management.Automation.PSCustomObject')]
    param (
        [Parameter(
            Mandatory,
            Position = 0,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName,
            ParameterSetName = 'byComputerName'
        )]
        [ValidateNotNullOrEmpty()]
        [string[]]$ComputerName,
        [Parameter(
            Mandatory,
            Position = 0,
            ParameterSetName = 'byPath'
        )]
        [ValidateScript({ Test-Path -Path $_ })]
        [string]$Path
    )

    begin {
        Write-Verbose -Message "Starting function.."
        if ($PSBoundParameters.Path)
        {
            $ComputerName = Get-Content -Path $Path
        }
    }

    process {
        foreach ($Computer in $ComputerName)
        {
            Write-Verbose -Message "Getting PowerShell version from: $($Computer -join ', ')"
            $Version = Invoke-Command -ComputerName $Computer -ScriptBlock {
                Write-Output $PSVersionTable.PSVersion.ToString()
            }
            [PSCustomObject]@{
                Computer = $Computer
                Version = $Version
            }
        }
    }

    end {
        Write-Verbose -Message "Function finished!"
    }
}

You can view this information by using the Get-Help cmdlet:

Get-Help -Name Get-PSVersion

For full description on how to make comment based help, please refer to Microsoft documentation.

SupportsShouldProcess

One last thing I want to teach you is an Cmdlet attribute called SupportsShouldProcess. This is recommended to implement whenever you write a function that can change the system state of a computer (See the list of verb here). It will then prompt the user, to make sure he really wants to do this.

Consider the following advanced function (also note that if they are empty, you can omit the begin, and end blocks):

function Stop-Server {
    [CmdletBinding()]
    param (
        [Parameter(
            Mandatory,
            Position = 0,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName
        )]
        [ValidateNotNullOrEmpty()]
        [string[]]$ComputerName
    )

    process {
        foreach ($Computer in $ComputerName)
        {
            Write-Verbose -Message "Performing shutdown on server $Computer"
            Stop-Computer -ComputerName $Computer
        }
    }
}

This is something that can cause problems if used incorrectly. So let’s att the SupportsShouldProcess attribute.

function Stop-Server {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param (
        [Parameter(
            Mandatory,
            Position = 0,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName
        )]
        [ValidateNotNullOrEmpty()]
        [string[]]$ComputerName
    )

    process {
        foreach ($Computer in $ComputerName)
        {
            if ($PSCmdlet.ShouldProcess($Computer, "Shutdown computer"))
            {
                Write-Verbose -Message "Performing shutdown on server $Computer"
                Stop-Computer -ComputerName $Computer
            }
        }
    }
}

Note that you also have to implement if-statements where the users should be prompted if he wants to continue.

The ConfirmImpact is to set the threshold for when to prompt, according to the users $ConfirmPreference. In the following example I have changed the ConfirmImpact to Medium. If the users value is higher than the one in the advanced function, the function will not ask the user is he’s sure to continue.

Another great thing about this feature is that you can now make use of the -WhatIf switch on your function. This will inform the user what would have been done if the switch was not present.

Conclusion

You have now learnt how to step up your game, and make advanced functions with all the pizzazz they bring to the table! Now I guess you have a few hours ahead of you to go back and fix up some of your regular old functions.. 🙂 Remember, all user-facing functions should be advanced functions!

Leave a Reply