diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Generator.cs b/src/libraries/Microsoft.Extensions.Options/gen/Generator.cs index d50acf275c67e..8fdfc60a82080 100644 --- a/src/libraries/Microsoft.Extensions.Options/gen/Generator.cs +++ b/src/libraries/Microsoft.Extensions.Options/gen/Generator.cs @@ -13,7 +13,7 @@ namespace Microsoft.Extensions.Options.Generators { [Generator] - public class Generator : IIncrementalGenerator + public class OptionValidatorGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Parser.cs b/src/libraries/Microsoft.Extensions.Options/gen/Parser.cs index 1ecd82cbe74c0..010b89562a917 100644 --- a/src/libraries/Microsoft.Extensions.Options/gen/Parser.cs +++ b/src/libraries/Microsoft.Extensions.Options/gen/Parser.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Globalization; using System.Linq; using System.Text; @@ -469,14 +470,36 @@ private List GetMembersToValidate(ITypeSymbol modelType, bool s var validationAttr = new ValidationAttributeInfo(attributeType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); validationAttrs.Add(validationAttr); - foreach (var constructorArgument in attribute.ConstructorArguments) + ImmutableArray parameters = attribute.AttributeConstructor?.Parameters ?? ImmutableArray.Empty; + bool lastParameterDeclaredWithParamsKeyword = parameters.Length > 0 && parameters[parameters.Length - 1].IsParams; + + ImmutableArray arguments = attribute.ConstructorArguments; + + for (int i = 0; i < arguments.Length; i++) { - validationAttr.ConstructorArguments.Add(GetArgumentExpression(constructorArgument.Type!, constructorArgument.Value)); + TypedConstant argument = arguments[i]; + if (argument.Kind == TypedConstantKind.Array) + { + bool isParams = lastParameterDeclaredWithParamsKeyword && i == arguments.Length - 1; + validationAttr.ConstructorArguments.Add(GetArrayArgumentExpression(argument.Values, isParams)); + } + else + { + validationAttr.ConstructorArguments.Add(GetArgumentExpression(argument.Type!, argument.Value)); + } } foreach (var namedArgument in attribute.NamedArguments) { - validationAttr.Properties.Add(namedArgument.Key, GetArgumentExpression(namedArgument.Value.Type!, namedArgument.Value.Value)); + if (namedArgument.Value.Kind == TypedConstantKind.Array) + { + bool isParams = lastParameterDeclaredWithParamsKeyword && namedArgument.Key == parameters[parameters.Length - 1].Name; + validationAttr.Properties.Add(namedArgument.Key, GetArrayArgumentExpression(namedArgument.Value.Values, isParams)); + } + else + { + validationAttr.Properties.Add(namedArgument.Key, GetArgumentExpression(namedArgument.Value.Type!, namedArgument.Value.Value)); + } } } } @@ -637,6 +660,32 @@ private bool CanValidate(ITypeSymbol validatorType, ISymbol modelType) return false; } + private string GetArrayArgumentExpression(ImmutableArray value, bool isParams) + { + var sb = new StringBuilder(); + if (!isParams) + { + sb.Append("new[] { "); + } + + for (int i = 0; i < value.Length; i++) + { + sb.Append(GetArgumentExpression(value[i].Type!, value[i].Value)); + + if (i < value.Length - 1) + { + sb.Append(", "); + } + } + + if (!isParams) + { + sb.Append(" }"); + } + + return sb.ToString(); + } + private string GetArgumentExpression(ITypeSymbol type, object? value) { if (value == null) diff --git a/src/libraries/Microsoft.Extensions.Options/tests/SourceGeneration.Unit.Tests/Main.cs b/src/libraries/Microsoft.Extensions.Options/tests/SourceGeneration.Unit.Tests/Main.cs index ca0db51c12102..0091f0b672397 100644 --- a/src/libraries/Microsoft.Extensions.Options/tests/SourceGeneration.Unit.Tests/Main.cs +++ b/src/libraries/Microsoft.Extensions.Options/tests/SourceGeneration.Unit.Tests/Main.cs @@ -1610,7 +1610,7 @@ public partial class FirstModelValidator : IValidateOptions // Run the generator with C# 7.0 and verify that it fails. var (diagnostics, generatedSources) = await RoslynTestUtils.RunGenerator( - new Generator(), refAssemblies.ToArray(), new[] { source }, includeBaseReferences: true, LanguageVersion.CSharp7).ConfigureAwait(false); + new OptionValidatorGenerator(), refAssemblies.ToArray(), new[] { source }, includeBaseReferences: true, LanguageVersion.CSharp7).ConfigureAwait(false); Assert.NotEmpty(diagnostics); Assert.Equal("SYSLIB1216", diagnostics[0].Id); @@ -1618,7 +1618,7 @@ public partial class FirstModelValidator : IValidateOptions // Run the generator with C# 8.0 and verify that it succeeds. (diagnostics, generatedSources) = await RoslynTestUtils.RunGenerator( - new Generator(), refAssemblies.ToArray(), new[] { source }, includeBaseReferences: true, LanguageVersion.CSharp8).ConfigureAwait(false); + new OptionValidatorGenerator(), refAssemblies.ToArray(), new[] { source }, includeBaseReferences: true, LanguageVersion.CSharp8).ConfigureAwait(false); Assert.Empty(diagnostics); Assert.Single(generatedSources); @@ -1638,6 +1638,129 @@ public partial class FirstModelValidator : IValidateOptions Assert.Equal(0, diags.Length); } + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotBrowser), nameof(PlatformDetection.IsNetCore))] + public async Task DataAnnotationAttributesWithParams() + { + var (diagnostics, generatedSources) = await RunGenerator(@""" + public class MyOptions + { + [Required] + public string P1 { get; set; } + + [Length(10, 20)] + public string P2 { get; set; } + + [AllowedValues(10, 20, 30)] + public int P3 { get; set; } + + [DeniedValues(""One"", ""Ten"", ""Hundred"")] + public string P4 { get; set; } + } + + [OptionsValidator] + public partial class MyOptionsValidator : IValidateOptions + { + } + """); + + Assert.Empty(diagnostics); + Assert.Single(generatedSources); + + var generatedSource = """ + + // + #nullable enable + #pragma warning disable CS1591 // Compensate for https://github.com/dotnet/roslyn/issues/54103 + namespace Test +{ + partial class MyOptionsValidator + { + /// + /// Validates a specific named options instance (or all when is ). + /// + /// The name of the options instance being validated. + /// The options instance. + /// Validation result. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Options.SourceGeneration", "42.42.42.42")] + public global::Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, global::Test.MyOptions options) + { + global::Microsoft.Extensions.Options.ValidateOptionsResultBuilder? builder = null; + var context = new global::System.ComponentModel.DataAnnotations.ValidationContext(options); + var validationResults = new global::System.Collections.Generic.List(); + var validationAttributes = new global::System.Collections.Generic.List(1); + + context.MemberName = "P1"; + context.DisplayName = string.IsNullOrEmpty(name) ? "MyOptions.P1" : $"{name}.P1"; + validationAttributes.Add(global::__OptionValidationStaticInstances.__Attributes.A1); + if (!global::System.ComponentModel.DataAnnotations.Validator.TryValidateValue(options.P1, context, validationResults, validationAttributes)) + { + (builder ??= new()).AddResults(validationResults); + } + + context.MemberName = "P2"; + context.DisplayName = string.IsNullOrEmpty(name) ? "MyOptions.P2" : $"{name}.P2"; + validationResults.Clear(); + validationAttributes.Clear(); + validationAttributes.Add(global::__OptionValidationStaticInstances.__Attributes.A2); + if (!global::System.ComponentModel.DataAnnotations.Validator.TryValidateValue(options.P2, context, validationResults, validationAttributes)) + { + (builder ??= new()).AddResults(validationResults); + } + + context.MemberName = "P3"; + context.DisplayName = string.IsNullOrEmpty(name) ? "MyOptions.P3" : $"{name}.P3"; + validationResults.Clear(); + validationAttributes.Clear(); + validationAttributes.Add(global::__OptionValidationStaticInstances.__Attributes.A3); + if (!global::System.ComponentModel.DataAnnotations.Validator.TryValidateValue(options.P3, context, validationResults, validationAttributes)) + { + (builder ??= new()).AddResults(validationResults); + } + + context.MemberName = "P4"; + context.DisplayName = string.IsNullOrEmpty(name) ? "MyOptions.P4" : $"{name}.P4"; + validationResults.Clear(); + validationAttributes.Clear(); + validationAttributes.Add(global::__OptionValidationStaticInstances.__Attributes.A4); + if (!global::System.ComponentModel.DataAnnotations.Validator.TryValidateValue(options.P4, context, validationResults, validationAttributes)) + { + (builder ??= new()).AddResults(validationResults); + } + + return builder is null ? global::Microsoft.Extensions.Options.ValidateOptionsResult.Success : builder.Build(); + } + } +} +namespace __OptionValidationStaticInstances +{ + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Options.SourceGeneration", "42.42.42.42")] + file static class __Attributes + { + internal static readonly global::System.ComponentModel.DataAnnotations.RequiredAttribute A1 = new global::System.ComponentModel.DataAnnotations.RequiredAttribute(); + + internal static readonly global::System.ComponentModel.DataAnnotations.LengthAttribute A2 = new global::System.ComponentModel.DataAnnotations.LengthAttribute( + (int)10, + (int)20); + + internal static readonly global::System.ComponentModel.DataAnnotations.AllowedValuesAttribute A3 = new global::System.ComponentModel.DataAnnotations.AllowedValuesAttribute( + (int)10, (int)20, (int)30); + + internal static readonly global::System.ComponentModel.DataAnnotations.DeniedValuesAttribute A4 = new global::System.ComponentModel.DataAnnotations.DeniedValuesAttribute( + "One", "Ten", "Hundred"); + } +} +namespace __OptionValidationStaticInstances +{ + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Options.SourceGeneration", "42.42.42.42")] + file static class __Validators + { + } +} + +"""; + Assert.Equal(generatedSource.Replace("\r\n", "\n"), generatedSources[0].SourceText.ToString().Replace("\r\n", "\n")); + } + private static CSharpCompilation CreateCompilationForOptionsSource(string assemblyName, string source, string? refAssemblyPath = null) { // Ensure the generated source compiles @@ -1676,7 +1799,7 @@ private static CSharpCompilation CreateCompilationForOptionsSource(string assemb refAssemblies.Add(refAssembly); } - return await RoslynTestUtils.RunGenerator(new Generator(), refAssemblies.ToArray(), new List { source }, includeBaseReferences: true, languageVersion).ConfigureAwait(false); + return await RoslynTestUtils.RunGenerator(new OptionValidatorGenerator(), refAssemblies.ToArray(), new List { source }, includeBaseReferences: true, languageVersion).ConfigureAwait(false); } private static async Task<(IReadOnlyList diagnostics, ImmutableArray generatedSources)> RunGenerator( @@ -1733,7 +1856,7 @@ private static CSharpCompilation CreateCompilationForOptionsSource(string assemb assemblies.Add(Assembly.GetAssembly(typeof(Microsoft.Extensions.Options.ValidateObjectMembersAttribute))!); } - var result = await RoslynTestUtils.RunGenerator(new Generator(), assemblies.ToArray(), new[] { text }) + var result = await RoslynTestUtils.RunGenerator(new OptionValidatorGenerator(), assemblies.ToArray(), new[] { text }) .ConfigureAwait(false); return result; diff --git a/src/libraries/Microsoft.Extensions.Options/tests/SourceGeneration.Unit.Tests/OptionsRuntimeTests.cs b/src/libraries/Microsoft.Extensions.Options/tests/SourceGeneration.Unit.Tests/OptionsRuntimeTests.cs index 9a5de0e759f8a..b644eea74120f 100644 --- a/src/libraries/Microsoft.Extensions.Options/tests/SourceGeneration.Unit.Tests/OptionsRuntimeTests.cs +++ b/src/libraries/Microsoft.Extensions.Options/tests/SourceGeneration.Unit.Tests/OptionsRuntimeTests.cs @@ -211,6 +211,37 @@ public void TestValidationWithCyclicReferences() ValidateOptionsResult result2 = dataAnnotationValidateOptions.Validate("MyOptions", options); Assert.True(result1.Succeeded); } + +#if NET8_0_OR_GREATER + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotBrowser))] + public void TestNewDataAnnotationFailures() + { + NewAttributesValidator sourceGenValidator = new(); + + OptionsUsingNewAttributes validOptions = new() + { + P1 = "123456", P2 = 2, P3 = 4, P4 = "c", P5 = "d" + }; + + ValidateOptionsResult result = sourceGenValidator.Validate("OptionsUsingNewAttributes", validOptions); + Assert.True(result.Succeeded); + + OptionsUsingNewAttributes invalidOptions = new() + { + P1 = "123", P2 = 4, P3 = 1, P4 = "e", P5 = "c" + }; + + result = sourceGenValidator.Validate("OptionsUsingNewAttributes", invalidOptions); + + Assert.Equal(new []{ + "P1: The field OptionsUsingNewAttributes.P1 must be a string or collection type with a minimum length of '5' and maximum length of '10'.", + "P2: The OptionsUsingNewAttributes.P2 field does not equal any of the values specified in AllowedValuesAttribute.", + "P3: The OptionsUsingNewAttributes.P3 field equals one of the values specified in DeniedValuesAttribute.", + "P4: The OptionsUsingNewAttributes.P4 field does not equal any of the values specified in AllowedValuesAttribute.", + "P5: The OptionsUsingNewAttributes.P5 field equals one of the values specified in DeniedValuesAttribute." + }, result.Failures); + } +#endif // NET8_0_OR_GREATER } public class MyOptions @@ -270,4 +301,29 @@ public struct MyOptionsStruct public partial class MySourceGenOptionsValidator : IValidateOptions { } + +#if NET8_0_OR_GREATER + public class OptionsUsingNewAttributes + { + [Length(5, 10)] + public string P1 { get; set; } + + [AllowedValues(1, 2, 3)] + public int P2 { get; set; } + + [DeniedValues(1, 2, 3)] + public int P3 { get; set; } + + [AllowedValues(new object?[] { "a", "b", "c" })] + public string P4 { get; set; } + + [DeniedValues(new object?[] { "a", "b", "c" })] + public string P5 { get; set; } + } + + [OptionsValidator] + public partial class NewAttributesValidator : IValidateOptions + { + } +#endif // NET8_0_OR_GREATER } \ No newline at end of file diff --git a/src/libraries/Microsoft.Extensions.Options/tests/SourceGenerationTests/EmitterTests.cs b/src/libraries/Microsoft.Extensions.Options/tests/SourceGenerationTests/EmitterTests.cs index fe3e007e0464d..563157bd2d698 100644 --- a/src/libraries/Microsoft.Extensions.Options/tests/SourceGenerationTests/EmitterTests.cs +++ b/src/libraries/Microsoft.Extensions.Options/tests/SourceGenerationTests/EmitterTests.cs @@ -32,7 +32,7 @@ public async Task TestEmitter() } var (d, r) = await RoslynTestUtils.RunGenerator( - new Generator(), + new OptionValidatorGenerator(), new[] { Assembly.GetAssembly(typeof(RequiredAttribute))!,