Is it possible to extend indexers in PowerShell? - powershell

PowerShell's type extension facility is neat, but I haven't yet figured out the way -- if one exists -- to extend an indexer. I've tried to add a ScriptProperty for the indexer property (Chars in the case of System.String) and a ScriptMethod for the getter (get_Chars), but neither approach seems fruitful. Is it at all possible, or am I wasting my time? :)
[Edit] Apparently the proper member type is ParameterizedProperty, but when I try that, I get:
Add-Member : Cannot add a member with type "ParameterizedProperty". Specify a different
type for the MemberTypes parameter.
At line:1 char:11
+ Add-Member <<<< -MemberType ParameterizedProperty -Name Item -InputObject $string { "x" }
+ CategoryInfo : InvalidOperation: (:) [Add-Member], InvalidOperationException
+ FullyQualifiedErrorId : CannotAddMemberType,Microsoft.PowerShell.Commands.AddMemberCommand

I'm going to conclude that the error message I'm getting is the final word on the matter. Also, on further reflection it has become obvious that the sort of extending I was hoping for is not supported by this mechanism anyway. :-)

You can't create ParameterizedProperty properties directly in Powershell, but you are able to indirectly create them by allowing Powershell to wrap a PSObject around an object that has an accessor property. You then make this PSObject a NoteProperty on the object to which you want to add the property.
In C#, we are talking about a this[] accessor. I have written a Powershell script which creates a minimal .NET object which has as this[] accessor. In order to make this as generic as possible, I have tried to copy what the ScriptProperty member does, and I have added two properties of type ScriptBlock - one for a Get block, and another for a Set block. So essentially, when the user sets the this[] accessor, it calls the Set block, and when the user retrieves from the this[] accessor, it calls the Get block.
The following module, I have called PSObjectWrappers.psm1:
<#
.SUMMARY
Creates a new ParameterizedPropertyAccessor object.
.DESCRIPTION
Instantiates and returns an object compiled on the fly which provides some plumbing which allows a user to call a new Parameterized
Property, which looks as if it is created on the parent object. In fact, a NoteProperty is created on the parent object which retrieves
an instance of ParameterizedPropertyAccessor, which has a this[] accessor which Powershell wraps in a ParameterizedProperty object.
When the this[] accessor is retrieved, it tries to retrieve a value via a Get script block. When the this[] accessor is updated, this
triggers a Set script block.
.NOTES
No actual variable value state is stored by this object.
The C# code is conditionally compiled to take advantage of new functionality in Powershell 4. Before this version, the first parameter
in the Set and Get script blocks must be "[PSObject] $this". From this version, the $this parameter is automatically created for the user.
#>
Function New-ParameterizedPropertyAccessor
{
Param(
# Contains the object on which the "ParameterizedProperty" will be added.
[Parameter(Mandatory = $true, Position = 0)]
[PSObject] $Parent,
# The name of the parameterized property.
[Parameter(Mandatory = $true, Position = 1)]
[string] $Name,
# Script block which will be called when the property is retrieved.
# First parameter must be $this. Second parameter must be $key.
[Parameter(Mandatory = $true, Position = 2)]
[scriptblock] $Get,
# Script block which will be called when the property is set.
# First parameter must be $this. Second parameter must be $key. Third parameter must be $value.
[Parameter(Mandatory = $true, Position = 3)]
[scriptblock] $Set
);
# Note. You *MUST* ensure the next line starts at position 1 on the line. Likewise, the last line of the code *MUST*
# start at position 1 on the line.
$csharpCode = #'
using System;
using System.Collections.Generic;
using System.Management.Automation;
public class ParameterizedPropertyAccessor
{
private PSObject _parentPsObject;
private ScriptBlock _getBlock;
private ScriptBlock _setBlock;
public ParameterizedPropertyAccessor(PSObject parentPsObject, string propertyName, ScriptBlock getBlock, ScriptBlock setBlock)
{
_parentPsObject = parentPsObject;
PSVariable psVariable = new PSVariable(propertyName, this, ScopedItemOptions.ReadOnly);
PSVariableProperty psVariableProperty = new PSVariableProperty(psVariable);
_parentPsObject.Properties.Add(psVariableProperty);
_getBlock = getBlock;
_setBlock = setBlock;
}
public object this[object key]
{
get
{
#if WITH_CONTEXT
return _getBlock.InvokeWithContext(null, new List<PSVariable> { new PSVariable("this", _parentPsObject) }, new object[] { key });
#else
return _getBlock.Invoke(new object[] { _parentPsObject, key });
#endif
}
set
{
#if WITH_CONTEXT
_setBlock.InvokeWithContext(null, new List<PSVariable> { new PSVariable("this", _parentPsObject) }, new object[] { key, value });
#else
_setBlock.Invoke(new object[] { _parentPsObject, key, value });
#endif
}
}
}
'#;
<#
The version of the ScriptBlock object in Powershell 4 and above allows us to create automatically declared
context variables. In this case, we are providing a $this object, like you would get if we were using a
ScriptMethod or ScriptProperty member script. If we are using this version, then set the WITH_CONTEXT symbol
to conditionally compile a version of the C# code above which takes advantage of this.
#>
If ($PSVersionTable.PSVersion.Major -ge 4)
{
$compilerParameters = New-Object System.CodeDom.Compiler.CompilerParameters;
$compilerParameters.CompilerOptions = "/define:WITH_CONTEXT";
$compilerParameters.ReferencedAssemblies.Add( "System.dll" );
$compilerParameters.ReferencedAssemblies.Add( "System.Core.dll" );
$compilerParameters.ReferencedAssemblies.Add( ([PSObject].Assembly.Location) );
}
# Compiles the C# code in-memory and allows us to instantiate it.
Add-Type -TypeDefinition $csharpCode -CompilerParameters $compilerParameters;
# Instantiates the object.
New-Object ParameterizedPropertyAccessor -ArgumentList $Parent,$Name,$Get,$Set;
}
Note that I have done so conditional compilation in the C# code to make the code behave like a proper ScriptBlock in Powershell 4 and above, so a $this variable is automatically provided. Otherwise, you must ensure that the first parameter in each script block is called $this.
The following is my test script, Test-PPA.ps1:
<#
.SYNOPSIS
Test script for the ParameterizedPropertyAccessor object.
#>
<#
.SYNOPSIS
Create a new PSCustomObject which will contain a NoteProperty called Item accessed like a ParameterizedProperty.
#>
Function New-TestPPA
{
# Instantiate our test object.
$testPPA = New-Object -TypeName PSCustomObject;
# Create a new instance of our PPA object, added to our test object, providing it Get and Set script blocks.
# Note that currently the scripts are set up for Powershell 4 and above. If you are using a version of Powershell
# previous to this, comment out the current Param() values, and uncomment the alternate Param() values.
$ppa = New-ParameterizedPropertyAccessor -Parent $testPPA -Name Item -Get `
{
Param(
<#
[Parameter(Mandatory = $true, Position = 0)]
[PSObject] $this,
[Parameter(Mandatory = $true, Position = 1)]
[string] $Key
#>
[Parameter(Mandatory = $true, Position = 0)]
[string] $Key
)
$this._ht[$Key];
} -Set {
Param(
<#
[Parameter(Mandatory = $true, Position = 0)]
[PSObject] $this,
[Parameter(Mandatory = $true, Position = 1)]
[string] $Key,
[Parameter(Mandatory = $true, Position = 2)]
[string] $Value
#>
[Parameter(Mandatory = $true, Position = 0)]
[string] $Key,
[Parameter(Mandatory = $true, Position = 1)]
[string] $Value
)
$this._ht[$Key] = $Value;
};
# Add a HashTable <_ht> used as our backing store. Note that this could be any keyed collection type object.
$testPPA | Add-Member -MemberType NoteProperty -Name _ht -Value #{} -PassThru;
}
[string] $scriptDir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent;
Import-Module $scriptDir\PSObjectWrappers.psm1;
# Create test object.
$testPPA = New-TestPPA;
# Note that "Item" property is actually a NoteProperty of type ParameterizedPropertyAccessor.
Write-Host "Type '`$testPPA | gm' to see Item NoteProperty.";
# Note that it is the ParameterizedPropertyAccessor object retrieved that has a ParameterizedProperty.
# Also note that Powershell has named this property "Item".
Write-Host "Type '`$testPPA.Item | gm' to see Item ParameterizedProperty";
# Step through what happens when we "set" the "parameterized" Item property.
# Note that this is actually retrieving the Item NoteProperty, and then setting its default accessor, which calls
# the 'Set' ScriptBlock.
Write-Host "";
Write-Host "Setting Name value";
Write-Host "... to 'Mark'."
$testPPA.Item["Name"] = "Mark";
# Step through what happens when we "get" the "parameterized" Item property.
# Note that this is actually retrieving the Item NoteProperty, and then retrieving its default accessor, which calls
# the 'Get' ScriptBlock.
Write-Host "";
Write-Host "Retrieving Name value:";
$temp = $testPPA.Item["Name"];
Write-Host $temp;
Note that you will have to change the script blocks, as indicated, if you are using versions previous to Powershell 4.

Related

How to call a script with unknown parameters

I have a script that calls other scripts that other people manage. It's essentially a CI/CD script that gives users the ability to tap into the pipeline.
The issue I'm running into now is that I would like this calling script to implement a couple new parameters. However, the old scripts don't always implement those parameters.
If I call their script that doesn't implement the parameters, I get an error "A parameter cannot be found that matches parameter name 'newparameter'".
Is there a way to dynamically pass in a parameter so that it doesn't fail if the parameter doesn't exist? I don't mind if they don't implement it. It's a bonus parameter that they don't need to use.
Alternately, can I do something like a Get-Command for a custom .ps1 script, to get a list of accepted parameters? With that, I could confirm that a parameter is implemented before I pass it.
This might help you get started, you could use the Parser Class
to get all functions and it's parameters from a script, this answer shows a minimal reproduction. I'll leave it to you to investigate further.
Given myScript.ps1 that has these 3 functions:
function ExampleFunc {
param([int] $param1 = 123, [string] $param2)
}
function ExampleFunc2 {
param([object] $param3, [switch] $param4)
}
function ExampleFunc3 ($param5, [hashtable] $param6 = #{foo = 'var'}) {
}
You can use the ParseFile Method to get the AST, then you can use the .FindAll method to filter for all FunctionDefinitionAst and subsequently find all parameters filtering for all ParameterAst.
using namespace System.Management.Automation.Language
$ast = [Parser]::ParseFile('path\to\myScript.ps1', [ref] $null, [ref] $null)
$ast.FindAll({ $args[0] -is [FunctionDefinitionAst] }, $true) | ForEach-Object {
$out = [ordered]#{ Function = $_.Name }
$_.FindAll({ $args[0] -is [ParameterAst] }, $true) | ForEach-Object {
$out['ParameterName'] = $_.Name.VariablePath
$out['Type'] = $_.StaticType
$out['DefaultValue'] = $_.DefaultValue
[pscustomobject] $out
}
} | Format-Table
Above code would result in the following for myScript.ps1:
Function ParameterName Type DefaultValue
-------- ------------- ---- ------------
ExampleFunc param1 System.Int32 123
ExampleFunc param2 System.String
ExampleFunc2 param3 System.Object
ExampleFunc2 param4 System.Management.Automation.SwitchParameter
ExampleFunc3 param5 System.Object
ExampleFunc3 param6 System.Collections.Hashtable #{foo = 'var'}
The same could be accomplished using Get-Command:
(Get-Command 'fullpath\to\myScript.ps1').ScriptBlock.Ast.FindAll({
... same syntax as before ... }, $true # or $false for non-recursive search
)

Powershell splatting operator only for accepted parameters?

Is it possible, using PowerShell, to use splatting from hashtable when the hashtable contains more entries that the function accepts ?
My use case is to have config objects I pass from one function to another. However, all functions does not require same parameters.
Ex:
function Process-Something{
param(
[Parameter()]
[string]$Owner
)
}
function Process-SomethingElse{
param(
[Parameter()]
[string]$Owner,
[Parameter()]
[int]$x,
[Parameter()]
[int]$y
)
}
$config = #{
"Owner" = "Bart Simpson"
"X" = 10
"Y" = 20
}
Process-Something #config
Process-SomethingElse #config
It fails with these error:
Process-Something : Cannot find a matching parameter « Y ».
The idea is to avoid specifying individual properties for each functions.
As #Ansgar is stating in the comments, the whole idea of having defined your parameters, is to get validation. When you are splatting parameters to your function, you are forcing them to the function. So if a given property of your hashtable doesn't exist as a parameter, you will get an error - just like it is intended.
What you can do, is going into a PSCustomObject and utilize the pipe. If you set all you parameters to accept value from the pipeline, using property name (ValueFromPipelineByPropertyName = $true), then you can actually get the desired behavior.
First I'm redefining your different functions, to have the ValueFromPipelineByPropertyName = $true parameter attribute configured.
function Process-Something{
param(
[Parameter(ValueFromPipelineByPropertyName = $true)]
[string]$Owner
)
$PSBoundParameters
}
function Process-SomethingElse{
param(
[Parameter(ValueFromPipelineByPropertyName = $true)]
[string]$Owner,
[Parameter(ValueFromPipelineByPropertyName = $true)]
[int]$x,
[Parameter(ValueFromPipelineByPropertyName = $true)]
[int]$y
)
$PSBoundParameters
}
With that in place, I'm able to create a hashtable like your example, convert it to a PSCustomObject, and now I can pipe that new object to the different methods and have them pick up only the properties that they need.
I included the PSBoundParameters to showcase that they get what they expect.
Testing is done like this:
$config = #{
"Owner" = "Bart Simpson"
"X" = 10
"Y" = 20
}
$psConfig = [PSCustomObject]$config
$psConfig | Process-Something
$psConfig | Process-SomethingElse

How do you call a PowerShell function with an Object of Arguments

In PowerShell, one can generally call a function with arguments as follows:
DoRoutineStuff -Action 'HouseKeeping' -Owner 'Adamma George' -Multiples 4 -SkipEmail
To trap these 4 supplied arguments at runtime, one might place this inside the function definition
""
"ARGUMENTS:"
$PSBoundParameters
And the resulting object displayed might look like so:
ARGUMENTS:
Key Value
--- -----
Action HouseKeeping
Owner Adamma George
Multiples 4
SkipEmail True
Now, my question is: If I were to manually build the $MyObject identical to $PSBoundParameters displayed above, is there a way to say:
RunFunction 'DoRoutineStuff' -oArgument $MyObject
Again, if it were to be a script file rather than the function DoRoutineStuff, does that make any difference?
Why might one need to do this?
Picture a situation where you need to catch the arguments supplied to first script or function, using $PSBoundParameters, like so:
DoRoutineStuff{
param(
[string]$Action,
[string]$Owner,
[Int]$Multiples,
[switch]$SkipEmail
)
$Data = $PSBoundParameters
#Update one object property
$Data.Multiples = 1
#Then, recursively call `DoRoutineStuff` using `$Data`
#Other tasks
exit;
}
It sounds like the language feature you're looking for is splatting.
You simply pack you're named parameter arguments into a hashtable, store that in a variable and then pass the variable using # in front of its name:
$myArguments = #{
Action = 'HouseKeeping'
Owner = 'Adamma George'
Multiples = 4
SkipEmail = $true
}
Do-Stuff #myArguments
You can also use this technique to only pass a partial set of parameter arguments (or none at all), great for passing along conditional arguments:
$myArguments = #{}
if($someCondition){
$myArguments['Multiples'] = 1
$myArguments['SkipEmail'] = $true
}
if($somethingElse){
$myArguments['Multiple'] = 4
}
Do-Stuff -Action 'HouseKeeping' -Owner 'Adamma George' #myArguments
You can also reuse $PSBoundParameters for splatting further - very useful for proxy functions:
function Measure-Files
{
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $false)]
[string]$Filter,
[Parameter(Mandatory = $false)]
[switch]$Recurse
)
return (Get-ChildItem #PSBoundParameters |Measure-Object -Property Length).Sum
}

Parameter binding by name through pipeline when some values are empty

In my module, I have two functions a 'Get-Data' and 'Add-Data' I'm having trouble passing information between them through the pipeline.
The following simplified code shows my issue.
function Get-Data{
param ($path)
$out = Import-Csv $path
Write-Output $out
}
The data is the following, notice there are 'gaps' in the data and not every object has all three properties, in fact they cant. You can only have Name and Color or Name and Fruit. (In the real code there are many more properties)
Name,Color,Fruit
Jim,red,
Kate,,Apple
Bob,green,
Abby,,Banana
In the Add-Data function I want to use parameter sets as there are lots of parameters, but only 4 parameter sets possible (two in the simplified code, 'A' and 'B'). This function is exported from the module and I don't want the user to be able to input invalid parameter combinations.
Here is the add-data function:
function Add-Data {
[CmdletBinding()]
Param (
[Parameter(
Position = 1,
ValuefromPipelineByPropertyName = $true,
Mandatory = $true
)]
[System.String]$Name,
[Parameter(
ParameterSetName = 'A',
Position = 2,
ValuefromPipelineByPropertyName = $true,
Mandatory = $true
)]
[System.String]$Color,
[Parameter(
ParameterSetName = 'B',
Position = 3,
ValuefromPipelineByPropertyName = $true,
Mandatory = $true
)]
[System.String]$Fruit
)
BEGIN{}
PROCESS{
Write-Output "$Name $Color $Fruit"
}
END{}
}
When I pipe one to the other like below:
Get-Data -Path c:\some.csv | Add-Data
I get the error:
Cannot bind argument to parameter 'Fruit' because it is an empty string
I know that I am passing an empty string to the fruit parameter and it's what is causing me the issue, but it's obviously not the behaviour I want.
I'd like the fact that the property is empty to help resolve the parameter set so it only needs Name and Color.
I can't use [AllowNull()] or [AllowEmptyString()] and remove the parameter sets as I expect users to use 'add-data' from the command line and I want
help add-data
to show them the correct params to enter.
Thanks in advance for any help.

PowerShell Param ValidateSet values with Spaces and Tab completion

First, I do apologize for posting another question concerning PowerShell and tab completion. The StackOverflow system identified several excellent questions with answers concerning this very topic, but they all seemed too cumbersome to implement into this simple New-ADComputer script.
The params are going into a Splat to keep the script readable. The following code correctly tab completes in the ISE, but must be wrapped in double quotes.
Is there any native method in PowerShell to allow for tab completion of Parameter Sets that include spaces?
Param(
[Parameter(Mandatory=$true)]
[string]$Server,
[Parameter(Mandatory=$true)]
[ValidateSet('Env1','Env 2','Env 3')]
[string]$Environment,
[Parameter(Mandatory=$true)]
[ValidateSet('Application','Database','File and Print','Web Server')]
[string]$Type
)
$NewADitems = #{
Name = $server
Path = "OU=$Type,OU=$Environment,OU=Smaller DN string"
Location ='MySite'
Description = "Test Description"
ManagedBy = "Huge Distingushed Name string"
WhatIf = $true
}
Write-Host #NewADitems
Command used and error received:
PS C:\Scripts> .\ADComputer-ParamTest.ps1 -Server ThisTest -Environment Env 3 -Type File and Print
C:\Scripts\ADComputer-ParamTest.ps1 : Cannot validate argument on parameter
'Environment'. The argument "Env" does not belong to the set "Env1,Env 2,Env3"
specified by the ValidateSet attribute. Supply an argument that is in the
set and then try the command again.At line:1 char:58
+ .\ADComputer-ParamTest.ps1 -Server ThisTest -Environment Env 3 -Type File and Pr ...
+ ~~~
Edit: More information. If you leave off the single/double quotes in my example script for the parameter Environment, tab completion will not work for the final parameter Type. Enclosing the 2nd set in quotes will correct this but it's a way to keep watch for this behavior.
No, at least up to Powershell 5.0 April 2015 preview. Tab completion works as you describe. It will still need the quotes around the set to actually work without throwing the error. For what it's worth, it does add the closing quote of the matching type when you start the tab completion with a quote. For example, pressing "f then Tab will complete to "File and Print"(not sure when that was added as a feature).
I tried finding ways to auto-include the quotes as part of the ValidateSet including additional double quotes around the parameter sets and other attempts at escaping quotes. All attempts resulted in tab completion not working in various ways.
Some of the attempts, in case anyone might try that avenue:
[ValidateSet('Env1','"Env 2"','"Env 3"')]
[ValidateSet('Env1',"'Env 2'","'Env 3'")]
[ValidateSet('Env1','`"Env 2`"',"`'Env 3`'")]
[ValidateSet('Env1','\"Env 2\"',"\'Env 3\'")]
This has been entered as a bug since 2013. According to the workarounds listed in Auto-completed parameter values, with spaces, do not have quotes around them, you can update the TabExpansion2 function that Powershell uses for autocompletion. To do so, just run the following code:
function TabExpansion2
{
[CmdletBinding(DefaultParameterSetName = 'ScriptInputSet')]
Param(
[Parameter(ParameterSetName = 'ScriptInputSet', Mandatory = $true, Position = 0)]
[string] $inputScript,
[Parameter(ParameterSetName = 'ScriptInputSet', Mandatory = $true, Position = 1)]
[int] $cursorColumn,
[Parameter(ParameterSetName = 'AstInputSet', Mandatory = $true, Position = 0)]
[System.Management.Automation.Language.Ast] $ast,
[Parameter(ParameterSetName = 'AstInputSet', Mandatory = $true, Position = 1)]
[System.Management.Automation.Language.Token[]] $tokens,
[Parameter(ParameterSetName = 'AstInputSet', Mandatory = $true, Position = 2)]
[System.Management.Automation.Language.IScriptPosition] $positionOfCursor,
[Parameter(ParameterSetName = 'ScriptInputSet', Position = 2)]
[Parameter(ParameterSetName = 'AstInputSet', Position = 3)]
[Hashtable] $options = $null
)
End
{
if ($psCmdlet.ParameterSetName -eq 'ScriptInputSet')
{
$completion = [System.Management.Automation.CommandCompletion]::CompleteInput(
$inputScript,
$cursorColumn,
$options)
}
else
{
$completion = [System.Management.Automation.CommandCompletion]::CompleteInput(
$ast,
$tokens,
$positionOfCursor,
$options)
}
$count = $completion.CompletionMatches.Count
for ($i = 0; $i -lt $count; $i++)
{
$result = $completion.CompletionMatches[$i]
if ($result.CompletionText -match '\s')
{
$completion.CompletionMatches[$i] = New-Object System.Management.Automation.CompletionResult(
"'$($result.CompletionText)'",
$result.ListItemText,
$result.ResultType,
$result.ToolTip
)
}
}
return $completion
}
}
It's worth noting that string insertion works properly for native cmdlets like Get-EventLog -LogName which will properly encase 'Internet Explorer'. Although if you look at the source for Get-EventLog, you'll see that $LogName doesn't actually use ValidateSet so it's intellisense must be provided through another mechanism.
Other Instances:
ValidateSet and tab completion does not work on strings with spaces