ValidateSetAttribute enhancement: support set values to be dynamically generated from a custom ValidateSetValueGenerator (#3784)

Currently `ValidateSetAttribute` accepts only explicit constants as valid values. This is a significant limitation. Sometimes we need to get valid values dynamically, ex., Azure VMs, logged-on users and so on. The PR add follow possibilities:
- pass a _custom type_ (a valid values generator) implementing `IValidateSetValuesGenerator` interface to get valid values dynamically.
- pass a _custom type_ (a valid values generator) derived from `CachedValidValuesGeneratorBase` abstract class to get valid values dynamically and _cache_ the list to share with other ValidateSetAttribute attributes.
This commit is contained in:
Ilya
2017-07-13 21:28:32 -07:00
committed by Dongbo Wang
parent 67266f6aa0
commit dc76c86f7a
4 changed files with 415 additions and 24 deletions
@@ -4,12 +4,15 @@ Copyright (c) Microsoft Corporation. All rights reserved.
using System.Collections;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using System.Globalization;
using System.Management.Automation.Internal;
using System.Diagnostics.CodeAnalysis;
using System.Management.Automation.Language;
using System.Runtime.CompilerServices;
using System.Linq;
using System.Threading.Tasks;
namespace System.Management.Automation.Internal
{
@@ -1115,7 +1118,7 @@ namespace System.Management.Automation
/// <summary>
/// Gets or sets the custom error message pattern that is displayed to the user.
///
///
/// The text representation of the object being validated and the validating regex is passed as
/// the first and second formatting parameters to the ErrorMessage formatting pattern.
/// <example>
@@ -1177,10 +1180,10 @@ namespace System.Management.Automation
{
/// <summary>
/// Gets or sets the custom error message that is displayed to the user.
///
///
/// The item being validated and the validating scriptblock is passed as the first and second
/// formatting argument.
///
///
/// <example>
/// [ValidateScript("$_ % 2", ErrorMessage = "The item '{0}' did not pass validation of script '{1}'")]
/// </example>
@@ -1353,20 +1356,82 @@ namespace System.Management.Automation
}
}
/// <summary>
/// Optional base class for <see cref="IValidateSetValuesGenerator"/> implementations that want a default implementation to cache valid values.
/// </summary>
public abstract class CachedValidValuesGeneratorBase : IValidateSetValuesGenerator
{
// Cached valid values.
private string[] _validValues;
private int _validValuesCacheExpiration;
/// <summary>
/// Initializes a new instance of the CachedValidValuesGeneratorBase class.
/// </summary>
/// <param name="cacheExpirationInSeconds">Sets a time interval in seconds to reset the '_validValues' dynamic valid values cache.</param>
protected CachedValidValuesGeneratorBase(int cacheExpirationInSeconds)
{
_validValuesCacheExpiration = cacheExpirationInSeconds;
}
/// <summary>
/// Abstract method to generate a valid values.
/// </summary>
public abstract string[] GenerateValidValues();
/// <summary>
/// Get a valid values.
/// </summary>
public string[] GetValidValues()
{
// Because we have a background task to clear the cache by '_validValues = null'
// we use the local variable to exclude a race condition.
var validValuesLocal = _validValues;
if (validValuesLocal != null)
{
return validValuesLocal;
}
var validValuesNoCache = GenerateValidValues();
if (validValuesNoCache == null)
{
throw new ValidationMetadataException(
"ValidateSetGeneratedValidValuesListIsNull",
null,
Metadata.ValidateSetGeneratedValidValuesListIsNull);
}
if (_validValuesCacheExpiration > 0)
{
_validValues = validValuesNoCache;
Task.Delay(_validValuesCacheExpiration * 1000).ContinueWith((task) => _validValues = null);
}
return validValuesNoCache;
}
}
/// <summary>
/// Validates that each parameter argument is present in a specified set
/// </summary>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)]
public sealed class ValidateSetAttribute : ValidateEnumeratedArgumentsAttribute
{
// We can use either static '_validValues'
// or dynamic valid values list generated by instance of 'validValuesGenerator'.
private string[] _validValues;
private IValidateSetValuesGenerator validValuesGenerator = null;
// The valid values generator cache works across 'ValidateSetAttribute' instances.
private static ConcurrentDictionary<Type, IValidateSetValuesGenerator> s_ValidValuesGeneratorCache = new ConcurrentDictionary<Type, IValidateSetValuesGenerator>();
/// <summary>
/// Gets or sets the custom error message that is displayed to the user
///
///
/// The item being validated and a text representation of the validation set
/// is passed as the first and second formatting argument to the ErrorMessage formatting pattern.
///
///
/// <example>
/// [ValidateSet("A","B","C", ErrorMessage="The item '{0}' is not part of the set '{1}'.")
/// </example>
@@ -1380,13 +1445,28 @@ namespace System.Management.Automation
public bool IgnoreCase { get; set; } = true;
/// <summary>
/// Gets the values in the set
/// Gets the valid values in the set.
/// </summary>
public IList<string> ValidValues
{
get
{
return _validValues;
if (validValuesGenerator == null)
{
return _validValues;
}
var validValuesLocal = validValuesGenerator.GetValidValues();
if (validValuesLocal == null)
{
throw new ValidationMetadataException(
"ValidateSetGeneratedValidValuesListIsNull",
null,
Metadata.ValidateSetGeneratedValidValuesListIsNull);
}
return validValuesLocal;
}
}
@@ -1409,16 +1489,13 @@ namespace System.Management.Automation
}
string objString = element.ToString();
for (int setIndex = 0; setIndex < _validValues.Length; setIndex++)
foreach (string setString in ValidValues)
{
string setString = _validValues[setIndex];
if (CultureInfo.InvariantCulture.
CompareInfo.Compare(setString, objString,
IgnoreCase
? CompareOptions.IgnoreCase
: CompareOptions.None) == 0)
{
return;
}
@@ -1455,6 +1532,37 @@ namespace System.Management.Automation
_validValues = validValues;
}
/// <summary>
/// Initializes a new instance of the ValidateSetAttribute class.
/// Valid values is returned dynamically from a custom class implementing 'IValidateSetValuesGenerator' interface.
/// </summary>
/// <param name="valuesGeneratorType">class that implements the 'IValidateSetValuesGenerator' interface</param>
/// <exception cref="ArgumentException">for null arguments</exception>
public ValidateSetAttribute(Type valuesGeneratorType)
{
// We check 'IsNotPublic' because we don't want allow 'Activator.CreateInstance' create an instance of non-public type.
if (!typeof(IValidateSetValuesGenerator).IsAssignableFrom(valuesGeneratorType) || valuesGeneratorType.IsNotPublic)
{
throw PSTraceSource.NewArgumentException("valuesGeneratorType");
}
// Add a valid values generator to the cache.
// We don't cache valid values.
// We expect that valid values can be cached in the valid values generator.
validValuesGenerator = s_ValidValuesGeneratorCache.GetOrAdd(valuesGeneratorType, (key) => (IValidateSetValuesGenerator)Activator.CreateInstance(key));
}
}
/// <summary>
/// Allows dynamically generate set of values for ValidateSetAttribute.
/// </summary>
public interface IValidateSetValuesGenerator
{
/// <summary>
/// Get a valid values.
/// </summary>
string[] GetValidValues();
}
#region Allow
@@ -1298,15 +1298,35 @@ namespace System.Management.Automation.Language
private static Attribute NewValidateSetAttribute(AttributeAst ast)
{
ValidateSetAttribute result;
var cvv = new ConstantValueVisitor { AttributeArgument = true };
var args = new string[ast.PositionalArguments.Count];
for (int i = 0; i < ast.PositionalArguments.Count; i++)
// 'ValidateSet([CustomGeneratorType], IgnoreCase=$false)' is supported in scripts.
if (ast.PositionalArguments.Count == 1 && ast.PositionalArguments[0] is TypeExpressionAst generatorTypeAst)
{
args[i] = _attrArgToStringConverter.Target(_attrArgToStringConverter,
ast.PositionalArguments[i].Accept(cvv));
if (TypeResolver.TryResolveType(generatorTypeAst.TypeName.FullName, out Type generatorType))
{
result = new ValidateSetAttribute(generatorType);
}
else
{
throw InterpreterError.NewInterpreterException(ast, typeof(RuntimeException), ast.Extent,
"TypeNotFound", ParserStrings.TypeNotFound, generatorTypeAst.TypeName.FullName, typeof(System.Management.Automation.IValidateSetValuesGenerator).FullName);
}
}
else
{
// 'ValidateSet("value1","value2", IgnoreCase=$false)' is supported in scripts.
var args = new string[ast.PositionalArguments.Count];
for (int i = 0; i < ast.PositionalArguments.Count; i++)
{
args[i] = _attrArgToStringConverter.Target(_attrArgToStringConverter,
ast.PositionalArguments[i].Accept(cvv));
}
result = new ValidateSetAttribute(args);
}
var result = new ValidateSetAttribute(args);
foreach (var namedArg in ast.NamedArguments)
{
var argValue = namedArg.Argument.Accept(cvv);
@@ -2998,7 +3018,7 @@ namespace System.Management.Automation.Language
{
var pipeElements = pipelineAst.PipelineElements;
var firstCommandExpr = (pipeElements[0] as CommandExpressionAst);
if (firstCommandExpr != null && pipeElements.Count == 1)
{
if (firstCommandExpr.Redirections.Count > 0)
@@ -3014,7 +3034,7 @@ namespace System.Management.Automation.Language
{
Expression input;
int i, commandsInPipe;
if (firstCommandExpr != null)
{
if (firstCommandExpr.Redirections.Count > 0)
@@ -3035,7 +3055,7 @@ namespace System.Management.Automation.Language
// here so that we can tell the difference b/w $null and no input when
// starting the pipeline, in other words, PipelineOps.InvokePipe will
// not pass this value to the pipe.
input = ExpressionCache.AutomationNullConstant;
i = 0;
commandsInPipe = pipeElements.Count;
@@ -3043,16 +3063,16 @@ namespace System.Management.Automation.Language
Expression[] pipelineExprs = new Expression[commandsInPipe];
CommandBaseAst[] pipeElementAsts = new CommandBaseAst[commandsInPipe];
var commandRedirections = new object[commandsInPipe];
for (int j = 0; i < pipeElements.Count; ++i, ++j)
{
var pipeElement = pipeElements[i];
pipelineExprs[j] = Compile(pipeElement);
commandRedirections[j] = GetCommandRedirections(pipeElement);
pipeElementAsts[j] = pipeElement;
}
// The redirections are passed as a CommandRedirection[][] - one dimension for each command in the pipe,
// one dimension because each command may have multiple redirections. Here we create the array for
// each command in the pipe, either a compile time constant or created at runtime if necessary.
@@ -3076,7 +3096,7 @@ namespace System.Management.Automation.Language
// No redirections.
redirectionExpr = ExpressionCache.NullCommandRedirections;
}
if (firstCommandExpr != null)
{
var inputTemp = Expression.Variable(input.Type);
@@ -3084,7 +3104,7 @@ namespace System.Management.Automation.Language
exprs.Add(Expression.Assign(inputTemp, input));
input = inputTemp;
}
Expression invokePipe = Expression.Call(
CachedReflectionInfo.PipelineOps_InvokePipeline,
input.Cast(typeof(object)),
@@ -168,6 +168,9 @@
<data name="ValidateSetFailure" xml:space="preserve">
<value>The argument "{0}" does not belong to the set "{1}" specified by the ValidateSet attribute. Supply an argument that is in the set and then try the command again.</value>
</data>
<data name="ValidateSetGeneratedValidValuesListIsNull" xml:space="preserve">
<value>Valid values generator return a null value.</value>
</data>
<data name="ValidateFailureResult" xml:space="preserve">
<value>"{0}" failed on property "{1}" {2}</value>
</data>
@@ -216,3 +216,263 @@ Describe 'Type resolution with attributes' -Tag "CI" {
}
}
}
Describe 'ValidateSet support a dynamically generated set' -Tag "CI" {
Context 'C# tests' {
BeforeAll {
$a=@'
using System;
using System.Management.Automation;
using System.Collections.Generic;
namespace Test.Language {
[Cmdlet(VerbsCommon.Get, "TestValidateSet0")]
public class TestValidateSetCommand0 : PSCmdlet
{
[Parameter]
[ValidateSet(typeof(PSCmdlet))]
public string Param1;
protected override void EndProcessing()
{
WriteObject(Param1);
}
}
[Cmdlet(VerbsCommon.Get, "TestValidateSet4")]
public class TestValidateSetCommand4 : PSCmdlet
{
[Parameter]
[ValidateSet(typeof(GenValuesForParam))]
public string Param1;
protected override void EndProcessing()
{
WriteObject(Param1);
}
}
[Cmdlet(VerbsCommon.Get, "TestValidateSet5")]
public class TestValidateSetCommand5 : PSCmdlet
{
[Parameter]
[ValidateSet(typeof(GenValuesForParamNull))]
public string Param1;
protected override void EndProcessing()
{
WriteObject(Param1);
}
}
/// Implement of test IValidateSetValuesGenerator
public class GenValuesForParamNull : IValidateSetValuesGenerator
{
public string[] GetValidValues()
{
var testValues = new string[] {"Test1","TestString1","Test2"};
return null;
}
}
public class GenValuesForParam : IValidateSetValuesGenerator
{
public string[] GetValidValues()
{
var testValues = new string[] {"Test1","TestString1","Test2"};
return testValues;
}
}
}
'@
$testAssemply = "$TestDrive\tst-$(New-Guid).dll"
Add-Type -TypeDefinition $a -OutputAssembly $testAssemply
Import-Module $testAssemply
}
It 'Throw if IValidateSetValuesGenerator is not implemented' {
{ Get-TestValidateSet0 -Param1 "TestString" -ErrorAction Stop } | ShouldBeErrorId "Argument"
}
It 'Dynamically generated set works in C# with default (immediate) cache expire' {
Get-TestValidateSet4 -Param1 "TestString1" -ErrorAction SilentlyContinue | Should BeExactly "TestString1"
}
It 'Empty dynamically generated set throws in C#' {
$exc = {
Get-TestValidateSet5 -Param1 "TestString1" -ErrorAction Stop
} | ShouldBeErrorId "ParameterArgumentValidationError,Test.Language.TestValidateSetCommand5"
$exc.Exception.InnerException.ErrorRecord.FullyQualifiedErrorId | Should BeExactly "ValidateSetGeneratedValidValuesListIsNull"
}
}
Context 'Powershell tests' {
BeforeAll {
class GenValuesForParam : System.Management.Automation.IValidateSetValuesGenerator {
[String[]] GetValidValues() {
return [string[]]("Test1","TestString1","Test2")
}
}
class GenValuesForParamNull : System.Management.Automation.IValidateSetValuesGenerator {
[String[]] GetValidValues() {
return [string[]]$null
}
}
# Return '$testValues2' and after 2 seconds after first use return another array '$testValues1'.
class GenValuesForParamCache1 : System.Management.Automation.IValidateSetValuesGenerator {
[String[]] GetValidValues() {
$testValues1 = "Test11","TestString11","Test22"
$testValues2 = "Test11","TestString22","Test22"
$currentTime = [DateTime]::Now
if ([DateTime]::Compare([GenValuesForParamCache1]::cacheTime, $currentTime) -le 0)
{
$testValues = $testValues1;
}
else
{
$testValues = $testValues2;
}
return [string[]]$testValues
}
static [DateTime] $cacheTime = [DateTime]::Now.AddSeconds(2);
}
function Get-TestValidateSetPS4
{
[CmdletBinding()]
Param
(
[ValidateSet([GenValuesForParam])]
$Param1
)
$Param1
}
function Get-TestValidateSetPS5
{
[CmdletBinding()]
Param
(
[ValidateSet([GenValuesForParamNull])]
$Param1
)
$Param1
}
function Get-TestValidateSetPS6
{
[CmdletBinding()]
Param
(
[ValidateSet([UnImplementedGeneratorOfValues])]
$Param1
)
$Param1
}
}
It 'Dynamically generated set works in PowerShell script with default (immediate) cache expire' {
Get-TestValidateSetPS4 -Param1 "TestString1" -ErrorAction SilentlyContinue | Should BeExactly "TestString1"
}
It 'Empty dynamically generated set throws in PowerShell script' {
$exc = {
Get-TestValidateSetPS5 -Param1 "TestString1" -ErrorAction Stop
} | ShouldBeErrorId "ParameterArgumentValidationError,Get-TestValidateSetPS5"
$exc.Exception.InnerException.ErrorRecord.FullyQualifiedErrorId | Should BeExactly "ValidateSetGeneratedValidValuesListIsNull"
}
It 'Unimplemented valid values generator type throws in PowerShell script' {
{
Get-TestValidateSetPS6 -Param1 "AnyTestString" -ErrorAction Stop
} | ShouldBeErrorId "TypeNotFound"
}
}
Context 'CachedValidValuesGeneratorBase class tests' {
BeforeAll {
class GenValuesForParam : System.Management.Automation.CachedValidValuesGeneratorBase {
GenValuesForParam() : base(300) {
}
[String[]] GenerateValidValues() {
return [string[]]("Test1","TestString1","Test2")
}
}
class GenValuesWithExpiration : System.Management.Automation.CachedValidValuesGeneratorBase {
GenValuesWithExpiration() : base(2) {
}
Static [bool] $temp = $true;
[String[]] GenerateValidValues() {
if ([GenValuesWithExpiration]::temp) {
[GenValuesWithExpiration]::temp = $false
return [string[]]("Test1","TestString1","Test2")
} else {
[GenValuesWithExpiration]::temp = $true
return [string[]]("Test1","TestString2","Test2")
}
}
}
function Get-TestValidateSetPS4
{
[CmdletBinding()]
Param
(
[ValidateSet([GenValuesForParam])]
$Param1
)
$Param1
}
function Get-TestValidateSetPS5
{
[CmdletBinding()]
Param
(
[ValidateSet([GenValuesWithExpiration])]
$Param1
)
$Param1
}
}
It 'Can implement CachedValidValuesGeneratorBase in PowerShell' {
Get-TestValidateSetPS4 -Param1 "TestString1" -ErrorAction SilentlyContinue | Should BeExactly "TestString1"
}
It 'Can implement CachedValidValuesGeneratorBase with cache expiration in PowerShell' {
Get-TestValidateSetPS5 -Param1 "TestString1" -ErrorAction SilentlyContinue | Should BeExactly "TestString1"
Get-TestValidateSetPS5 -Param1 "TestString1" -ErrorAction SilentlyContinue | Should BeExactly "TestString1"
Start-Sleep 3
Get-TestValidateSetPS5 -Param1 "TestString2" -ErrorAction SilentlyContinue | Should BeExactly "TestString2"
}
}
}