3

In PowerShell, I want to write a function, that accepts different options as parameters. It is OK, if it receives more than one parameter, but it has to receive at least one parameter. I want to enforce it through the parameter definition and not through code afterwards. I can get it to work with the following code:

function Set-Option {

    Param(
        [Parameter(Mandatory, ParameterSetName="AtLeastOption1")]
        [Parameter(Mandatory=$false, ParameterSetName="AtLeastOption2")]
        [Parameter(Mandatory=$false, ParameterSetName="AtLeastOption3")]
        $Option1,

        [Parameter(Mandatory=$false, ParameterSetName="AtLeastOption1")]
        [Parameter(Mandatory, ParameterSetName="AtLeastOption2")]
        [Parameter(Mandatory=$false, ParameterSetName="AtLeastOption3")]
        $Option2,

        [Parameter(Mandatory=$false, ParameterSetName="AtLeastOption1")]
        [Parameter(Mandatory=$false, ParameterSetName="AtLeastOption2")]
        [Parameter(Mandatory, ParameterSetName="AtLeastOption3")]
        $Option3
    )

    # Do stuff, but don't evaluate the plausibility of the given parameters here
}

But as you can see, it scales badly. For each additional option, I have to add a line to all other options. Can this be done in a more efficient and a more maintainable way?

As I already said, I don't want to check the parameters in the code, e. g. through evaluating $PSBoundParameters. I want it to happen in the parameter definition for auto-doc reasons.


If you need a real world example, have a look at Set-DhcpServerv4OptionValue which accepts many different options (-DnsDomain, -DnsServer, -Router, ...), where it is OK to have them all, but it makes no sense to have none.


Note: After several answers have already been provided, I just realized that my code is actually not working, if you provide more than one option.

stackprotector
  • 10,498
  • 4
  • 35
  • 64

3 Answers3

4

The following isn't a great solution - and depending on what you mean by auto-doc, it may not work for you - but it scales well, as you'll only ever need one additional parameter set:

function Set-Option {

  [CmdletBinding(DefaultParameterSetName='Fail')]
  Param(
      [Parameter(ParameterSetName='AtLeastOne')]
      $Option1,

      [Parameter(ParameterSetName='AtLeastOne')]
      $Option2,

      [Parameter(ParameterSetName='AtLeastOne')]
      $Option3,

      # Note: All that 'DontShow' does is to exclude the param. from tab completion.
      [Parameter(ParameterSetName='Fail', DontShow)] 
      ${-} = $(throw "Please specify at least one option.")
  )

  # Do stuff, but don't evaluate the plausibility of the given parameters here
}
  • All real parameters are optional and belong to the same parameter set that is not the default.

  • The purpose of dummy parameter ${-}, which is the only one in the default parameter set, is solely to throw an error via its default value.

    • Due to its irregular name, you actually cannot pass an explicit value to it (which is desirable here, because it is purely auxiliary and not meant for direct use): you'd have to use -- <value>, but -- has special meaning to the parameter binder (deactivates named parameter binding for the subsequent arguments).

    • Unfortunately, property DontShow (e.g. [Parameter(DontShow)]) only hides the parameter from tab-completion, not also from the syntax diagrams.

      • GitHub issue #7868 proposes introducing a way to hide (obsolete) parameters from the syntax diagram.

Thus, unfortunately, the dummy parameter set and its parameter appear in the syntax diagram, so that Set-Option -? shows the following:

SYNTAX
    Set-Option [-- <Object>] [<CommonParameters>]

    Set-Option [-Option1 <Object>] [-Option2 <Object>] [-Option3 <Object>] [<CommonParameters>]

Note that syntax diagrams lack a notation for your desired logic.

mklement0
  • 382,024
  • 64
  • 607
  • 775
2

Edit: As OP noted, the solution presented here doesn't work when more than one argument is passed:

Set-Option -Option1 foo -Option2 42

Parameter set cannot be resolved using the specified named parameters. One or more    
parameters issued cannot be used together or an insufficient number of parameters were
provided.

I'll keep it as an example for using DynamicParam.


This is a solution using DynamicParam to automatically generate the same parameter sets that you have created manually. Despite not being a "code-free" solution, it still shows the expected syntax diagram (when called like Set-Option -?), because PowerShell gets all necessary information from the DynamicParam block.

First we define a reusable helper function to be able to write a DRY DynamicParam block:

Function Add-ParamGroupAtLeastOne {
    <#
    .SYNOPSIS
        Define a group of parameters from which at least one must be passed.
    #>
    Param(
        [Parameter(Mandatory)] [Management.Automation.RuntimeDefinedParameterDictionary] $Params,
        [Parameter(Mandatory)] [Collections.Specialized.IOrderedDictionary] $ParamDefinitions
    )

    foreach( $paramDef in $ParamDefinitions.GetEnumerator() ) {

        $attributes = [Collections.ObjectModel.Collection[Attribute]]::new()

        # Generate parameter sets for one parameter
        foreach( $groupItem in $ParamDefinitions.Keys ) {
            $attr = [Management.Automation.ParameterAttribute]@{
                Mandatory = $paramDef.Key -eq $groupItem
                ParameterSetName = "AtLeastOne$groupItem"
            }
            if( $paramDef.Value.HelpMessage ) {
                $attr.HelpMessage = $paramDef.Value.HelpMessage
            }
            
            $attributes.Add( $attr )
        }
    
        # Add one parameter
        $Params.Add( $paramDef.Key, [Management.Automation.RuntimeDefinedParameter]::new( $paramDef.Key, $paramDef.Value.Type, $attributes ))         
    }
}

The Set-Option function can now be written like this:

Function Set-Option {
    [CmdletBinding()]
    Param()  # Still required

    DynamicParam {
        $parameters = [Management.Automation.RuntimeDefinedParameterDictionary]::new()

        Add-ParamGroupAtLeastOne -Params $parameters -ParamDefinitions ([ordered] @{ 
            Option1 = @{ Type = 'string'; HelpMessage = 'the 1st option' }
            Option2 = @{ Type = 'int';    HelpMessage = 'the 2nd option' }
            Option3 = @{ Type = 'bool';   HelpMessage = 'the 3rd option' }
        })

        $parameters
    }    

    process {
        # Do stuff
    }
}

Set-Option -? outputs this syntax diagram, as expected:

SYNTAX
    Set-Option -Option1 <string> [-Option2 <int>] [-Option3 <bool>] [<CommonParameters>]

    Set-Option -Option2 <int> [-Option1 <string>] [-Option3 <bool>] [<CommonParameters>]

    Set-Option -Option3 <bool> [-Option1 <string>] [-Option2 <int>] [<CommonParameters>]

If you want to add more parameter attributes, have a look at the ParameterAttribute class and add the desired attributes in the function Add-ParamGroupAtLeastOne as I have done exemplary for HelpMessage.

zett42
  • 25,437
  • 3
  • 35
  • 72
  • 2
    I just realized that my code is actually _not_ working, if you provide more than one option. Unfortunately, the same applies to your solution. – stackprotector Mar 23 '22 at 09:05
  • @stackprotector That's unfortunate! The error looks like as if PowerShell can't resolve the arguments to a single parameter set. You get the same message (as expected) when calling this function with both arguments: `function fun{ param( [parameter(parametersetname='x')] $x, [parameter(parametersetname='y')] $y ) }` – zett42 Mar 23 '22 at 09:21
  • 2
    As for why it doesn't work: To resolve ambiguity, a default parameter set must be designated. However, since all parameter sets have at least one mandatory parameter, choosing any one of them as the default would invariably prompt for its respective mandatory parameter, if not specified; e.g., if you make the one with `-Option1` the default parameter set and try to call `Set-Option -Option 2`, you'll get a prompt for the value of `-Option 1`. – mklement0 Mar 23 '22 at 12:41
0

If the parameters are all switches (i.e., you specify them as -Option1 rather than -Option1 SomeValue), include a test at the beginning of the actual code that checks that they're not ALL false, and if they are, reject the invocation. If they're value parameters (i.e., -Option1 SomeValue), you'll have to test each of them against $null, and if they're all $null, reject the invocation.

function Set-Option {
   param (
      [switch]$Option1,
      [switch]$Option2,
      ...
   )

   if (!($Option1 -or $Option2 -or ...)) {
      # reject the invocation and abort
   }
   ...
}
Jeff Zeitlin
  • 9,773
  • 2
  • 21
  • 33