Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Incremental Dynamic Parameter Bind #361

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 231 additions & 0 deletions Draft/Incremental-Parameter-Bind.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
---
RFC: RFC
Author: Darren Kattan, James Brundage
Status: Draft
Area: Dynamic Parameter Binding
Version: 1.0
Comments Due: 2023-12-19
Plan to implement: Yes
---

# Incremental Binding of RuntimeDefinedParameter in Dynamicparam

Facilitate PowerShell's dynamic parameter block by allowing `RuntimeDefinedParameter` objects to bind arguments incrementally. This will enhance the ability to use previously bound values to refine subsequent parameters dynamically.

## Motivation

As a PowerShell script or module author,
I can use the bound values of one parameter to filter the choices for subsequent parameters,
so that I can offer dynamic and contextual choices to users and enhance the user experience.

## User Experience

Suppose a user wants to write a Get-LatestAvailableMicrosoftEdgeVersion function using data available from https://edgeupdates.microsoft.com/api/products

The data is JSON but is structured as followed

1. Product: Stable
- Platform: Windows
- Architecture: x64
- msi: 118.0.2088.57
- Architecture: arm64
- msi: 118.0.2088.57
- Architecture: x86
- msi: 118.0.2088.57
- Platform: Linux
- Architecture: x64
- rpm: 118.0.2088.57
- deb: 118.0.2088.57
- Platform: MacOS
- Architecture: universal
- pkg: 118.0.2088.57
- plist: 118.0.2088.57
- Platform: Android
- Architecture: arm64
- Version: 118.0.2088.58
- Platform: iOS
- Architecture: arm64
- Version: 118.0.2088.60

2. Product: Beta
- Platform: iOS
- Architecture: arm64
- Version: 118.0.2088.36
- Platform: Linux
- Architecture: x64
- rpm: 119.0.2151.12
- deb: 119.0.2151.12
- Platform: Windows
- Architecture: arm64
- msi: 119.0.2151.12
- Architecture: x64
- msi: 119.0.2151.12
- Architecture: x86
- msi: 119.0.2151.12
- Platform: MacOS
- Architecture: universal
- plist: 119.0.2151.12
- pkg: 119.0.2151.12
- Platform: Android
- Architecture: arm64
- Version: 119.0.2151.11


To get the version number the function would require the following parameters:
- Product
- Platform
- Architecture (Not Applicable for Android and iOS)
- PackageType (Applicable for Linux to disambiguate rpm/deb and Windows to disambiguate msi/exe)


With this proposed change, once the user selects a Product and Platform, Architecture and PackageType options are conditionally required based on the API data.

The sample code below demonstrates how this experience _might_ look with the proposed changes:

```powershell
$EdgeVersions = Invoke-RestMethod https://edgeupdates.microsoft.com/api/products

$productParamAttributes = [Collection[Attribute]]@(
([ValidateSet]::new($EdgeVersions.Product)),
([Parameter]@{ Mandatory = $true })
)
[RuntimeDefinedParameter]::new('Product', [string], $productParamAttributes)

if($Product)
{
$EdgeVersions = $EdgeVersions | ?{$_.Product -eq $Product}
$Platforms = $EdgeVersions | %{ $_.Releases } | select -Unique -Expand Platform

$platformParamAttributes = [Collection[Attribute]]@(
([ValidateSet]::new($Platforms)),
([Parameter]@{ Mandatory = $true })
)
[RuntimeDefinedParameter]::new('Platform', [string], $platformParamAttributes)

if($Platform)
{
$EdgeVersions = $EdgeVersions | ?{$_.Releases.Platform -eq $Platform}
$Architectures = $EdgeVersions.Releases.Architecture | select -Unique | sort

$architectureParamAttributes = [Collection[Attribute]]@(
([ValidateSet]::new($Architectures)),
([Parameter]@{ Mandatory = $true })
)
[RuntimeDefinedParameter]::new('Architecture', [string], $architectureParamAttributes)

if($Architecture)
{
$EdgeVersions = $EdgeVersions | ?{$_.Releases.Architecture -eq $Architecture}
$versionToInstallValues = $EdgeVersions.Releases.ProductVersion | select -Unique

$versionParamAttributes = [Collection[Attribute]]@(
([ValidateSet]::new($versionToInstallValues)),
([Parameter]@{ Mandatory = $true })
)
[RuntimeDefinedParameter]::new('Version', [string], $versionParamAttributes)
}
}
}
```
Upon execution, users will be prompted incrementally:

```output
Product: [List of Products]
Platform: [Filtered List of Platforms]
Architecture: [Filtered List of Architectures]
Version: [Filtered List of Versions]
```

The syntax could be even more concise with user-supplied helper function `New-Parameter`
```powershell
function New-Parameter {
param(
[string]$Name,
[Type]$Type = [object],
[string[]]$ValidValues,
[switch]$Mandatory
)

$attributes = [Collection[Attribute]]@(
([ValidateSet]::new($ValidValues)),
([Parameter]@{ Mandatory = $Mandatory })
)

return [RuntimeDefinedParameter]::new($Name, $Type, $attributes)
}

Function Get-EdgeUpdates
{
param()
dynamicparam
{
$EdgeVersions = Invoke-RestMethod https://edgeupdates.microsoft.com/api/products
New-Parameter -Name Product -ValidValues $EdgeVersions.Product -Type ([string]) -Mandatory
if($Product)
{
$EdgeReleases = $EdgeVersions | ?{$_.Product -eq $Product} | select -Expand Releases # Beta,Stable,Dev,Canary
New-Parameter -Name Platform -ValidValues ($EdgeReleases.Platform | select -Unique) -Type ([string]) -Mandatory # Windows,Linux,MacOS,Android
if($Platform)
{
$EdgeReleases = $EdgeReleases | ?{$_.Platform -eq $Platform}
New-Parameter -Name Architecture -ValidValues ($EdgeReleases.Architecture | select -Unique) -Type ([string]) -Mandatory # x86,x64,arm64,universal
if($Architecture)
{
$EdgeReleases = $EdgeReleases | ?{$_.Architecture -eq $Architecture}
New-Parameter -Name Version -ValidValues ($EdgeReleases.ProductVersion | select -Unique) -Type ([string]) -Mandatory
}
}
}
}
end
{
}
}
```

## Specification

1. **Incremental Binding**: As `RuntimeDefinedParameter` objects are written to the output in the `dynamicparam` block, they are immediately bound if an argument is available. This behavior differs from the current state, where all parameters are bound after the entire `dynamicparam` block has executed.

2. **Variable Creation**: When a `RuntimeDefinedParameter` is bound, a variable with the name of that parameter should be created in the `dynamicparam` block scope. This makes the bound value immediately available for subsequent logic in the `dynamicparam` block.

3. **Backward Compatibility**: This change must not break scripts or modules that use the `dynamicparam` block but do not expect incremental binding. It's crucial to ensure the new behavior doesn't adversely affect existing implementations.

## Alternate Proposals and Considerations

1. **Explicit Flag for Incremental Binding**: Instead of changing the default behavior, introduce an `IncrementalBind` flag to `CmdletBindingAttribute` or create an `IncrementalBindingAttribute` that enables incremental binding. This would allow script authors to opt-in to the new behavior explicitly.

2. **Expose BindParameter() in dynamicparam block**: Syntax could look like
```powershell
dynamicparam
{
$ProductParam = [RuntimeDefinedParameter]::new('Product', [string], $productParamAttributes)

$_.BindParameter($ProductParam)
$Product = $PSBoundParameters['Product']
# or
$Product = $this.BindParameter($ProductParam)
# or
if($_.TryBindParameter($ProductParam, out $Product))
{
...
}
}
```

1. **Performance Considerations**: Depending on the implementation, there could be performance implications. The exact performance impact should be assessed during the development phase, and optimizations should be considered to ensure that this feature doesn't introduce significant overhead.

2. **Discoverability and Documentation Considerations**: Perhaps the most reasonable objection to this RFC is discoverability of incrementally bound parameters by Get-Help. For this I propose we either:
- Do nothing as this is already a problem with dynamic parameters that are conditionally present based off the bound value of static parameters
- `Get-Help` could append `This function has dynamic parameters and may require additional parameters not documented here, supply -ArgumentList parameter for additional details` to commands that implement dynamic/incrementally bound parameters

The syntax could look like `Get-Help MyIncrementallyBindableFunction -Product Edge`

This is similar to `--help` or `/h` options for many command line tools where you can get details about sub-commands.

This would be relatively easy to wire up as:

1. `Get-Help` doesn't have any parameters with `ValueFromRemainingArguments`
2. We could pass `-ArgumentList` to `Get-Command` to within `Get-Help` so the incrementally bound parameters become visible

Please note that the above proposal is a starting point and would benefit from feedback, especially concerning discoverability and performance.