diff --git a/src/Components/Endpoints/src/Forms/DataAnnotationsLocalizer.cs b/src/Components/Endpoints/src/Forms/DataAnnotationsLocalizer.cs index 19affdcd681d..4a6727a65182 100644 --- a/src/Components/Endpoints/src/Forms/DataAnnotationsLocalizer.cs +++ b/src/Components/Endpoints/src/Forms/DataAnnotationsLocalizer.cs @@ -43,7 +43,7 @@ public string ResolveDisplayName(in ClientValidationFieldMetadata metadata, bool // Keep in sync with the generated ResolveAttributeErrorMessage/FormatErrorMessage in // src/Validation/gen/Templates/ValidatableInfo.cs, which this mirrors for the SSR client payload. public string? ResolveAttributeErrorMessage( - string memberName, + string? memberName, string displayName, Type type, ValidationAttribute attribute, @@ -54,38 +54,97 @@ public string ResolveDisplayName(in ClientValidationFieldMetadata metadata, bool return attribute.FormatErrorMessage(displayName); } - var lookupKey = GetErrorMessageKey(attribute, memberName, type); - - if (string.IsNullOrEmpty(lookupKey)) - { - return attribute.FormatErrorMessage(displayName); - } - var localizer = GetStringLocalizer(type, localizerFactory); - var localizedTemplate = localizer[lookupKey]; + var localizedTemplate = FindLocalizedTemplate(localizer, attribute, memberName, type); - if (localizedTemplate.ResourceNotFound) + if (localizedTemplate is null) { return attribute.FormatErrorMessage(displayName); } // Format the localized template with attribute-specific arguments - return FormatMessage(attribute, CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatMessage(attribute, CultureInfo.CurrentCulture, localizedTemplate, displayName); } - private string? GetErrorMessageKey(ValidationAttribute attribute, string memberName, Type type) + // Resolves the localized message template for a validation attribute. + // + // An explicit ErrorMessage is used verbatim as the lookup key. Otherwise the built-in key + // convention is applied, walking from the most specific key to the least specific one: + // + // {DeclaringType}_{MemberName}_{AttributeType}_Error + // {DeclaringType}_{AttributeType}_Error + // {AttributeType}_Error + // + // The {DeclaringType} segment is omitted when the declaring type is a framework type, which + // collapses the first key to {MemberName}_{AttributeType}_Error and drops the second. + // + // Returns null when no key resolves, in which case the caller falls back to the + // non-localized message produced by the attribute itself. + // + // Keep in sync with the generated LocalizationHelpers.FindLocalizedTemplate in + // src/Validation/gen/Templates/LocalizationHelpers.cs. + private static string? FindLocalizedTemplate( + IStringLocalizer localizer, + ValidationAttribute attribute, + string? memberName, + Type declaringType) { if (!string.IsNullOrEmpty(attribute.ErrorMessage)) { - return attribute.ErrorMessage; + var explicitMatch = localizer[attribute.ErrorMessage]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; } - return options.MessageKeyProvider?.Invoke(new ValidationMessageKeyContext + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + // The member-specific tier is skipped when there is no member to key on, which is the case + // for a type-level attribute that reports no member names. + if (memberName is not null) { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = type, - }); + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", StringComparison.Ordinal) || + ns.StartsWith("System.", StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name[..arityIndex]; } private IStringLocalizer GetStringLocalizer(Type type, IStringLocalizerFactory localizerFactory) diff --git a/src/Components/Endpoints/test/FormValidation/ClientValidationProviderTests.cs b/src/Components/Endpoints/test/FormValidation/ClientValidationProviderTests.cs index 4e271b311a2f..324f5bb91314 100644 --- a/src/Components/Endpoints/test/FormValidation/ClientValidationProviderTests.cs +++ b/src/Components/Endpoints/test/FormValidation/ClientValidationProviderTests.cs @@ -173,12 +173,9 @@ public void Localizer_LocalizesDisplayNameAndErrorMessage_OnMevPath() var translations = new Dictionary { ["Custom Label"] = "Étiquette", - ["req-key"] = "{0} est requis.", + ["LocalizedFieldModel_Field_RequiredAttribute_Error"] = "{0} est requis.", }; var options = CreateMevOptions(typeof(LocalizedFieldModel)); -#pragma warning disable ASP0029 // Microsoft.Extensions.Validation evaluation APIs. - options.MessageKeyProvider = _ => "req-key"; -#pragma warning restore ASP0029 var factory = new TestStringLocalizerFactory(translations); var rule = SingleRule( @@ -198,10 +195,10 @@ public void Localizer_DoesNotLocalize_OnStaticValidatorPath() var translations = new Dictionary { ["Custom Label"] = "Étiquette", - ["req-key"] = "{0} est requis.", + ["LocalizedFieldModel_Field_RequiredAttribute_Error"] = "{0} est requis.", }; #pragma warning disable ASP0029 // Microsoft.Extensions.Validation evaluation APIs. - var options = new ValidationOptions { MessageKeyProvider = _ => "req-key" }; + var options = new ValidationOptions(); #pragma warning restore ASP0029 var factory = new TestStringLocalizerFactory(translations); @@ -218,14 +215,15 @@ public void Localizer_ResolvesFromDeclaringType_ForInheritedProperty() // The validated property is declared on the base type but the form model is the derived type. // Server-side validation resolves the localizer, message key, and display name from the // *declaring* type, so the client payload must do the same rather than use the derived - // (runtime container) type. The factory only knows translations for the base type, so a - // localized result proves the declaring type flowed through. + // (runtime container) type. The factory only knows translations for the base type, and the + // conventional key itself is built from the base type name, so a localized result proves the + // declaring type flowed through both the localizer lookup and the key convention. var byType = new Dictionary> { [typeof(InheritedFieldBaseModel)] = new Dictionary { ["Base Label"] = "Étiquette", - ["req-key"] = "{0} est requis.", + ["InheritedFieldBaseModel_Field_RequiredAttribute_Error"] = "{0} est requis.", }, // The derived type has no translations; if it were (incorrectly) used, both the display // name and the message template would fall back to their non-localized values. @@ -233,23 +231,13 @@ public void Localizer_ResolvesFromDeclaringType_ForInheritedProperty() }; var factory = new TypeAwareStringLocalizerFactory(byType); - Type? messageKeyDeclaringType = null; var options = CreateMevOptions(typeof(DerivedFieldModel)); -#pragma warning disable ASP0029 // Microsoft.Extensions.Validation evaluation APIs. - options.MessageKeyProvider = context => - { - messageKeyDeclaringType = context.DeclaringType; - return "req-key"; - }; -#pragma warning restore ASP0029 var rule = SingleRule( GetMevData(options, factory, (nameof(DerivedFieldModel.Field), "Model." + nameof(DerivedFieldModel.Field)))!, "Model." + nameof(DerivedFieldModel.Field)); - // The declaring (base) type is used for the message key context... - Assert.Equal(typeof(InheritedFieldBaseModel), messageKeyDeclaringType); - // ...and for both the display-name and error-message localizer lookups. + // The declaring (base) type is used for both the display-name and error-message lookups. Assert.Equal("Étiquette est requis.", rule.Message); } diff --git a/src/Validation/gen/Templates/LocalizationHelpers.cs b/src/Validation/gen/Templates/LocalizationHelpers.cs index 47ebab904924..873b80d81cda 100644 --- a/src/Validation/gen/Templates/LocalizationHelpers.cs +++ b/src/Validation/gen/Templates/LocalizationHelpers.cs @@ -7,4 +7,66 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } diff --git a/src/Validation/gen/Templates/ValidatableInfo.cs b/src/Validation/gen/Templates/ValidatableInfo.cs index ab86d41100db..c8fc298882ce 100644 --- a/src/Validation/gen/Templates/ValidatableInfo.cs +++ b/src/Validation/gen/Templates/ValidatableInfo.cs @@ -69,7 +69,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -85,29 +85,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in diff --git a/src/Validation/gen/Templates/ValidatableTypeInfo.cs b/src/Validation/gen/Templates/ValidatableTypeInfo.cs index ba11c1c10fa1..45f47af19840 100644 --- a/src/Validation/gen/Templates/ValidatableTypeInfo.cs +++ b/src/Validation/gen/Templates/ValidatableTypeInfo.cs @@ -365,7 +365,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/src/PublicAPI.Unshipped.txt b/src/Validation/src/PublicAPI.Unshipped.txt index 7e293127be09..ab952a550de0 100644 --- a/src/Validation/src/PublicAPI.Unshipped.txt +++ b/src/Validation/src/PublicAPI.Unshipped.txt @@ -34,18 +34,8 @@ Microsoft.Extensions.Validation.ValidateContext.ServiceProvider.get -> System.IS Microsoft.Extensions.Validation.ValidateContext.ServiceProvider.init -> void Microsoft.Extensions.Validation.ValidateContext.ValidationErrors.get -> System.Collections.Generic.IReadOnlyDictionary!>? Microsoft.Extensions.Validation.ValidateContext.ValidationOptions.init -> void -Microsoft.Extensions.Validation.ValidationMessageKeyContext -Microsoft.Extensions.Validation.ValidationMessageKeyContext.DeclaringType.get -> System.Type! -Microsoft.Extensions.Validation.ValidationMessageKeyContext.DeclaringType.init -> void -Microsoft.Extensions.Validation.ValidationMessageKeyContext.MemberName.get -> string! -Microsoft.Extensions.Validation.ValidationMessageKeyContext.MemberName.init -> void -Microsoft.Extensions.Validation.ValidationMessageKeyContext.ValidationMessageKeyContext() -> void -Microsoft.Extensions.Validation.ValidationMessageKeyContext.ValidatorType.get -> System.Type! -Microsoft.Extensions.Validation.ValidationMessageKeyContext.ValidatorType.init -> void Microsoft.Extensions.Validation.ValidationOptions.LocalizerProvider.get -> System.Func! Microsoft.Extensions.Validation.ValidationOptions.LocalizerProvider.set -> void -Microsoft.Extensions.Validation.ValidationOptions.MessageKeyProvider.get -> System.Func? -Microsoft.Extensions.Validation.ValidationOptions.MessageKeyProvider.set -> void Microsoft.Extensions.Validation.ValidationOptions.TryGetValidatableParameterInfo(System.Reflection.ParameterInfo! parameterInfo, out Microsoft.Extensions.Validation.IValidatableParameterInfo? validatableInfo) -> bool Microsoft.Extensions.Validation.ValidationOptions.TryGetValidatableTypeInfo(System.Type! type, out Microsoft.Extensions.Validation.IValidatableTypeInfo? validatableTypeInfo) -> bool *REMOVED*abstract Microsoft.Extensions.Validation.ValidatableParameterInfo.GetValidationAttributes() -> System.ComponentModel.DataAnnotations.ValidationAttribute![]! diff --git a/src/Validation/src/ValidationMessageKeyContext.cs b/src/Validation/src/ValidationMessageKeyContext.cs deleted file mode 100644 index 2fdf142929e3..000000000000 --- a/src/Validation/src/ValidationMessageKeyContext.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.Extensions.Validation; - -/// -/// Provides contextual information used to compute the resource key for looking up a localized -/// validation error message. -/// -/// -/// An instance is passed to when a custom key -/// convention is configured. The returned key is then resolved against the configured -/// . -/// -public sealed class ValidationMessageKeyContext -{ - /// - /// Gets the type of the validator that produced the error being localized. For DataAnnotations, - /// this is the validation attribute type (for example, typeof(RequiredAttribute)). - /// - public required Type ValidatorType { get; init; } - - /// - /// Gets the name of the member being validated: the property name for property validation, the - /// parameter name for parameter validation, or the type name for type-level validation. - /// - public required string MemberName { get; init; } - - /// - /// Gets the type associated with the member being validated: the containing type for a property, - /// the validated type itself for type-level validation, or the parameter's own type for a parameter. - /// - public required Type DeclaringType { get; init; } -} diff --git a/src/Validation/src/ValidationOptions.cs b/src/Validation/src/ValidationOptions.cs index d35350df0db8..07d9d3c93b74 100644 --- a/src/Validation/src/ValidationOptions.cs +++ b/src/Validation/src/ValidationOptions.cs @@ -45,18 +45,6 @@ public class ValidationOptions public Func LocalizerProvider { get; set; } = (type, factory) => factory.Create(type); - /// - /// Gets or sets a delegate that computes the resource key used to look up a localized validation - /// message. - /// - /// - /// The provider supplies the lookup key by convention (for example, keyed by - /// ) for validators that do not specify an - /// explicit message. When a validator specifies an explicit message, that message is used as the - /// lookup key and the provider is not consulted. - /// - public Func? MessageKeyProvider { get; set; } - /// /// Attempts to get validation information for the specified type. /// diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.ParameterDisplayName_WithNameOnly#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.ParameterDisplayName_WithNameOnly#ValidatableInfoResolver.g.verified.cs index 9317a5c66053..f6463a64187a 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.ParameterDisplayName_WithNameOnly#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.ParameterDisplayName_WithNameOnly#ValidatableInfoResolver.g.verified.cs @@ -309,6 +309,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -383,7 +445,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -399,29 +461,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1034,7 +1082,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.ParameterDisplayName_WithResourceType#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.ParameterDisplayName_WithResourceType#ValidatableInfoResolver.g.verified.cs index 9317a5c66053..f6463a64187a 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.ParameterDisplayName_WithResourceType#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.ParameterDisplayName_WithResourceType#ValidatableInfoResolver.g.verified.cs @@ -309,6 +309,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -383,7 +445,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -399,29 +461,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1034,7 +1082,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_DisplayAttributeResourceTypeTakesPrecedenceOverDisplayNameAttribute#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_DisplayAttributeResourceTypeTakesPrecedenceOverDisplayNameAttribute#ValidatableInfoResolver.g.verified.cs index 7a0b20e9dade..adb23a55dce4 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_DisplayAttributeResourceTypeTakesPrecedenceOverDisplayNameAttribute#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_DisplayAttributeResourceTypeTakesPrecedenceOverDisplayNameAttribute#ValidatableInfoResolver.g.verified.cs @@ -325,6 +325,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -399,7 +461,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -415,29 +477,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1050,7 +1098,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_DisplayAttributeTakesPrecedenceOverDisplayNameAttribute#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_DisplayAttributeTakesPrecedenceOverDisplayNameAttribute#ValidatableInfoResolver.g.verified.cs index fad6d39374ea..b0cabc5b05bf 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_DisplayAttributeTakesPrecedenceOverDisplayNameAttribute#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_DisplayAttributeTakesPrecedenceOverDisplayNameAttribute#ValidatableInfoResolver.g.verified.cs @@ -325,6 +325,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -399,7 +461,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -415,29 +477,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1050,7 +1098,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_WithControlCharacters_EmitsValidLiteral#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_WithControlCharacters_EmitsValidLiteral#ValidatableInfoResolver.g.verified.cs index c627d7084fc0..151a9d8621b1 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_WithControlCharacters_EmitsValidLiteral#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_WithControlCharacters_EmitsValidLiteral#ValidatableInfoResolver.g.verified.cs @@ -325,6 +325,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -399,7 +461,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -415,29 +477,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1050,7 +1098,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_WithDisplayNameAttribute#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_WithDisplayNameAttribute#ValidatableInfoResolver.g.verified.cs index 853edfc29dee..9bc0921c177b 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_WithDisplayNameAttribute#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_WithDisplayNameAttribute#ValidatableInfoResolver.g.verified.cs @@ -325,6 +325,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -399,7 +461,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -415,29 +477,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1050,7 +1098,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_WithNameOnly#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_WithNameOnly#ValidatableInfoResolver.g.verified.cs index d68b2a59e4e9..1ac451b4f970 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_WithNameOnly#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_WithNameOnly#ValidatableInfoResolver.g.verified.cs @@ -325,6 +325,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -399,7 +461,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -415,29 +477,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1050,7 +1098,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_WithResourceType#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_WithResourceType#ValidatableInfoResolver.g.verified.cs index 8d631ddb4bb4..5aac48f14c40 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_WithResourceType#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_WithResourceType#ValidatableInfoResolver.g.verified.cs @@ -325,6 +325,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -399,7 +461,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -415,29 +477,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1050,7 +1098,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_WithResourceType_OnHiddenGenericProperty#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_WithResourceType_OnHiddenGenericProperty#ValidatableInfoResolver.g.verified.cs index c13082a727eb..70eb9ee7d043 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_WithResourceType_OnHiddenGenericProperty#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_WithResourceType_OnHiddenGenericProperty#ValidatableInfoResolver.g.verified.cs @@ -334,6 +334,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -408,7 +470,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -424,29 +486,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1059,7 +1107,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_WithoutDisplayAttribute_UsesPropertyName#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_WithoutDisplayAttribute_UsesPropertyName#ValidatableInfoResolver.g.verified.cs index c56af96e56ea..58eda703a2d7 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_WithoutDisplayAttribute_UsesPropertyName#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.PropertyDisplayName_WithoutDisplayAttribute_UsesPropertyName#ValidatableInfoResolver.g.verified.cs @@ -325,6 +325,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -399,7 +461,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -415,29 +477,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1050,7 +1098,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.RecordPropertyDisplayName_LiteralOnConstructorParameter#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.RecordPropertyDisplayName_LiteralOnConstructorParameter#ValidatableInfoResolver.g.verified.cs index dcdc58eaedf7..ddda2a48cf43 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.RecordPropertyDisplayName_LiteralOnConstructorParameter#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.RecordPropertyDisplayName_LiteralOnConstructorParameter#ValidatableInfoResolver.g.verified.cs @@ -325,6 +325,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -399,7 +461,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -415,29 +477,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1050,7 +1098,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.RecordPropertyDisplayName_WithNameOnly#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.RecordPropertyDisplayName_WithNameOnly#ValidatableInfoResolver.g.verified.cs index fdbbfd176d6d..fef762630523 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.RecordPropertyDisplayName_WithNameOnly#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.RecordPropertyDisplayName_WithNameOnly#ValidatableInfoResolver.g.verified.cs @@ -325,6 +325,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -399,7 +461,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -415,29 +477,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1050,7 +1098,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.RecordPropertyDisplayName_WithResourceType#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.RecordPropertyDisplayName_WithResourceType#ValidatableInfoResolver.g.verified.cs index d4ed50318028..4825c59721c4 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.RecordPropertyDisplayName_WithResourceType#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.RecordPropertyDisplayName_WithResourceType#ValidatableInfoResolver.g.verified.cs @@ -325,6 +325,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -399,7 +461,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -415,29 +477,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1050,7 +1098,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.TypeDisplayName_WithDisplayNameAttribute#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.TypeDisplayName_WithDisplayNameAttribute#ValidatableInfoResolver.g.verified.cs index 72a0cedc12f7..a7e92d10ea64 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.TypeDisplayName_WithDisplayNameAttribute#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.TypeDisplayName_WithDisplayNameAttribute#ValidatableInfoResolver.g.verified.cs @@ -318,6 +318,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -392,7 +454,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -408,29 +470,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1043,7 +1091,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.TypeDisplayName_WithNameOnly#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.TypeDisplayName_WithNameOnly#ValidatableInfoResolver.g.verified.cs index c5835716f8c4..0add10d01940 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.TypeDisplayName_WithNameOnly#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.TypeDisplayName_WithNameOnly#ValidatableInfoResolver.g.verified.cs @@ -318,6 +318,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -392,7 +454,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -408,29 +470,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1043,7 +1091,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.TypeDisplayName_WithResourceType#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.TypeDisplayName_WithResourceType#ValidatableInfoResolver.g.verified.cs index 695eed15c3ed..c9b124368368 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.TypeDisplayName_WithResourceType#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorDisplayNameTests.TypeDisplayName_WithResourceType#ValidatableInfoResolver.g.verified.cs @@ -318,6 +318,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -392,7 +454,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -408,29 +470,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1043,7 +1091,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanGenerateWhenAddValidationCalledMultipleTimes#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanGenerateWhenAddValidationCalledMultipleTimes#ValidatableInfoResolver.g.verified.cs index bb6a3eaae4cd..43bf165f22a1 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanGenerateWhenAddValidationCalledMultipleTimes#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanGenerateWhenAddValidationCalledMultipleTimes#ValidatableInfoResolver.g.verified.cs @@ -326,6 +326,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -400,7 +462,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -416,29 +478,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1051,7 +1099,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateClassTypesWithAttribute#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateClassTypesWithAttribute#ValidatableInfoResolver.g.verified.cs index 0372351bd36e..eb6ececfa842 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateClassTypesWithAttribute#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateClassTypesWithAttribute#ValidatableInfoResolver.g.verified.cs @@ -405,6 +405,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -479,7 +541,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -495,29 +557,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1130,7 +1178,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateComplexTypes#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateComplexTypes#ValidatableInfoResolver.g.verified.cs index 2e99c9f892f6..007e264cac37 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateComplexTypes#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateComplexTypes#ValidatableInfoResolver.g.verified.cs @@ -411,6 +411,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -485,7 +547,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -501,29 +563,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1136,7 +1184,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateComplexTypesWithJsonIgnore#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateComplexTypesWithJsonIgnore#ValidatableInfoResolver.g.verified.cs index 62208b72990d..8796026bdd30 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateComplexTypesWithJsonIgnore#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateComplexTypesWithJsonIgnore#ValidatableInfoResolver.g.verified.cs @@ -341,6 +341,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -415,7 +477,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -431,29 +493,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1066,7 +1114,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject#ValidatableInfoResolver.g.verified.cs index 6b845d81bd92..bf3d265a0141 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject#ValidatableInfoResolver.g.verified.cs @@ -362,6 +362,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -436,7 +498,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -452,29 +514,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1087,7 +1135,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject_WithoutPropertyValidations#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject_WithoutPropertyValidations#ValidatableInfoResolver.g.verified.cs index 8686a4076ff9..443b40f6a905 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject_WithoutPropertyValidations#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject_WithoutPropertyValidations#ValidatableInfoResolver.g.verified.cs @@ -368,6 +368,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -442,7 +504,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -458,29 +520,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1093,7 +1141,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateMultipleNamespaces#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateMultipleNamespaces#ValidatableInfoResolver.g.verified.cs index dc343d5bba59..57e9c72dcb82 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateMultipleNamespaces#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateMultipleNamespaces#ValidatableInfoResolver.g.verified.cs @@ -341,6 +341,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -415,7 +477,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -431,29 +493,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1066,7 +1114,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateParameters#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateParameters#ValidatableInfoResolver.g.verified.cs index 122ceeea89e5..f28937c04efe 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateParameters#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateParameters#ValidatableInfoResolver.g.verified.cs @@ -325,6 +325,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -399,7 +461,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -415,29 +477,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1050,7 +1098,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateParametersFromDelegateVariableHandler#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateParametersFromDelegateVariableHandler#ValidatableInfoResolver.g.verified.cs index 9efb51d42b05..51bd197ce3a1 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateParametersFromDelegateVariableHandler#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateParametersFromDelegateVariableHandler#ValidatableInfoResolver.g.verified.cs @@ -331,6 +331,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -405,7 +467,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -421,29 +483,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1056,7 +1104,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidatePolymorphicTypes#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidatePolymorphicTypes#ValidatableInfoResolver.g.verified.cs index 4f6e75b15e47..9b429339213c 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidatePolymorphicTypes#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidatePolymorphicTypes#ValidatableInfoResolver.g.verified.cs @@ -394,6 +394,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -468,7 +530,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -484,29 +546,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1119,7 +1167,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateRecordStructTypes#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateRecordStructTypes#ValidatableInfoResolver.g.verified.cs index d857eefe276c..fb6c178c45b8 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateRecordStructTypes#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateRecordStructTypes#ValidatableInfoResolver.g.verified.cs @@ -359,6 +359,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -433,7 +495,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -449,29 +511,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1084,7 +1132,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateRecordTypes#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateRecordTypes#ValidatableInfoResolver.g.verified.cs index 5c9122188aa9..aa111f9ca8ac 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateRecordTypes#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateRecordTypes#ValidatableInfoResolver.g.verified.cs @@ -439,6 +439,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -513,7 +575,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -529,29 +591,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1164,7 +1212,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateRecordTypesWithAttribute#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateRecordTypesWithAttribute#ValidatableInfoResolver.g.verified.cs index 0372351bd36e..eb6ececfa842 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateRecordTypesWithAttribute#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateRecordTypesWithAttribute#ValidatableInfoResolver.g.verified.cs @@ -405,6 +405,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -479,7 +541,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -495,29 +557,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1130,7 +1178,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateRecursiveTypes#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateRecursiveTypes#ValidatableInfoResolver.g.verified.cs index 1c0e08db958e..90205c77c817 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateRecursiveTypes#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateRecursiveTypes#ValidatableInfoResolver.g.verified.cs @@ -331,6 +331,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -405,7 +467,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -421,29 +483,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1056,7 +1104,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateTypeWithParsableProperties#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateTypeWithParsableProperties#ValidatableInfoResolver.g.verified.cs index 1ac700621fc2..77fc4d21cae6 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateTypeWithParsableProperties#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateTypeWithParsableProperties#ValidatableInfoResolver.g.verified.cs @@ -373,6 +373,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -447,7 +509,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -463,29 +525,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1098,7 +1146,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateValidationAttributesOnClasses#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateValidationAttributesOnClasses#ValidatableInfoResolver.g.verified.cs index f14260dcec03..82d388890dfc 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateValidationAttributesOnClasses#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateValidationAttributesOnClasses#ValidatableInfoResolver.g.verified.cs @@ -346,6 +346,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -420,7 +482,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -436,29 +498,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1071,7 +1119,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmitForExemptTypes#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmitForExemptTypes#ValidatableInfoResolver.g.verified.cs index 8b20db0a3c88..793d3c6640b7 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmitForExemptTypes#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmitForExemptTypes#ValidatableInfoResolver.g.verified.cs @@ -325,6 +325,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -399,7 +461,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -415,29 +477,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1050,7 +1098,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnClassProperties#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnClassProperties#ValidatableInfoResolver.g.verified.cs index 59c0b47a940c..9922fffb3f44 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnClassProperties#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnClassProperties#ValidatableInfoResolver.g.verified.cs @@ -385,6 +385,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -459,7 +521,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -475,29 +537,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1110,7 +1158,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnEndpointParameters#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnEndpointParameters#ValidatableInfoResolver.g.verified.cs index 8b20db0a3c88..793d3c6640b7 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnEndpointParameters#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnEndpointParameters#ValidatableInfoResolver.g.verified.cs @@ -325,6 +325,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -399,7 +461,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -415,29 +477,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1050,7 +1098,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnRecordProperties#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnRecordProperties#ValidatableInfoResolver.g.verified.cs index e986954efd51..f3ffe7a68a8b 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnRecordProperties#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnRecordProperties#ValidatableInfoResolver.g.verified.cs @@ -341,6 +341,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -415,7 +477,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -431,29 +493,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1066,7 +1114,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.IValidatableObject_ReceivesValidatedInstanceAsObjectInstance#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.IValidatableObject_ReceivesValidatedInstanceAsObjectInstance#ValidatableInfoResolver.g.verified.cs index 8dd51d5495ac..2b0d85364c86 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.IValidatableObject_ReceivesValidatedInstanceAsObjectInstance#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.IValidatableObject_ReceivesValidatedInstanceAsObjectInstance#ValidatableInfoResolver.g.verified.cs @@ -318,6 +318,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -392,7 +454,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -408,29 +470,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1043,7 +1091,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsClassesWithNonAccessibleTypes#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsClassesWithNonAccessibleTypes#ValidatableInfoResolver.g.verified.cs index bf9026d54b91..77cd049f61db 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsClassesWithNonAccessibleTypes#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsClassesWithNonAccessibleTypes#ValidatableInfoResolver.g.verified.cs @@ -325,6 +325,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -399,7 +461,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -415,29 +477,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1050,7 +1098,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsFileLocalTypes#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsFileLocalTypes#ValidatableInfoResolver.g.verified.cs index 9317a5c66053..f6463a64187a 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsFileLocalTypes#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsFileLocalTypes#ValidatableInfoResolver.g.verified.cs @@ -309,6 +309,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -383,7 +445,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -399,29 +461,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1034,7 +1082,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsIndexerPropertiesOnTypes#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsIndexerPropertiesOnTypes#ValidatableInfoResolver.g.verified.cs index 6ff2ca3b265e..9bb9933dc3ea 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsIndexerPropertiesOnTypes#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsIndexerPropertiesOnTypes#ValidatableInfoResolver.g.verified.cs @@ -325,6 +325,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -399,7 +461,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -415,29 +477,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1050,7 +1098,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsNonReadableAndStaticProperties#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsNonReadableAndStaticProperties#ValidatableInfoResolver.g.verified.cs index 70f1c43eccac..adb51d764e42 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsNonReadableAndStaticProperties#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsNonReadableAndStaticProperties#ValidatableInfoResolver.g.verified.cs @@ -347,6 +347,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -421,7 +483,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -437,29 +499,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1072,7 +1120,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.ValidatesInternalTypes#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.ValidatesInternalTypes#ValidatableInfoResolver.g.verified.cs index 8b54e31865cd..3755089b32ed 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.ValidatesInternalTypes#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.ValidatesInternalTypes#ValidatableInfoResolver.g.verified.cs @@ -347,6 +347,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -421,7 +483,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -437,29 +499,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1072,7 +1120,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.ValidatesPropertiesWithJsonIgnoreWhenWritingConditions#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.ValidatesPropertiesWithJsonIgnoreWhenWritingConditions#ValidatableInfoResolver.g.verified.cs index 4de9caaa00a1..512480c7672e 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.ValidatesPropertiesWithJsonIgnoreWhenWritingConditions#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.ValidatesPropertiesWithJsonIgnoreWhenWritingConditions#ValidatableInfoResolver.g.verified.cs @@ -337,6 +337,68 @@ file static class LocalizationHelpers => context.ValidationOptions.LocalizerProvider(type, factory) ?? throw new global::System.InvalidOperationException( $"The ValidationOptions.LocalizerProvider delegate returned null for type '{type.FullName}'. The delegate must return a non-null IStringLocalizer instance."); + + public static string? FindLocalizedTemplate( + global::Microsoft.Extensions.Localization.IStringLocalizer localizer, + global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, + string? memberName, + global::System.Type declaringType) + { + if (!string.IsNullOrEmpty(attribute.ErrorMessage)) + { + var explicitMatch = localizer[attribute.ErrorMessage!]; + + return explicitMatch.ResourceNotFound ? null : explicitMatch.Value; + } + + var attributeName = attribute.GetType().Name; + var typeName = GetKeySegment(declaringType); + + if (memberName is not null) + { + var memberKey = typeName is null + ? $"{memberName}_{attributeName}_Error" + : $"{typeName}_{memberName}_{attributeName}_Error"; + + var memberMatch = localizer[memberKey]; + if (!memberMatch.ResourceNotFound) + { + return memberMatch.Value; + } + } + + // Without a type segment the type tier would duplicate the global tier. + if (typeName is not null) + { + var typeMatch = localizer[$"{typeName}_{attributeName}_Error"]; + if (!typeMatch.ResourceNotFound) + { + return typeMatch.Value; + } + } + + var globalMatch = localizer[$"{attributeName}_Error"]; + + return globalMatch.ResourceNotFound ? null : globalMatch.Value; + } + + // Framework types carry no app-specific meaning as a key segment, so they are omitted. This + // mainly affects parameters, whose declaring type is the parameter's own type. + private static string? GetKeySegment(global::System.Type type) + { + var ns = type.Namespace; + if (ns is not null && + (string.Equals(ns, "System", global::System.StringComparison.Ordinal) || + ns.StartsWith("System.", global::System.StringComparison.Ordinal))) + { + return null; + } + + var name = type.Name; + var arityIndex = name.IndexOf('`'); + + return arityIndex < 0 ? name : name.Substring(0, arityIndex); + } } @@ -411,7 +473,7 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo private protected static string? ResolveAttributeErrorMessage( global::Microsoft.Extensions.Validation.ValidateContext context, - string memberName, + string? memberName, string displayName, global::System.Type declaringType, global::System.ComponentModel.DataAnnotations.ValidationAttribute attribute, @@ -427,29 +489,15 @@ private protected static bool TryGetRequiredAttribute(global::System.ComponentMo return result.ErrorMessage; } - var lookupKey = !string.IsNullOrEmpty(attribute.ErrorMessage) - ? attribute.ErrorMessage - : context.ValidationOptions.MessageKeyProvider?.Invoke(new global::Microsoft.Extensions.Validation.ValidationMessageKeyContext - { - ValidatorType = attribute.GetType(), - MemberName = memberName, - DeclaringType = declaringType, - }); - - if (string.IsNullOrEmpty(lookupKey)) - { - return result.ErrorMessage; - } - var localizer = LocalizationHelpers.CreateStringLocalizer(context, declaringType, localizerFactory); - var localizedTemplate = localizer[lookupKey!]; - if (localizedTemplate.ResourceNotFound) + var localizedTemplate = LocalizationHelpers.FindLocalizedTemplate(localizer, attribute, memberName, declaringType); + if (localizedTemplate is null) { return result.ErrorMessage; } - return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate.Value, displayName); + return FormatErrorMessage(attribute, global::System.Globalization.CultureInfo.CurrentCulture, localizedTemplate, displayName); } // Keep in sync with DataAnnotationsLocalizer.FormatMessage in @@ -1062,7 +1110,7 @@ private protected override void ReportError(global::Microsoft.Extensions.Validat // If no member names are specified, then treat this as a top-level error var errorMessage = ResolveAttributeErrorMessage( context, - memberName: Type.Name, + memberName: null, displayName, declaringType: Type, attribute, diff --git a/src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidationLocalizationIntegrationTests.cs b/src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidationLocalizationIntegrationTests.cs index daa764ad274d..42f0f3699253 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidationLocalizationIntegrationTests.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidationLocalizationIntegrationTests.cs @@ -10,8 +10,8 @@ namespace Microsoft.Extensions.Validation.Tests; // End-to-end coverage for the validation localization pipeline that is now emitted into the -// generated code and driven purely by ValidationOptions.LocalizerProvider / MessageKeyProvider and a -// registered IStringLocalizerFactory. +// generated code and driven purely by ValidationOptions.LocalizerProvider, the built-in message +// key convention, and a registered IStringLocalizerFactory. public class ValidationLocalizationIntegrationTests : ValidationTestBase { [Theory] @@ -117,15 +117,53 @@ public async Task Property_SelfFormattingAttribute_UsesFormatMessageHook(bool us [Theory] [InlineData(true)] [InlineData(false)] - public async Task Property_MessageKeyProvider_ComputesLookupKey(bool useAsync) + public async Task Property_ConventionKey_MemberTier_TakesPrecedence(bool useAsync) { + // All three conventional keys resolve, so the most specific one wins. var translations = new Dictionary { - ["RequiredAttribute"] = "{0} is mandatory.", + ["LocalizedDefaultModel_Name_RequiredAttribute_Error"] = "{0} is required for this member.", + ["LocalizedDefaultModel_RequiredAttribute_Error"] = "{0} is required for this type.", + ["RequiredAttribute_Error"] = "{0} is mandatory.", }; - var (provider, options) = CreateServices( - translations, - o => o.MessageKeyProvider = ctx => ctx.ValidatorType.Name); + var (provider, options) = CreateServices(translations); + var typeInfo = GeneratedValidationTestHelpers.GetTypeInfo(options); + var context = GeneratedValidationTestHelpers.CreateContext(provider, options); + + await ValidateAsync(typeInfo, new LocalizedDefaultModel(), context, useAsync, default); + + Assert.Equal("Name is required for this member.", Single(context, "Name")); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Property_ConventionKey_TypeTier_UsedWhenMemberKeyMissing(bool useAsync) + { + var translations = new Dictionary + { + ["LocalizedDefaultModel_RequiredAttribute_Error"] = "{0} is required for this type.", + ["RequiredAttribute_Error"] = "{0} is mandatory.", + }; + var (provider, options) = CreateServices(translations); + var typeInfo = GeneratedValidationTestHelpers.GetTypeInfo(options); + var context = GeneratedValidationTestHelpers.CreateContext(provider, options); + + await ValidateAsync(typeInfo, new LocalizedDefaultModel(), context, useAsync, default); + + Assert.Equal("Name is required for this type.", Single(context, "Name")); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Property_ConventionKey_GlobalTier_UsedWhenSpecificKeysMissing(bool useAsync) + { + var translations = new Dictionary + { + ["RequiredAttribute_Error"] = "{0} is mandatory.", + }; + var (provider, options) = CreateServices(translations); var typeInfo = GeneratedValidationTestHelpers.GetTypeInfo(options); var context = GeneratedValidationTestHelpers.CreateContext(provider, options); @@ -137,28 +175,64 @@ public async Task Property_MessageKeyProvider_ComputesLookupKey(bool useAsync) [Theory] [InlineData(true)] [InlineData(false)] - public async Task Property_ExplicitErrorMessage_WinsOverProvider(bool useAsync) + public async Task Property_ConventionKey_NoMatch_FallsBackToAttributeMessage(bool useAsync) { - // An explicit ErrorMessage on the validator is used as the key directly; the convention - // provider is not consulted. + // A factory is registered but no conventional key resolves, so the attribute's own + // non-localized message is used. var translations = new Dictionary { - ["RequiredKey"] = "Explicit {0}.", - ["ConventionKey"] = "Convention {0}.", + ["UnrelatedKey"] = "unused", }; - var providerCalled = false; - var (provider, options) = CreateServices(translations, o => o.MessageKeyProvider = _ => + var (provider, options) = CreateServices(translations); + var typeInfo = GeneratedValidationTestHelpers.GetTypeInfo(options); + var context = GeneratedValidationTestHelpers.CreateContext(provider, options); + + await ValidateAsync(typeInfo, new LocalizedDefaultModel(), context, useAsync, default); + + Assert.Equal("The Name field is required.", Single(context, "Name")); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task TypeLevelAttribute_WithoutMemberNames_SkipsMemberTier(bool useAsync) + { + // A type-level attribute that reports no member names has no member to key on, so the + // member tier is skipped rather than repeating the type name in the key. + var translations = new Dictionary { - providerCalled = true; - return "ConventionKey"; - }); + ["LocalizedTypeLevelModel_LocalizedTypeLevelModel_AlwaysFailsAttribute_Error"] = "doubled key", + ["LocalizedTypeLevelModel_AlwaysFailsAttribute_Error"] = "{0} failed type-level validation.", + }; + var (provider, options) = CreateServices(translations); + var typeInfo = GeneratedValidationTestHelpers.GetTypeInfo(options); + var context = GeneratedValidationTestHelpers.CreateContext(provider, options); + + await ValidateAsync(typeInfo, new LocalizedTypeLevelModel(), context, useAsync, default); + + Assert.Equal("LocalizedTypeLevelModel failed type-level validation.", Single(context, string.Empty)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Property_ExplicitErrorMessage_WinsOverConvention(bool useAsync) + { + // An explicit ErrorMessage on the validator is used as the key directly; the built-in + // convention is not consulted. + var translations = new Dictionary + { + ["RequiredKey"] = "Explicit {0}.", + ["LocalizedKeyedModel_Name_RequiredAttribute_Error"] = "Convention {0}.", + ["RequiredAttribute_Error"] = "Convention {0}.", + }; + var (provider, options) = CreateServices(translations); var typeInfo = GeneratedValidationTestHelpers.GetTypeInfo(options); var context = GeneratedValidationTestHelpers.CreateContext(provider, options); await ValidateAsync(typeInfo, new LocalizedKeyedModel(), context, useAsync, default); Assert.Equal("Explicit Customer Name.", Single(context, "Name")); - Assert.False(providerCalled); } [Theory] @@ -273,6 +347,54 @@ public async Task Parameter_LocalizerProvider_InvokedWithParameterType_AndUsed(b Assert.Equal("The Nom du paramètre field is required.", Single(context, "value")); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Parameter_ConventionKey_OmitsFrameworkTypeSegment(bool useAsync) + { + // The parameter's declaring type is its own type (string), which carries no app-specific + // meaning, so the type segment is dropped rather than producing "String_value_...". + var translations = new Dictionary + { + ["String_value_RequiredAttribute_Error"] = "framework-typed key", + ["String_RequiredAttribute_Error"] = "framework-typed key", + ["value_RequiredAttribute_Error"] = "{0} is required for this parameter.", + ["RequiredAttribute_Error"] = "{0} is mandatory.", + }; + var (provider, options) = CreateServices(translations); + var parameterInfo = typeof(LocalizedParameterActions) + .GetMethod(nameof(LocalizedParameterActions.Action))! + .GetParameters()[0]; + Assert.True(options.TryGetValidatableParameterInfo(parameterInfo, out var paramInfo)); + var context = GeneratedValidationTestHelpers.CreateContext(provider, options); + + await ValidateAsync(paramInfo, null, context, useAsync, default); + + Assert.Equal("Parameter Name is required for this parameter.", Single(context, "value")); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Parameter_ConventionKey_FallsBackToGlobalTier(bool useAsync) + { + var translations = new Dictionary + { + ["String_RequiredAttribute_Error"] = "framework-typed key", + ["RequiredAttribute_Error"] = "{0} is mandatory.", + }; + var (provider, options) = CreateServices(translations); + var parameterInfo = typeof(LocalizedParameterActions) + .GetMethod(nameof(LocalizedParameterActions.Action))! + .GetParameters()[0]; + Assert.True(options.TryGetValidatableParameterInfo(parameterInfo, out var paramInfo)); + var context = GeneratedValidationTestHelpers.CreateContext(provider, options); + + await ValidateAsync(paramInfo, null, context, useAsync, default); + + Assert.Equal("Parameter Name is mandatory.", Single(context, "value")); + } + private static string Single(ValidateContext context, string key) => Assert.Single(context.ValidationErrors![key].Select(e => e.ErrorMessage)); @@ -348,6 +470,19 @@ public class LocalizedSelfFormattingModel public string? Value { get; set; } } +[ValidatableType] +[AlwaysFails] +public class LocalizedTypeLevelModel +{ + public string? Value { get; set; } +} + +[AttributeUsage(AttributeTargets.Class)] +public sealed class AlwaysFailsAttribute : ValidationAttribute +{ + public override bool IsValid(object? value) => false; +} + [ValidatableType] public class LocalizedResourceErrorModel {