diff --git a/src/System.Management.Automation/engine/Attributes.cs b/src/System.Management.Automation/engine/Attributes.cs index 0961b26e8ce..fbf200571a6 100644 --- a/src/System.Management.Automation/engine/Attributes.cs +++ b/src/System.Management.Automation/engine/Attributes.cs @@ -4,12 +4,15 @@ 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 @@ public sealed class ValidatePatternAttribute : ValidateEnumeratedArgumentsAttrib /// /// 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. /// @@ -1177,10 +1180,10 @@ public sealed class ValidateScriptAttribute : ValidateEnumeratedArgumentsAttribu { /// /// 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. - /// + /// /// /// [ValidateScript("$_ % 2", ErrorMessage = "The item '{0}' did not pass validation of script '{1}'")] /// @@ -1353,20 +1356,82 @@ public ValidateCountAttribute(int minLength, int maxLength) } } + /// + /// Optional base class for implementations that want a default implementation to cache valid values. + /// + public abstract class CachedValidValuesGeneratorBase : IValidateSetValuesGenerator + { + // Cached valid values. + private string[] _validValues; + private int _validValuesCacheExpiration; + + /// + /// Initializes a new instance of the CachedValidValuesGeneratorBase class. + /// + /// Sets a time interval in seconds to reset the '_validValues' dynamic valid values cache. + protected CachedValidValuesGeneratorBase(int cacheExpirationInSeconds) + { + _validValuesCacheExpiration = cacheExpirationInSeconds; + } + + /// + /// Abstract method to generate a valid values. + /// + public abstract string[] GenerateValidValues(); + + /// + /// Get a valid values. + /// + 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; + } + } + /// /// Validates that each parameter argument is present in a specified set /// [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 s_ValidValuesGeneratorCache = new ConcurrentDictionary(); /// /// 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. - /// + /// /// /// [ValidateSet("A","B","C", ErrorMessage="The item '{0}' is not part of the set '{1}'.") /// @@ -1380,13 +1445,28 @@ public sealed class ValidateSetAttribute : ValidateEnumeratedArgumentsAttribute public bool IgnoreCase { get; set; } = true; /// - /// Gets the values in the set + /// Gets the valid values in the set. /// public IList 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 @@ protected override void ValidateElement(object element) } 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 @@ public ValidateSetAttribute(params string[] validValues) _validValues = validValues; } + + /// + /// Initializes a new instance of the ValidateSetAttribute class. + /// Valid values is returned dynamically from a custom class implementing 'IValidateSetValuesGenerator' interface. + /// + /// class that implements the 'IValidateSetValuesGenerator' interface + /// for null arguments + 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)); + } + } + + /// + /// Allows dynamically generate set of values for ValidateSetAttribute. + /// + public interface IValidateSetValuesGenerator + { + /// + /// Get a valid values. + /// + string[] GetValidValues(); } #region Allow diff --git a/src/System.Management.Automation/engine/parser/Compiler.cs b/src/System.Management.Automation/engine/parser/Compiler.cs index 34c0828daa3..9972e7ff1f8 100644 --- a/src/System.Management.Automation/engine/parser/Compiler.cs +++ b/src/System.Management.Automation/engine/parser/Compiler.cs @@ -1298,15 +1298,35 @@ private static Attribute NewAliasAttribute(AttributeAst ast) 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 @@ public object VisitPipeline(PipelineAst pipelineAst) { 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 @@ public object VisitPipeline(PipelineAst pipelineAst) { Expression input; int i, commandsInPipe; - + if (firstCommandExpr != null) { if (firstCommandExpr.Redirections.Count > 0) @@ -3035,7 +3055,7 @@ public object VisitPipeline(PipelineAst pipelineAst) // 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 @@ public object VisitPipeline(PipelineAst pipelineAst) 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 @@ public object VisitPipeline(PipelineAst pipelineAst) // No redirections. redirectionExpr = ExpressionCache.NullCommandRedirections; } - + if (firstCommandExpr != null) { var inputTemp = Expression.Variable(input.Type); @@ -3084,7 +3104,7 @@ public object VisitPipeline(PipelineAst pipelineAst) exprs.Add(Expression.Assign(inputTemp, input)); input = inputTemp; } - + Expression invokePipe = Expression.Call( CachedReflectionInfo.PipelineOps_InvokePipeline, input.Cast(typeof(object)), diff --git a/src/System.Management.Automation/resources/Metadata.resx b/src/System.Management.Automation/resources/Metadata.resx index 14c10bbe25a..d223e3d2944 100644 --- a/src/System.Management.Automation/resources/Metadata.resx +++ b/src/System.Management.Automation/resources/Metadata.resx @@ -168,6 +168,9 @@ 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. + + Valid values generator return a null value. + "{0}" failed on property "{1}" {2} diff --git a/test/powershell/Language/Classes/Scripting.Classes.Attributes.Tests.ps1 b/test/powershell/Language/Classes/Scripting.Classes.Attributes.Tests.ps1 index e29e4676477..d378a1abfbd 100644 --- a/test/powershell/Language/Classes/Scripting.Classes.Attributes.Tests.ps1 +++ b/test/powershell/Language/Classes/Scripting.Classes.Attributes.Tests.ps1 @@ -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" + } + + } +}