Powershell multiple function params from ValidateSet - powershell

I'm writing a script with PowerShell and at some point I needed to use ValidateSet on function params. It's a very good feature, but what I need is something more than that.
For example
Function JustAnExample
{
param(
[Parameter(Mandatory=$false)][ValidateSet("IPAddress","Timezone","Cluster")]
[String]$Fields
)
write-host $Fields
}
So this code snippet allows me to choose one item from the list like that
JustAnExample -Fields IPAddress
and then prints it to the screen.
I wonder if there is a possibility to allow to choose multiple values and pass them to function from one Validation set like so
JustAnExample -Fields IPAddress Cluster
Maybe there is a library for that or maybe I just missed something but I really can't find a solution for this.

If you want to pass multiple string arguments to the -Fields parameter, change it to an array type ([String[]]):
param(
[Parameter(Mandatory=$false)]
[ValidateSet("IPAddress","Timezone","Cluster")]
[String[]]$Fields
)
And separate the arguments with , instead of space:
JustAnExample -Fields IPAddress,Cluster

#SokIsKedu, I had the same issue and could not find an answer. So I use the following inside the function:
function Test-Function {
param (
[ValidateSet('ValueA', 'ValueB')]
[string[]] $TestParam
)
$duplicate = $TestParam | Group-Object | Where-Object -Property Count -gt 1
if ($null -ne $duplicate) {
throw "TestParam : The following values are duplicated: $($duplicate.Name -join ', ')"
}
...
}
It does not prevent duplicates being passed in, but it does catch them.

Related

Using ValidateSet with functions or List

I'm trying to do something like this.
The Get-SomeOtherParameter returns a system.Array type list from a database.
I don't want to hardcode my ValidateSet in case the list changes overTime in the database
function Get-SomeItems {
param (
[Parameter(Mandatory = $true)]
[ValidateSet(Get-SomeOtherParameter)]
[string]$filter,
[Parameter(Mandatory = $true)]
[ValidateSet('abc', 'def', 'ghi')]
[String]$filter2
)
}
To complement Start-Automating's helpful answer by spelling out the [ValidateScript({ ... }] and [ArgumentCompleter({ ... }) approaches:
# Function that returns the valid values for the -filter parameter below.
function Get-ValidFilterValues {
# Sample, hard-coded values. This is where your database lookup would happen.
'foo', 'bar'
}
function Get-SomeItems {
param (
[Parameter(Mandatory)]
[ValidateScript({
$validValues = Get-ValidFilterValues
if ($_ -in $validValues) { return $true } # OK
throw "'$_' is not a valid value. Use one of the following: '$($validValues -join ', ')'"
})]
[ArgumentCompleter({
param($cmd, $param, $wordToComplete)
(Get-ValidFilterValues) -like "$wordToComplete*"
})]
[string]$filter,
[Parameter(Mandatory)]
[ValidateSet('abc', 'def', 'ghi')]
[String]$filter2
)
$filter, $filter2 # sample output.
}
A simpler PowerShell (Core) 7+ alternative is to implement validation via a custom class that implements the System.Management.Automation.IValidateSetValuesGenerator interface, which automatically also provides tab-completion:
# Custom class that implements the IValidateSetValuesGenerator interface
# in order to return the valid values for the -filter parameter below.
class ValidFilterValues : System.Management.Automation.IValidateSetValuesGenerator {
[string[]] GetValidValues() {
# Sample, hard-coded values. This is where your database lookup would happen.
return 'foo', 'bar'
}
}
function Get-SomeItems {
param (
[Parameter(Mandatory)]
[ValidateSet([ValidFilterValues])] # Pass the custom class defined above.
[string]$filter,
[Parameter(Mandatory)]
[ValidateSet('abc', 'def', 'ghi')]
[String]$filter2
)
$filter, $filter2 # sample output.
}
There's two aspects to what you're trying to do:
Making sure the parameter validation is correct
Making the PowerShell experience around it "good" (aka supporting tab completion).
Parameter Validation :
As you might have already noticed [ValidateSet] is a hard-coded list. It's not really possible to soft code this (it is possible to dynamically build your script every time using some other modules, lemme know if you want more of an explainer for this).
To make the Validation work without [ValidateSet], I'd suggest [ValidateScript({})]. [ValidateScript] will run whatever script is in ValidateScript to ensure the script is valid. If the [ValidateScript()] throws, the user will see that message when they pass an invalid value in.
Tab-Completion :
To make it feel easy, you'll also want to add support for tab completion.
This is fairly straightforward using the [ArgumentCompleter] attribute.
Here's an example copied / pasted from a module called LightScript
[ArgumentCompleter({
param ( $commandName,
$parameterName,
$wordToComplete,
$commandAst,
$fakeBoundParameters )
$effectNames = #(Get-NanoLeaf -ListEffectName |
Select-Object -Unique)
if ($wordToComplete) {
$toComplete = $wordToComplete -replace "^'" -replace "'$"
return #($effectNames -like "$toComplete*" -replace '^', "'" -replace '$',"'")
} else {
return #($effectNames -replace '^', "'" -replace '$',"'")
}
})]
This ArgumentCompleter does a few things:
Calls some other command to get a list of effects
If $wordToComplete was passed, finds all potential completions (while stripping off whitespace and enclosing in quotes)
If $WordToComplete was not passed, puts each potential completion in quotes
Basically, all you should need to change are the command names / variables to make this work.
Hope this Helps

How to pass foreach-object output to a function accepting pipeline input?

This is a very simple issue as far as I understand. There are plenty of similar questions on here, but I haven't been able to find exactly what I need. What am I missing?
Expected output
1 2 3
Actual output (error)
cmdlet ForEach-Object at command pipeline position 1
Supply values for the following parameters:
Process[0]:
Code
function processItem {
param($item)
Process {
$item
}
}
$collection = #(1,2,3)
$collection | foreach-object | processItem
First, you don't have to use Foreach-Object here because the pipeline will directly unwrap $items and send one value at a time to your function processItem.
Passing Arrays to Pipeline
If a function returns more than one value, PowerShell wraps them in an array. However, if you pass the results to another function inside a pipeline, the pipeline automatically "unwraps" the array and processes one array element at a time.
The parameter $item in the function doesn't accept pipeline input in your code, you should use ValueFromPipeline like this:
function processItem {
param([parameter(ValueFromPipeline=$true)]$item)
Process {
$item
}
}
Use like this:
$items = #(1, 2, 3)
$items | processItem

Processing group aliases in a loop

I'm having an issue getting a loop in a function to properly. The goal is to compare the output of some JSON data to existing unified groups in Office 365, and if the group already exists, skip it, otherwise, create a new group. The tricky part is that as part of function that creates the group, it prepends "gr-" to the group name. Because the compare function is comparing the original JSON data without the prepended data to Office 365, the compare function has to have the logic to prepend "gr-" on the fly. If there is a better way to accomplish this last piece, I am certainly open to suggestions.
Here is the latest version of the function. There have been other variations, but none so far have worked. There are no errors, but the code does not identify lists that definitely do exist. I am using simple echo statements for the purpose of testing, the actual code will include the function to create a new group.
# Test variable that cycles through each .json file.
$jsonFiles = Get-ChildItem -Path "c:\tmp\json" -Filter *.json |
Get-Content -Raw
$allobjects = ForEach-Object {
$jsonFiles | ConvertFrom-Json
}
$alreadyCreatedGroup = ForEach-Object {Get-UnifiedGroup | select alias}
# Determine if list already exists in Office 365
function checkForExistingGroup {
[CmdletBinding()]
Param(
[Parameter(Position=0, Mandatory=$true, ValueFromPipeline=$true)]
$InputObject
)
Process {
if ("gr-$($InputObject.alias)" -like $alreadyCreatedGroup) {
echo "Group exists"
} else {
echo "Group does not exist"
}
}
}
$allobjects | checkForExistingGroup
#$alreadyCreatedGroup | checkForExistingGroup
The above code always produces "Group does not exist" for each alias from the JSON data.
The individual variables appear to be outputting correctly:
PS> $alreadyCreatedGroup
Alias
-----
gr-jsonoffice365grouptest1
gr-jsonoffice365grouptest2
gr-jsonoffice365grouptest3
PS> $allobjects.alias
jsonoffice365grouptest3
jsonoffice365grouptest4
If I run the following on its own:
"gr-$($allobjects.alias)"
I get the following output:
gr-jsonoffice365grouptest3 jsonoffice365grouptest4
So on its own it appends the output from the JSON files, but I had hoped by using $InputObject in the function, this would resolve that issue.
Well, a single group will never be -like a list of groups. You want to check if the list of groups contains the alias.
if ($alreadyCreatedGroup -contains "gr-$($InputObject.Alias)") {
echo "Group exists"
} else {
echo "Group does not exist"
}
In PowerShell v3 or newer you could also use the -in operator instead of the -contains operator, which feels more natural to most people:
if ("gr-$($InputObject.Alias)" -in $alreadyCreatedGroup) {
echo "Group exists"
} else {
echo "Group does not exist"
}
And I'd recommend to pass the group list to the function as a parameter rather than using a global variable:
function checkForExistingGroup {
[CmdletBinding()]
Param(
[Parameter(Position=0, Mandatory=$true, ValueFromPipeline=$true)]
$InputObject,
[Parameter(Mandatory=$true, ValueFromPipeline=$false)]
[array]$ExistingGroups
)
...
}
"gr-$($allobjects.Alias)" doesn't produce the result you expect, because the expression basically means: take the Alias properties of all elements in the array/collection $allobjects, concatenate their values with the $OFS character (output field separator), then insert the result after the substring "gr-". That doesn't affect your function, though, because the pipeline already unrolls the input array, so the function sees one input object at a time.

When my powershell cmdlet parameter accepts ValueFromPipelineByPropertyName and I have an alias, how can I get the original property name?

How can a function tell if a parameter was passed in as an alias, or an object in the pipeline's property was matched as an alias? How can it get the original name?
Suppose my Powershell cmdlet accepts pipeline input and I want to use ValueFromPipelineByPropertyName. I have an alias set up because I might be getting a few different types of objects, and I want to be able to do something slightly different depending on what I receive.
This does not work
function Test-DogOrCitizenOrComputer
{
[CmdletBinding()]
Param
(
# Way Overloaded Example
[Parameter(Mandatory=$true,
ValueFromPipeline=$true,
ValueFromPipelineByPropertyName=$true,
Position=0)]
[Alias("Country", "Manufacturer")]
[string]$DogBreed,
[Parameter(Mandatory=$true,
ValueFromPipelineByPropertyName=$true,
Position=1)]
[string]$Name
)
# For debugging purposes, since the debugger clobbers stuff
$foo = $MyInvocation
$bar = $PSBoundParameters
# This always matches.
if ($MyInvocation.BoundParameters.ContainsKey('DogBreed')) {
"Greetings, $Name, you are a good dog, you cute little $DogBreed"
}
# These never do.
if ($MyInvocation.BoundParameters.ContainsKey('Country')) {
"Greetings, $Name, proud citizen of $Country"
}
if ($MyInvocation.BoundParameters.ContainsKey('Manufacturer')) {
"Greetings, $Name, future ruler of earth, created by $Manufacturer"
}
}
Executing it, we see problems
At first, it seems to work:
PS> Test-DogOrCitizenOrComputer -Name Keith -DogBreed Basset
Greetings, Keith, you are a good dog, you cute little Basset
The problem is apparent when we try an Alias:
PS> Test-DogOrCitizenOrComputer -Name Calculon -Manufacturer HP
Greetings, Calculon, you are a good dog, you cute little HP
Bonus fail, doesn't work via pipeline:
PS> New-Object PSObject -Property #{'Name'='Fred'; 'Country'='USA'} | Test-DogOrCitizenOrComputer
Greetings, Fred, you are a good dog, you cute little USA
PS> New-Object PSObject -Property #{'Name'='HAL'; 'Manufacturer'='IBM'} | Test-DogOrCitizenOrComputer
Greetings, HAL, you are a good dog, you cute little IBM
Both $MyInvocation.BoundParameters and $PSBoundParameters contain the defined parameter names, not any aliases that were matched. I don't see a way to get the real names of arguments matched via alias.
It seems PowerShell is not only being 'helpful' to the user by silently massaging arguments to the right parameters via aliases, but it's also being 'helpful' to the programmer by folding all aliased inputs into the main parameter name. That's fine, but I can't figure out how to determine the actual original parameter passed to the Cmdlet (or the object property passed in via pipeline)
How can a function tell if a parameter was passed in as an alias, or an object in the pipeline's property was matched as an alias? How can it get the original name?
I don't think there is any way for a Function to know if an Alias has been used, but the point is it shouldn't matter. Inside the function you should always refer to the parameter as if its used by it's primary name.
If you need the parameter to act different depending on whether it's used an Alias that is not what an Alias is for and you should instead use different parameters, or a second parameter that acts as a switch.
By the way, if you're doing this because you want to use multiple parameters as ValueFromPipelineByPropertyName, you already can with individual parameters and you don't need to use Aliases to achieve this.
Accepting value from the pipeline by Value does need to be unique, for each different input type (e.g only one string can be by value, one int by value etc.). But accepting pipeline by Name can be enabled for every parameter (because each parameter name is unique).
I banged my head quite hard on this, so I'd like to write down the state of my understanding. The solution is at the bottom (such as it is).
First, quickly: if you alias the command, you can get the alias easily with $MyInvocation.InvocationName. But that doesn't help with parameter aliases.
Works in some cases
You can get some joy by pulling the commandline that invoked you:
function Do-Stuff {
[CmdletBinding()]param(
[Alias('AliasedParam')]$Param
)
$InvocationLine = $MyInvocation.Line.Substring($MyInvocation.OffsetInLine - 1)
return $InvocationLine
}
$a = 42; Do-Stuff -AliasedParam $a; $b = 23
# Do-Stuff -AliasedParam $a; $b = 23
This will show the alias names. You could parse them with regex, but I'd suggest using the language parser:
$InvocationAst = [Management.Automation.Language.Parser]::ParseInput($InvocationLine, [ref]$null, [ref]$null)
$InvocationAst.EndBlock.Statements[0].PipelineElements[0].CommandElements.ParameterName
That will get you a list of parameters as they were called. However, it's flimsy:
Doesn't work for splats
Doesn't work for ValueFromPipelineByPropertyName
Abbreviated param names will cause extra headache
Only works in the function body; in a dynamicparam block, the $MyInvocation properties are not yet populated
Doesn't work
I did a deep dive into ParameterBinderController - thanks to Rohn Edwards for some reflection snippets.
This is not going to get you anywhere. Why not? Because the relevant method has no side effects - it just moves seamlessly from canonical param names to aliases. Reflection ain't enough; you would need to attach a debugger, which I do not consider to be a code solution.
This is why Trace-Command never shows the alias resolution. If it did, you might be able to hook the trace provider.
Doesn't work
Register-ArgumentCompleter takes a scriptblock which accepts a CommandAst. This AST holds the aliased param names as tokens. But you won't get far in a script, because argument completers are only invoked when you interactively tab-complete an argument.
There are several completer classes that you could hook into; this limitation applies to them all.
Doesn't work
I messed about with custom parameter attributes, e.g. class HookAttribute : System.Management.Automation.ArgumentTransformationAttribute. These receive an EngineIntrinsics argument. Unfortunately, you get no new context; parameter binding has already been done when attributes are invoked, and the bindings you'll find with reflection are all referring to the canonical parameter name.
The Alias attribute itself is a sealed class.
Works
Where you can get joy is with the PreCommandLookupAction hook. This lets you intercept command resolution. At that point, you have the args as they were written.
This sample returns the string AliasedParam whenever you use the param alias. It works with abbreviated param names, colon syntax, and splatting.
$ExecutionContext.InvokeCommand.PreCommandLookupAction = {
param ($CommandName, $EventArgs)
if ($CommandName -eq 'Do-Stuff' -and $EventArgs.CommandOrigin -eq 'Runspace')
{
$EventArgs.CommandScriptBlock = {
# not sure why, but Global seems to be required
$Global:_args = $args
& $CommandName #args
Remove-Variable _args -Scope Global
}.GetNewClosure()
$EventArgs.StopSearch = $true
}
}
function Do-Stuff
{
[CmdletBinding()]
param
(
[Parameter()]
[Alias('AliasedParam')]
$Param
)
$CalledParamNames = #($_args) -match '^-' -replace '^-' -replace ':$'
$CanonParamNames = $MyInvocation.BoundParameters.Keys
$AliasParamNames = $CanonParamNames | ForEach-Object {$MyInvocation.MyCommand.Parameters[$_].Aliases}
# Filter out abbreviations that could match canonical param names (they take precedence over aliases)
$CalledParamNames = $CalledParamNames | Where-Object {
$CalledParamName = $_
-not ($CanonParamNames | Where-Object {$_.StartsWith($CalledParamName)} | Select-Object -First 1)
}
# Param aliases that would bind, so we infer that they were used
$BoundAliases = $AliasParamNames | Where-Object {
$AliasParamName = $_
$CalledParamNames | Where-Object {$AliasParamName.StartsWith($_)} | Select-Object -First 1
}
$BoundAliases
}
# Do-Stuff -AliasP 42
# AliasedParam
If the Global variable offends you, you could use a helper parameter instead:
$EventArgs.CommandScriptBlock = {
& $CommandName #args -_args $args
}.GetNewClosure()
[Parameter(DontShow)]
$_args
The drawback is that some fool might actually use the helper parameter, even though it's hidden with DontShow.
You could develop this approach further by doing a dry-run call of the parameter binding mechanism in the function body or the CommandScriptBlock.

Unexpected behavior in function processing PowerShell pipeline

I created a function to process items I want from an object and sort them into a new PSCustomObject. If I pass the object through the pipeline I get some duplicated and odd results versus passing the object as a parameter into the function and using a ForEach-Object loop.
Here is my example (this would produce 3 records):
$audioSessions | Where-Object {$_.QoeReport.FeedbackReports}
Versus this (which produces six and some are duplicated:
$audioSessions | Where-Object {$_.QoeReport.FeedbackReports} | ProcessFeedback
Here is the difference in the output:
Any idea why this would be happening? There are 3 objects I'm passing to the ProcessFeedback function, no? Why are some items duplicated and some are not?
If I choose to pass the entire variable into the function and loop within it, I get the 3 objects back from my function as expected:
ProcessFeedback -feedbackInput $audioSessions
Then, inside my function I do the filter with the Where-Object statement resulting in something like this:
function ProcessFeedback{
[cmdletbinding()]
Param(
[Parameter(mandatory=$true, valuefrompipeline=$true)]
$feedbackInput
)
begin{}
process{
$feedbackInput | Where-Object {$_.QoeReport.FeedbackReports} | ForEach-Object{
[array]$newObject += [PSCustomObject][ordered]#{
FromUri = $_.FromUri
ToUri = $_.ToUri
CaptureTime = $_.QoeReport.FeedbackReports.CaptureTime
Rating = $_.QoeReport.FeedBackReports.Rating
}
}
return $newObject
}
}
NOTE: When I pass the object through the pipeline, I remove the Where-Object statement in the ProcessFeedback function as I only ever see one object passed to it at a time.
Okay so I think I figured this out...
If you're passing multiple objects through the pipeline, there is no need to add the results together.
I simply changed the code to remove the [array] and += values from $newObject as follows:
function ProcessFeedback{
begin{}
process{
$newObject = [PSCustomObject][ordered]#{
FromUri = $_.FromUri
ToUri = $_.ToUri
CaptureTime = $_.QoeReport.FeedbackReports.CaptureTime
Rating = $_.QoeReport.FeedBackReports.Rating
}
}
return $newObject
}
}
Then run it like this:
[array]$arrFeedbackResults += $audioSessions | Where-Object {$_.QoeReport.FeedbackReports} | ProcessFeedback
I also removed the input parameter from the function as the object is passed through the pipeline anyway.