From 1a7f40330f30a26904c770b670b29e86f0e4516b Mon Sep 17 00:00:00 2001 From: OleRoss Date: Sat, 4 Apr 2026 11:20:27 +0200 Subject: [PATCH 1/7] refactor: Setup ObservableValidator for compiled validation --- .../ComponentModel/ObservableValidator.cs | 314 ++++++++---------- .../__ObservableValidatorHelper.cs | 5 - 2 files changed, 139 insertions(+), 180 deletions(-) diff --git a/src/CommunityToolkit.Mvvm/ComponentModel/ObservableValidator.cs b/src/CommunityToolkit.Mvvm/ComponentModel/ObservableValidator.cs index 6c83c9125..9ceb794e8 100644 --- a/src/CommunityToolkit.Mvvm/ComponentModel/ObservableValidator.cs +++ b/src/CommunityToolkit.Mvvm/ComponentModel/ObservableValidator.cs @@ -9,44 +9,39 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Linq.Expressions; -using System.Reflection; using System.Runtime.CompilerServices; namespace CommunityToolkit.Mvvm.ComponentModel; /// -/// A base class for objects implementing the interface. This class -/// also inherits from , so it can be used for observable items too. +/// A base class for observable objects implementing the interface. /// +/// +/// This type stores validation state and exposes helper APIs to validate individual properties or all +/// properties in an instance. The actual validation logic can be provided either by derived types or by +/// source-generated code overriding the available validation hooks. +/// public abstract class ObservableValidator : ObservableObject, INotifyDataErrorInfo { /// - /// The instance used to track compiled delegates to validate entities. + /// The cached for . /// - private static readonly ConditionalWeakTable> EntityValidatorMap = new(); + private static readonly PropertyChangedEventArgs HasErrorsChangedEventArgs = new(nameof(HasErrors)); /// - /// The instance used to track display names for properties to validate. + /// The optional instance to use when creating a . /// - /// - /// This is necessary because we want to reuse the same instance for all validations, but - /// with the same behavior with respect to formatted names that new instances would have provided. The issue is that the - /// property is not refreshed when we set , - /// so we need to replicate the same logic to retrieve the right display name for properties to validate and update that - /// property manually right before passing the context to and proceed with the normal functionality. - /// - private static readonly ConditionalWeakTable> DisplayNamesMap = new(); + private readonly IServiceProvider? validationServiceProvider; /// - /// The cached for . + /// The optional copied items to use when creating a . /// - private static readonly PropertyChangedEventArgs HasErrorsChangedEventArgs = new(nameof(HasErrors)); + private readonly Dictionary? validationItems; /// /// The instance currently in use. /// - private readonly ValidationContext validationContext; + private ValidationContext? validationContext; /// /// The instance used to store previous validation results. @@ -65,53 +60,54 @@ public abstract class ObservableValidator : ObservableObject, INotifyDataErrorIn /// /// Initializes a new instance of the class. - /// This constructor will create a new that will - /// be used to validate all properties, which will reference the current instance - /// and no additional services or validation properties and settings. + /// This constructor will lazily create a new when validation runs. + /// The created context will reference the current instance and no additional services or validation items. /// - [RequiresUnreferencedCode("The type of the current instance cannot be statically discovered.")] protected ObservableValidator() { - this.validationContext = new ValidationContext(this); } /// /// Initializes a new instance of the class. - /// This constructor will create a new that will - /// be used to validate all properties, which will reference the current instance. + /// This constructor will lazily create a new when validation runs. + /// The created context will reference the current instance and expose the specified validation items. /// /// A set of key/value pairs to make available to consumers. - [RequiresUnreferencedCode("The type of the current instance cannot be statically discovered.")] protected ObservableValidator(IDictionary? items) { - this.validationContext = new ValidationContext(this, items); + if (items is not null) + { + this.validationItems = new Dictionary(items); + } } /// /// Initializes a new instance of the class. - /// This constructor will create a new that will - /// be used to validate all properties, which will reference the current instance. + /// This constructor will lazily create a new when validation runs. + /// The created context will reference the current instance and expose the specified services and validation items. /// /// An instance to make available during validation. /// A set of key/value pairs to make available to consumers. - [RequiresUnreferencedCode("The type of the current instance cannot be statically discovered.")] protected ObservableValidator(IServiceProvider? serviceProvider, IDictionary? items) { - this.validationContext = new ValidationContext(this, serviceProvider, items); + this.validationServiceProvider = serviceProvider; + + if (items is not null) + { + this.validationItems = new Dictionary(items); + } } /// /// Initializes a new instance of the class. - /// This constructor will store the input instance, - /// and it will use it to validate all properties for the current viewmodel. + /// This constructor stores the input instance for later reuse. /// /// /// The instance to use to validate properties. /// - /// This instance will be passed to all - /// calls executed by the current viewmodel, and its property will be updated every time - /// before the call is made to set the name of the property being validated. The property name will not be reset after that, so the - /// value of will always indicate the name of the last property that was validated, if any. + /// This instance will be reused by the validation helpers in this type. Its + /// and properties will be updated before validating a property, and they will + /// keep the values from the last validation operation. /// /// /// Thrown if is . @@ -145,7 +141,6 @@ protected ObservableValidator(ValidationContext validationContext) /// are not raised if the current and new value for the target property are the same. /// /// Thrown if is . - [RequiresUnreferencedCode("The type of the current instance cannot be statically discovered.")] protected bool SetProperty([NotNullIfNotNull(nameof(newValue))] ref T field, T newValue, bool validate, [CallerMemberName] string propertyName = null!) { ArgumentNullException.ThrowIfNull(propertyName); @@ -174,7 +169,6 @@ protected bool SetProperty([NotNullIfNotNull(nameof(newValue))] ref T field, /// (optional) The name of the property that changed. /// if the property was changed, otherwise. /// Thrown if or are . - [RequiresUnreferencedCode("The type of the current instance cannot be statically discovered.")] protected bool SetProperty([NotNullIfNotNull(nameof(newValue))] ref T field, T newValue, IEqualityComparer comparer, bool validate, [CallerMemberName] string propertyName = null!) { ArgumentNullException.ThrowIfNull(comparer); @@ -211,7 +205,6 @@ protected bool SetProperty([NotNullIfNotNull(nameof(newValue))] ref T field, /// are not raised if the current and new value for the target property are the same. /// /// Thrown if or are . - [RequiresUnreferencedCode("The type of the current instance cannot be statically discovered.")] protected bool SetProperty(T oldValue, T newValue, Action callback, bool validate, [CallerMemberName] string propertyName = null!) { ArgumentNullException.ThrowIfNull(callback); @@ -242,7 +235,6 @@ protected bool SetProperty(T oldValue, T newValue, Action callback, bool v /// (optional) The name of the property that changed. /// if the property was changed, otherwise. /// Thrown if , or are . - [RequiresUnreferencedCode("The type of the current instance cannot be statically discovered.")] protected bool SetProperty(T oldValue, T newValue, IEqualityComparer comparer, Action callback, bool validate, [CallerMemberName] string propertyName = null!) { ArgumentNullException.ThrowIfNull(comparer); @@ -277,7 +269,6 @@ protected bool SetProperty(T oldValue, T newValue, IEqualityComparer compa /// (optional) The name of the property that changed. /// if the property was changed, otherwise. /// Thrown if , or are . - [RequiresUnreferencedCode("The type of the current instance cannot be statically discovered.")] protected bool SetProperty(T oldValue, T newValue, TModel model, Action callback, bool validate, [CallerMemberName] string propertyName = null!) where TModel : class { @@ -315,7 +306,6 @@ protected bool SetProperty(T oldValue, T newValue, TModel model, Acti /// (optional) The name of the property that changed. /// if the property was changed, otherwise. /// Thrown if , , or are . - [RequiresUnreferencedCode("The type of the current instance cannot be statically discovered.")] protected bool SetProperty(T oldValue, T newValue, IEqualityComparer comparer, TModel model, Action callback, bool validate, [CallerMemberName] string propertyName = null!) where TModel : class { @@ -345,7 +335,6 @@ protected bool SetProperty(T oldValue, T newValue, IEqualityComparer< /// (optional) The name of the property that changed. /// Whether the validation was successful and the property value changed as well. /// Thrown if is . - [RequiresUnreferencedCode("The type of the current instance cannot be statically discovered.")] protected bool TrySetProperty(ref T field, T newValue, out IReadOnlyCollection errors, [CallerMemberName] string propertyName = null!) { ArgumentNullException.ThrowIfNull(propertyName); @@ -366,7 +355,6 @@ protected bool TrySetProperty(ref T field, T newValue, out IReadOnlyCollectio /// (optional) The name of the property that changed. /// Whether the validation was successful and the property value changed as well. /// Thrown if or are . - [RequiresUnreferencedCode("The type of the current instance cannot be statically discovered.")] protected bool TrySetProperty(ref T field, T newValue, IEqualityComparer comparer, out IReadOnlyCollection errors, [CallerMemberName] string propertyName = null!) { ArgumentNullException.ThrowIfNull(comparer); @@ -388,7 +376,6 @@ protected bool TrySetProperty(ref T field, T newValue, IEqualityComparer c /// (optional) The name of the property that changed. /// Whether the validation was successful and the property value changed as well. /// Thrown if or are . - [RequiresUnreferencedCode("The type of the current instance cannot be statically discovered.")] protected bool TrySetProperty(T oldValue, T newValue, Action callback, out IReadOnlyCollection errors, [CallerMemberName] string propertyName = null!) { ArgumentNullException.ThrowIfNull(callback); @@ -411,7 +398,6 @@ protected bool TrySetProperty(T oldValue, T newValue, Action callback, out /// (optional) The name of the property that changed. /// Whether the validation was successful and the property value changed as well. /// Thrown if , or are . - [RequiresUnreferencedCode("The type of the current instance cannot be statically discovered.")] protected bool TrySetProperty(T oldValue, T newValue, IEqualityComparer comparer, Action callback, out IReadOnlyCollection errors, [CallerMemberName] string propertyName = null!) { ArgumentNullException.ThrowIfNull(comparer); @@ -436,7 +422,6 @@ protected bool TrySetProperty(T oldValue, T newValue, IEqualityComparer co /// (optional) The name of the property that changed. /// Whether the validation was successful and the property value changed as well. /// Thrown if , or are . - [RequiresUnreferencedCode("The type of the current instance cannot be statically discovered.")] protected bool TrySetProperty(T oldValue, T newValue, TModel model, Action callback, out IReadOnlyCollection errors, [CallerMemberName] string propertyName = null!) where TModel : class { @@ -463,7 +448,6 @@ protected bool TrySetProperty(T oldValue, T newValue, TModel model, A /// (optional) The name of the property that changed. /// Whether the validation was successful and the property value changed as well. /// Thrown if , , or are . - [RequiresUnreferencedCode("The type of the current instance cannot be statically discovered.")] protected bool TrySetProperty(T oldValue, T newValue, IEqualityComparer comparer, TModel model, Action callback, out IReadOnlyCollection errors, [CallerMemberName] string propertyName = null!) where TModel : class { @@ -492,7 +476,7 @@ protected void ClearErrors(string? propertyName = null) } else { - ClearErrorsForProperty(propertyName!); + ClearErrorsForProperty(propertyName); } } @@ -513,7 +497,7 @@ IEnumerable GetAllErrors() } // Property-level errors, if any - if (this.errors.TryGetValue(propertyName!, out List? errors)) + if (this.errors.TryGetValue(propertyName, out List? errors)) { return errors; } @@ -530,104 +514,83 @@ IEnumerable GetAllErrors() IEnumerable INotifyDataErrorInfo.GetErrors(string? propertyName) => GetErrors(propertyName); /// - /// Validates all the properties in the current instance and updates all the tracked errors. - /// If any changes are detected, the event will be raised. + /// Validates all properties in the current instance and updates the tracked errors. /// /// - /// Only public instance properties (excluding custom indexers) that have at least one - /// applied to them will be validated. All other - /// members in the current instance will be ignored. None of the processed properties - /// will be modified - they will only be used to retrieve their values and validate them. + /// This method delegates to . /// - [RequiresUnreferencedCode( - "This method requires the generated CommunityToolkit.Mvvm.ComponentModel.__Internals.__ObservableValidatorExtensions type not to be removed to use the fast path. " + - "If this type is removed by the linker, or if the target recipient was created dynamically and was missed by the source generator, a slower fallback " + - "path using a compiled LINQ expression will be used. This will have more overhead in the first invocation of this method for any given recipient type. " + - "Additionally, due to the usage of validation APIs, the type of the current instance cannot be statically discovered.")] protected void ValidateAllProperties() { - // Fast path that tries to create a delegate from a generated type-specific method. This - // is used to make this method more AOT-friendly and faster, as there is no dynamic code. - [RequiresUnreferencedCode("The type of the current instance cannot be statically discovered.")] - static Action GetValidationAction(Type type) - { - if (type.Assembly.GetType("CommunityToolkit.Mvvm.ComponentModel.__Internals.__ObservableValidatorExtensions") is Type extensionsType && - extensionsType.GetMethod("CreateAllPropertiesValidator", new[] { type }) is MethodInfo methodInfo) - { - return (Action)methodInfo.Invoke(null, new object?[] { null })!; - } - - return GetValidationActionFallback(type); - } + ValidateAllPropertiesCore(); + } - // Fallback method to create the delegate with a compiled LINQ expression - static Action GetValidationActionFallback(Type type) - { - // Get the collection of all properties to validate - (string Name, MethodInfo GetMethod)[] validatableProperties = ( - from property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public) - where property.GetIndexParameters().Length == 0 && - property.GetCustomAttributes(true).Any() - let getMethod = property.GetMethod - where getMethod is not null - select (property.Name, getMethod)).ToArray(); - - // Short path if there are no properties to validate - if (validatableProperties.Length == 0) - { - return static _ => { }; - } + /// + /// Validates all properties in the current instance. + /// + /// + /// The default implementation does nothing. + /// Derived types can override this method to validate all relevant properties and update the tracked errors. + /// + protected virtual void ValidateAllPropertiesCore() {} - // MyViewModel inst0 = (MyViewModel)arg0; - ParameterExpression arg0 = Expression.Parameter(typeof(object)); - UnaryExpression inst0 = Expression.Convert(arg0, type); - - // Get a reference to ValidateProperty(object, string) - MethodInfo validateMethod = typeof(ObservableValidator).GetMethod(nameof(ValidateProperty), BindingFlags.Instance | BindingFlags.NonPublic)!; - - // We want a single compiled LINQ expression that validates all properties in the - // actual type of the executing viewmodel at once. We do this by creating a block - // expression with the unrolled invocations of all properties to validate. - // Essentially, the body will contain the following code: - // =============================================================================== - // { - // inst0.ValidateProperty(inst0.Property0, nameof(MyViewModel.Property0)); - // inst0.ValidateProperty(inst0.Property1, nameof(MyViewModel.Property1)); - // ... - // inst0.ValidateProperty(inst0.PropertyN, nameof(MyViewModel.PropertyN)); - // } - // =============================================================================== - // We also add an explicit object conversion to represent boxing, if a given property - // is a value type. It will just be a no-op if the value is a reference type already. - // Note that this generated code is technically accessing a protected method from - // ObservableValidator externally, but that is fine because IL doesn't really have - // a concept of member visibility, that's purely a C# build-time feature. - BlockExpression body = Expression.Block( - from property in validatableProperties - select Expression.Call(inst0, validateMethod, new Expression[] - { - Expression.Convert(Expression.Call(inst0, property.GetMethod), typeof(object)), - Expression.Constant(property.Name) - })); + /// + /// Tries to validate a property with the specified name and value, adding any errors to the target collection. + /// + /// The value to validate. + /// The name of the property to validate. + /// The target collection for validation errors. + /// + /// A value indicating whether validation succeeded, failed, or was not handled. + /// + /// + /// The default implementation returns . + /// Derived types can override this method to provide property-specific validation logic. + /// + protected virtual ValidationStatus TryValidatePropertyCore(object? value, string propertyName, ICollection errors) + { + return ValidationStatus.Unhandled; + } - return Expression.Lambda>(body, arg0).Compile(); + /// + /// Validates a property value through . + /// + /// The value to validate. + /// The name of the property to validate. + /// The display name to use for validation messages. + /// The explicit validation attributes to use. + /// The target collection for validation errors. + /// if the property is valid, otherwise . + /// + /// This helper reuses a shared instance, updating its + /// and properties for the target property. + /// + protected ValidationStatus TryValidateValue(object? value, string propertyName, string displayName, IEnumerable validationAttributes, ICollection errors) + { + ArgumentNullException.ThrowIfNull(propertyName); + ArgumentNullException.ThrowIfNull(displayName); + ArgumentNullException.ThrowIfNull(validationAttributes); + ArgumentNullException.ThrowIfNull(errors); + if (displayName.Length == 0) + { + throw new ArgumentException("The display name cannot be empty.", nameof(displayName)); } - // Get or compute the cached list of properties to validate. Here we're using a static lambda to ensure the - // delegate is cached by the C# compiler, see the related issue at https://github.com/dotnet/roslyn/issues/5835. - EntityValidatorMap.GetValue( - GetType(), - [RequiresUnreferencedCode("The type of the current instance cannot be statically discovered.")] static (t) => GetValidationAction(t))(this); + ValidationContext updatedContext = GetOrCreateUpdatedValidationContext(propertyName, displayName); + + return Validator.TryValidateValue(value, updatedContext, errors, validationAttributes) + ? ValidationStatus.Success + : ValidationStatus.Error; } /// - /// Validates a property with a specified name and a given input value. - /// If any changes are detected, the event will be raised. + /// Validates a property with a specified name and value and updates the tracked errors for that property. /// /// The value to test for the specified property. /// The name of the property to validate. /// Thrown when is . - [RequiresUnreferencedCode("The type of the current instance cannot be statically discovered.")] + /// + /// Thrown when the validation request is not handled by . + /// protected internal void ValidateProperty(object? value, [CallerMemberName] string propertyName = null!) { ArgumentNullException.ThrowIfNull(propertyName); @@ -653,11 +616,7 @@ protected internal void ValidateProperty(object? value, [CallerMemberName] strin errorsChanged = true; } - // Validate the property, by adding new errors to the existing list - this.validationContext.MemberName = propertyName; - this.validationContext.DisplayName = GetDisplayNameForProperty(propertyName); - - bool isValid = Validator.TryValidateProperty(value, this.validationContext, propertyErrors); + ValidationStatus isValid = TryValidatePropertyCore(value, propertyName, propertyErrors); // Update the shared counter for the number of errors, and raise the // property changed event if necessary. We decrement the number of total @@ -665,7 +624,7 @@ protected internal void ValidateProperty(object? value, [CallerMemberName] strin // validation, and we increment it if the validation failed after being // correct before. The property changed event is raised whenever the // number of total errors is either decremented to 0, or incremented to 1. - if (isValid) + if (isValid is ValidationStatus.Success) { if (errorsChanged) { @@ -677,6 +636,10 @@ protected internal void ValidateProperty(object? value, [CallerMemberName] strin } } } + else if (isValid is ValidationStatus.Unhandled) + { + throw new InvalidOperationException($"The requested property {propertyName} was not handled"); + } else if (!errorsChanged) { this.totalErrors++; @@ -690,44 +653,37 @@ protected internal void ValidateProperty(object? value, [CallerMemberName] strin // Only raise the event once if needed. This happens either when the target property // had existing errors and is now valid, or if the validation has failed and there are // new errors to broadcast, regardless of the previous validation state for the property. - if (errorsChanged || !isValid) + if (errorsChanged || isValid is not ValidationStatus.Success) { ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName)); } } /// - /// Tries to validate a property with a specified name and a given input value, and returns - /// the computed errors, if any. If the property is valid, it is assumed that its value is - /// about to be set in the current object. Otherwise, no observable local state is modified. + /// Tries to validate a property with a specified name and value and returns the computed errors, if any. /// /// The value to test for the specified property. /// The name of the property to validate. /// The resulting validation errors, if any. - [RequiresUnreferencedCode("The type of the current instance cannot be statically discovered.")] private bool TryValidateProperty(object? value, string propertyName, out IReadOnlyCollection errors) { // Add the cached errors list for later use. - if (!this.errors.TryGetValue(propertyName!, out List? propertyErrors)) + if (!this.errors.TryGetValue(propertyName, out List? propertyErrors)) { propertyErrors = new List(); - this.errors.Add(propertyName!, propertyErrors); + this.errors.Add(propertyName, propertyErrors); } bool hasErrors = propertyErrors.Count > 0; List localErrors = new(); - // Validate the property, by adding new errors to the local list - this.validationContext.MemberName = propertyName; - this.validationContext.DisplayName = GetDisplayNameForProperty(propertyName!); - - bool isValid = Validator.TryValidateProperty(value, this.validationContext, localErrors); + ValidationStatus isValid = TryValidatePropertyCore(value, propertyName, localErrors); // We only modify the state if the property is valid and it wasn't so before. In this case, we // clear the cached list of errors (which is visible to consumers) and raise the necessary events. - if (isValid && hasErrors) + if ((isValid is ValidationStatus.Success) && hasErrors) { propertyErrors.Clear(); @@ -743,7 +699,7 @@ private bool TryValidateProperty(object? value, string propertyName, out IReadOn errors = localErrors; - return isValid; + return isValid is ValidationStatus.Success; } /// @@ -781,7 +737,7 @@ private void ClearAllErrors() /// The name of the property to clear errors for. private void ClearErrorsForProperty(string propertyName) { - if (!this.errors.TryGetValue(propertyName!, out List? propertyErrors) || + if (!this.errors.TryGetValue(propertyName, out List? propertyErrors) || propertyErrors.Count == 0) { return; @@ -800,33 +756,41 @@ private void ClearErrorsForProperty(string propertyName) } /// - /// Gets the display name for a given property. It could be a custom name or just the property name. + /// Lazily creates or updates the shared validation context for a target property. /// /// The target property name being validated. - /// The display name for the property. - [RequiresUnreferencedCode("The type of the current instance cannot be statically discovered.")] - private string GetDisplayNameForProperty(string propertyName) + /// The display name to expose for the target property. + /// The shared instance to use. + private ValidationContext GetOrCreateUpdatedValidationContext(string propertyName, string displayName) { - static Dictionary GetDisplayNames(Type type) - { - Dictionary displayNames = new(); - - foreach (PropertyInfo property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public)) - { - if (property.GetCustomAttribute() is DisplayAttribute attribute && - attribute.GetName() is string displayName) - { - displayNames.Add(property.Name, displayName); - } - } +#pragma warning disable IL2026 // The created ValidationContext object is used in a way that never calls reflection. + ValidationContext context = this.validationContext ??= new ValidationContext(this, this.validationServiceProvider, this.validationItems); +#pragma warning restore IL2026 - return displayNames; - } + context.MemberName = propertyName; + context.DisplayName = displayName; - // This method replicates the logic of DisplayName and GetDisplayName from the - // ValidationContext class. See the original source in the BCL for more details. - _ = DisplayNamesMap.GetValue(GetType(), static t => GetDisplayNames(t)).TryGetValue(propertyName, out string? displayName); + return context; + } - return displayName ?? propertyName; + /// + /// Indicates the outcome of a property validation request. + /// + protected enum ValidationStatus + { + /// + /// Validation succeeded. + /// + Success, + + /// + /// The requested property was not handled. + /// + Unhandled, + + /// + /// Validation failed. + /// + Error } } diff --git a/src/CommunityToolkit.Mvvm/ComponentModel/__Internals/__ObservableValidatorHelper.cs b/src/CommunityToolkit.Mvvm/ComponentModel/__Internals/__ObservableValidatorHelper.cs index cddca2c73..a4a598e4d 100644 --- a/src/CommunityToolkit.Mvvm/ComponentModel/__Internals/__ObservableValidatorHelper.cs +++ b/src/CommunityToolkit.Mvvm/ComponentModel/__Internals/__ObservableValidatorHelper.cs @@ -4,7 +4,6 @@ using System; using System.ComponentModel; -using System.Diagnostics.CodeAnalysis; namespace CommunityToolkit.Mvvm.ComponentModel.__Internals; @@ -24,10 +23,6 @@ public static class __ObservableValidatorHelper /// The name of the property to validate. [EditorBrowsable(EditorBrowsableState.Never)] [Obsolete("This method is not intended to be called directly by user code")] - [UnconditionalSuppressMessage( - "ReflectionAnalysis", - "IL2026:RequiresUnreferencedCode", - Justification = "This helper is called by generated code from public APIs that have the proper annotations already (and we don't want generated code to produce warnings that developers cannot fix).")] public static void ValidateProperty(ObservableValidator instance, object? value, string propertyName) { instance.ValidateProperty(value, propertyName); From 16a9f431396a89930eb2127829a21f36cc87cb33 Mon Sep 17 00:00:00 2001 From: OleRoss Date: Sun, 5 Apr 2026 15:07:00 +0200 Subject: [PATCH 2/7] feat: Add example generator --- ...CommunityToolkit.Mvvm.CodeFixers.projitems | 3 +- ...ObservableValidatorTypePartialCodeFixer.cs | 67 ++++ .../AnalyzerReleases.Unshipped.md | 6 + ...ityToolkit.Mvvm.SourceGenerators.projitems | 9 +- .../ComponentModel/Models/PropertyInfo.cs | 2 + .../Models/PropertyValidationInfo.cs | 11 + .../ComponentModel/Models/ValidationInfo.cs | 7 +- .../Models/ValidationTypeInfo.cs | 14 + .../ObservablePropertyGenerator.Execute.cs | 10 + ...rValidateAllPropertiesGenerator.Execute.cs | 305 --------------- ...ValidatorValidateAllPropertiesGenerator.cs | 96 ----- ...bleValidatorValidationGenerator.Execute.cs | 346 ++++++++++++++++++ .../ObservableValidatorValidationGenerator.cs | 118 ++++++ ...rValidationGeneratorPartialTypeAnalyzer.cs | 99 +++++ .../Diagnostics/DiagnosticDescriptors.cs | 21 ++ .../Extensions/ITypeSymbolExtensions.cs | 37 ++ .../ComponentModel/ObservableValidator.cs | 4 +- 17 files changed, 745 insertions(+), 410 deletions(-) create mode 100644 src/CommunityToolkit.Mvvm.CodeFixers/MakeObservableValidatorTypePartialCodeFixer.cs create mode 100644 src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/PropertyValidationInfo.cs create mode 100644 src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/ValidationTypeInfo.cs delete mode 100644 src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservableValidatorValidateAllPropertiesGenerator.Execute.cs delete mode 100644 src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservableValidatorValidateAllPropertiesGenerator.cs create mode 100644 src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservableValidatorValidationGenerator.Execute.cs create mode 100644 src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservableValidatorValidationGenerator.cs create mode 100644 src/CommunityToolkit.Mvvm.SourceGenerators/Diagnostics/Analyzers/ObservableValidatorValidationGeneratorPartialTypeAnalyzer.cs diff --git a/src/CommunityToolkit.Mvvm.CodeFixers/CommunityToolkit.Mvvm.CodeFixers.projitems b/src/CommunityToolkit.Mvvm.CodeFixers/CommunityToolkit.Mvvm.CodeFixers.projitems index 0b0abb069..fb8bfe9b7 100644 --- a/src/CommunityToolkit.Mvvm.CodeFixers/CommunityToolkit.Mvvm.CodeFixers.projitems +++ b/src/CommunityToolkit.Mvvm.CodeFixers/CommunityToolkit.Mvvm.CodeFixers.projitems @@ -12,10 +12,11 @@ + - \ No newline at end of file + diff --git a/src/CommunityToolkit.Mvvm.CodeFixers/MakeObservableValidatorTypePartialCodeFixer.cs b/src/CommunityToolkit.Mvvm.CodeFixers/MakeObservableValidatorTypePartialCodeFixer.cs new file mode 100644 index 000000000..220c90512 --- /dev/null +++ b/src/CommunityToolkit.Mvvm.CodeFixers/MakeObservableValidatorTypePartialCodeFixer.cs @@ -0,0 +1,67 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Immutable; +using System.Composition; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.SourceGenerators.Diagnostics; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Editing; +using Microsoft.CodeAnalysis.Text; + +namespace CommunityToolkit.Mvvm.CodeFixers; + +/// +/// A code fixer that makes types partial for generated ObservableValidator validation support. +/// +[ExportCodeFixProvider(LanguageNames.CSharp)] +[Shared] +public sealed class MakeObservableValidatorTypePartialCodeFixer : CodeFixProvider +{ + /// + public override ImmutableArray FixableDiagnosticIds { get; } = ImmutableArray.Create(DiagnosticDescriptors.ObservableValidatorTypeMustBePartialId); + + /// + public override FixAllProvider? GetFixAllProvider() + { + return WellKnownFixAllProviders.BatchFixer; + } + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + Diagnostic diagnostic = context.Diagnostics[0]; + TextSpan diagnosticSpan = context.Span; + + SyntaxNode? root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + + if (root?.FindNode(diagnosticSpan).FirstAncestorOrSelf() is TypeDeclarationSyntax typeDeclaration) + { + context.RegisterCodeFix( + CodeAction.Create( + title: "Make type partial", + createChangedDocument: token => MakeTypePartialAsync(context.Document, root, typeDeclaration), + equivalenceKey: "Make type partial"), + diagnostic); + } + } + + /// + /// Applies the code fix to a target type declaration and returns an updated document. + /// + /// The original document being fixed. + /// The original tree root belonging to the current document. + /// The to update. + /// An updated document with the applied code fix. + private static Task MakeTypePartialAsync(Document document, SyntaxNode root, TypeDeclarationSyntax typeDeclaration) + { + SyntaxGenerator generator = SyntaxGenerator.GetGenerator(document); + TypeDeclarationSyntax updatedTypeDeclaration = (TypeDeclarationSyntax)generator.WithModifiers(typeDeclaration, generator.GetModifiers(typeDeclaration).WithPartial(true)); + + return Task.FromResult(document.WithSyntaxRoot(root.ReplaceNode(typeDeclaration, updatedTypeDeclaration))); + } +} diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/AnalyzerReleases.Unshipped.md b/src/CommunityToolkit.Mvvm.SourceGenerators/AnalyzerReleases.Unshipped.md index f2b7fad65..5f1457151 100644 --- a/src/CommunityToolkit.Mvvm.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/src/CommunityToolkit.Mvvm.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -1,2 +1,8 @@ ; Unshipped analyzer release ; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +MVVMTK0057 | CommunityToolkit.Mvvm.SourceGenerators.ObservableValidatorValidationGenerator | Error | See https://aka.ms/mvvmtoolkit/errors/mvvmtk0057 diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/CommunityToolkit.Mvvm.SourceGenerators.projitems b/src/CommunityToolkit.Mvvm.SourceGenerators/CommunityToolkit.Mvvm.SourceGenerators.projitems index c3294780f..81e390625 100644 --- a/src/CommunityToolkit.Mvvm.SourceGenerators/CommunityToolkit.Mvvm.SourceGenerators.projitems +++ b/src/CommunityToolkit.Mvvm.SourceGenerators/CommunityToolkit.Mvvm.SourceGenerators.projitems @@ -28,15 +28,17 @@ + + - - + + @@ -59,6 +61,7 @@ + @@ -113,4 +116,4 @@ - \ No newline at end of file + diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/PropertyInfo.cs b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/PropertyInfo.cs index 1fee5b622..3ee8349ec 100644 --- a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/PropertyInfo.cs +++ b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/PropertyInfo.cs @@ -24,6 +24,7 @@ namespace CommunityToolkit.Mvvm.SourceGenerators.ComponentModel.Models; /// The sequence of commands to notify. /// Whether or not the generated property also broadcasts changes. /// Whether or not the generated property also validates its value. +/// Whether or not the generated property has validation attributes on the effective generated property. /// Whether the old property value is being directly referenced. /// Indicates whether the property is of a reference type or an unconstrained type parameter. /// Indicates whether to include nullability annotations on the setter. @@ -43,6 +44,7 @@ internal sealed record PropertyInfo( EquatableArray NotifiedCommandNames, bool NotifyPropertyChangedRecipients, bool NotifyDataErrorInfo, + bool HasValidationAttributes, bool IsOldPropertyValueDirectlyReferenced, bool IsReferenceTypeOrUnconstrainedTypeParameter, bool IncludeMemberNotNullOnSetAccessor, diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/PropertyValidationInfo.cs b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/PropertyValidationInfo.cs new file mode 100644 index 000000000..4b1b51a52 --- /dev/null +++ b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/PropertyValidationInfo.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace CommunityToolkit.Mvvm.SourceGenerators.ComponentModel.Models; + +/// +/// A model with gathered info on a locally declared validatable property. +/// +/// The name of the property to validate. +internal sealed record PropertyValidationInfo(string PropertyName); diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/ValidationInfo.cs b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/ValidationInfo.cs index 7d5b7b5d9..de7ec1ce1 100644 --- a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/ValidationInfo.cs +++ b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/ValidationInfo.cs @@ -3,13 +3,14 @@ // See the LICENSE file in the project root for more information. using CommunityToolkit.Mvvm.SourceGenerators.Helpers; +using CommunityToolkit.Mvvm.SourceGenerators.Models; namespace CommunityToolkit.Mvvm.SourceGenerators.ComponentModel.Models; /// /// A model with gathered info on all validatable properties in a given type. /// -/// The filename hint for the current type. +/// The hierarchy for the current type. /// The fully qualified type name of the target type. -/// The name of validatable properties. -internal sealed record ValidationInfo(string FilenameHint, string TypeName, EquatableArray PropertyNames); +/// The locally declared validatable properties for the current type. +internal sealed record ValidationInfo(HierarchyInfo Hierarchy, string TypeName, EquatableArray Properties); diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/ValidationTypeInfo.cs b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/ValidationTypeInfo.cs new file mode 100644 index 000000000..208491b23 --- /dev/null +++ b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/ValidationTypeInfo.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Mvvm.SourceGenerators.Models; + +namespace CommunityToolkit.Mvvm.SourceGenerators.ComponentModel.Models; + +/// +/// A model with identity information for a type requiring generated validation hooks. +/// +/// The hierarchy for the target type. +/// The fully qualified type name for the target type. +internal sealed record ValidationTypeInfo(HierarchyInfo Hierarchy, string TypeName); diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservablePropertyGenerator.Execute.cs b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservablePropertyGenerator.Execute.cs index 2b2756ec4..0dc69ca27 100644 --- a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservablePropertyGenerator.Execute.cs +++ b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservablePropertyGenerator.Execute.cs @@ -327,6 +327,7 @@ public static bool TryGetInfo( memberSyntax, memberSymbol, semanticModel, + ref hasAnyValidationAttributes, in forwardedAttributes, in builder, token); @@ -411,6 +412,7 @@ public static bool TryGetInfo( notifiedCommandNames.ToImmutable(), notifyRecipients, notifyDataErrorInfo, + hasAnyValidationAttributes, isOldPropertyValueDirectlyReferenced, isReferenceTypeOrUnconstrainedTypeParameter, includeMemberNotNullOnSetAccessor, @@ -885,6 +887,7 @@ private static void GetNullabilityInfo( /// The instance to process. /// The input instance to process. /// The instance for the current run. + /// Tracks whether the effective generated property has validation attributes. /// The collection of forwarded attributes to add new ones to. /// The current collection of gathered diagnostics. /// The cancellation token for the current operation. @@ -892,6 +895,7 @@ private static void GatherLegacyForwardedAttributes( MemberDeclarationSyntax memberSyntax, ISymbol memberSymbol, SemanticModel semanticModel, + ref bool hasAnyValidationAttributes, in ImmutableArrayBuilder forwardedAttributes, in ImmutableArrayBuilder diagnostics, CancellationToken token) @@ -965,6 +969,12 @@ private static void GatherLegacyForwardedAttributes( continue; } + if (targetIdentifier.IsKind(SyntaxKind.PropertyKeyword) && + attributeTypeSymbol.InheritsFromFullyQualifiedMetadataName("System.ComponentModel.DataAnnotations.ValidationAttribute")) + { + hasAnyValidationAttributes = true; + } + IEnumerable attributeArguments = attribute.ArgumentList?.Arguments ?? Enumerable.Empty(); // Try to extract the forwarded attribute diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservableValidatorValidateAllPropertiesGenerator.Execute.cs b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservableValidatorValidateAllPropertiesGenerator.Execute.cs deleted file mode 100644 index 0b82211ba..000000000 --- a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservableValidatorValidateAllPropertiesGenerator.Execute.cs +++ /dev/null @@ -1,305 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Collections.Immutable; -using System.Linq; -using System.Threading; -using CommunityToolkit.Mvvm.SourceGenerators.ComponentModel.Models; -using CommunityToolkit.Mvvm.SourceGenerators.Extensions; -using CommunityToolkit.Mvvm.SourceGenerators.Helpers; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; - -namespace CommunityToolkit.Mvvm.SourceGenerators; - -/// -partial class ObservableValidatorValidateAllPropertiesGenerator -{ - /// - /// A container for all the logic for . - /// - private static class Execute - { - /// - /// Checks whether a given type inherits from ObservableValidator. - /// - /// The input instance to check. - /// Whether inherits from ObservableValidator. - public static bool IsObservableValidator(INamedTypeSymbol typeSymbol) - { - return typeSymbol.InheritsFromFullyQualifiedMetadataName("CommunityToolkit.Mvvm.ComponentModel.ObservableValidator"); - } - - /// - /// Gets the instance from an input symbol. - /// - /// The input instance to inspect. - /// The cancellation token for the current operation. - /// The resulting instance for . - public static ValidationInfo GetInfo(INamedTypeSymbol typeSymbol, CancellationToken token) - { - using ImmutableArrayBuilder propertyNames = ImmutableArrayBuilder.Rent(); - - foreach (ISymbol memberSymbol in typeSymbol.GetAllMembers()) - { - if (memberSymbol is { IsStatic: true } or not (IPropertySymbol { IsIndexer: false } or IFieldSymbol)) - { - continue; - } - - token.ThrowIfCancellationRequested(); - - ImmutableArray attributes = memberSymbol.GetAttributes(); - - // Also include fields that are annotated with [ObservableProperty]. This is necessary because - // all generators run in an undefined order and looking at the same original compilation, so the - // current one wouldn't be able to see generated properties from other generators directly. - if (memberSymbol is IFieldSymbol && - !attributes.Any(static a => a.AttributeClass?.HasFullyQualifiedMetadataName( - "CommunityToolkit.Mvvm.ComponentModel.ObservablePropertyAttribute") == true)) - { - continue; - } - - // Skip the current member if there are no validation attributes applied to it - if (!attributes.Any(a => a.AttributeClass?.InheritsFromFullyQualifiedMetadataName( - "System.ComponentModel.DataAnnotations.ValidationAttribute") == true)) - { - continue; - } - - // Get the target property name either directly or matching the generated one - string propertyName = memberSymbol switch - { - IPropertySymbol propertySymbol => propertySymbol.Name, - IFieldSymbol fieldSymbol => ObservablePropertyGenerator.Execute.GetGeneratedPropertyName(fieldSymbol), - _ => throw new InvalidOperationException("Invalid symbol type") - }; - - propertyNames.Add(propertyName); - } - - token.ThrowIfCancellationRequested(); - - return new( - typeSymbol.GetFullyQualifiedMetadataName(), - typeSymbol.GetFullyQualifiedName(), - propertyNames.ToImmutable()); - } - - /// - /// Gets the head instance. - /// - /// Indicates whether [DynamicallyAccessedMembers] should be generated. - /// The head instance with the type attributes. - public static CompilationUnitSyntax GetSyntax(bool isDynamicallyAccessedMembersAttributeAvailable) - { - using ImmutableArrayBuilder attributes = ImmutableArrayBuilder.Rent(); - - // Prepare the base attributes with are always present: - // - // /// - // /// A helper type with generated validation stubs for types deriving from . - // /// - // [global::System.CodeDom.Compiler.GeneratedCode("...", "...")] - // [global::System.Diagnostics.DebuggerNonUserCode] - // [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - // [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] - // [global::System.Obsolete("This type is not intended to be used directly by user code")] - attributes.Add( - AttributeList(SingletonSeparatedList( - Attribute(IdentifierName($"global::System.CodeDom.Compiler.GeneratedCode")).AddArgumentListArguments( - AttributeArgument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(typeof(ObservableValidatorValidateAllPropertiesGenerator).FullName))), - AttributeArgument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(typeof(ObservableValidatorValidateAllPropertiesGenerator).Assembly.GetName().Version.ToString())))))) - .WithOpenBracketToken(Token(TriviaList( - Comment("/// "), - Comment("/// A helper type with generated validation stubs for types deriving from ."), - Comment("/// ")), SyntaxKind.OpenBracketToken, TriviaList()))); - attributes.Add(AttributeList(SingletonSeparatedList(Attribute(IdentifierName("global::System.Diagnostics.DebuggerNonUserCode"))))); - attributes.Add(AttributeList(SingletonSeparatedList(Attribute(IdentifierName("global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage"))))); - attributes.Add( - AttributeList(SingletonSeparatedList( - Attribute(IdentifierName("global::System.ComponentModel.EditorBrowsable")).AddArgumentListArguments( - AttributeArgument(ParseExpression("global::System.ComponentModel.EditorBrowsableState.Never")))))); - attributes.Add( - AttributeList(SingletonSeparatedList( - Attribute(IdentifierName("global::System.Obsolete")).AddArgumentListArguments( - AttributeArgument(LiteralExpression( - SyntaxKind.StringLiteralExpression, - Literal("This type is not intended to be used directly by user code"))))))); - - if (isDynamicallyAccessedMembersAttributeAvailable) - { - // Conditionally add the attribute to inform trimming, if the type is available: - // - // [global::System.CodeDom.Compiler.DynamicallyAccessedMembersAttribute(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicMethods)] - attributes.Add( - AttributeList(SingletonSeparatedList( - Attribute(IdentifierName("global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembersAttribute")).AddArgumentListArguments( - AttributeArgument(ParseExpression("global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicMethods")))))); - } - - // This code produces a compilation unit as follows: - // - // // - // #pragma warning disable - // namespace CommunityToolkit.Mvvm.ComponentModel.__Internals - // { - // - // internal static partial class __ObservableValidatorExtensions - // { - // } - // } - return - CompilationUnit().AddMembers( - NamespaceDeclaration(IdentifierName("CommunityToolkit.Mvvm.ComponentModel.__Internals")).WithLeadingTrivia(TriviaList( - Comment("// "), - Trivia(PragmaWarningDirectiveTrivia(Token(SyntaxKind.DisableKeyword), true)))).AddMembers( - ClassDeclaration("__ObservableValidatorExtensions").AddModifiers( - Token(SyntaxKind.InternalKeyword), - Token(SyntaxKind.StaticKeyword), - Token(SyntaxKind.PartialKeyword)) - .AddAttributeLists(attributes.ToArray()))) - .NormalizeWhitespace(); - } - - /// - /// Gets the instance for the input recipient. - /// - /// The input instance to process. - /// The generated instance for . - public static CompilationUnitSyntax GetSyntax(ValidationInfo validationInfo) - { - // Create a static factory method creating a delegate that can be used to validate all properties in a given class. - // This pattern is used so that the library doesn't have to use MakeGenericType(...) at runtime, nor use unsafe casts - // over the created delegate to be able to cache it as an Action instance. This pattern enables the same - // functionality and with almost identical performance (not noticeable in this context anyway), but while preserving - // full runtime type safety (as a safe cast is used to validate the input argument), and with less reflection needed. - // Note that we're deliberately creating a new delegate instance here and not using code that could see the C# compiler - // create a static class to cache a reusable delegate, because each generated method will only be called at most once, - // as the returned delegate will be cached by the MVVM Toolkit itself. So this ensures the produced code is minimal, - // and that there will be no unnecessary static fields and objects being created and possibly never collected. - // This code will produce a syntax tree as follows: - // - // // - // #pragma warning disable - // namespace CommunityToolkit.Mvvm.ComponentModel.__Internals - // { - // /// - // partial class __ObservableValidatorExtensions - // { - // /// - // /// Creates a validation stub for objects. - // /// - // /// Dummy parameter, only used to disambiguate the method signature. - // /// A validation stub for objects. - // [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] - // [global::System.Obsolete("This method is not intended to be called directly by user code")] - // public static global::System.Action CreateAllPropertiesValidator( _) - // { - // static void ValidateAllProperties(object obj) - // { - // var instance = ()obj; - // - // } - // - // return ValidateAllProperties; - // } - // } - // } - return - CompilationUnit().AddMembers( - NamespaceDeclaration(IdentifierName("CommunityToolkit.Mvvm.ComponentModel.__Internals")).WithLeadingTrivia(TriviaList( - Comment("// "), - Trivia(PragmaWarningDirectiveTrivia(Token(SyntaxKind.DisableKeyword), true)))).AddMembers( - ClassDeclaration("__ObservableValidatorExtensions").AddModifiers( - Token(TriviaList(Comment("/// ")), SyntaxKind.PartialKeyword, TriviaList())).AddMembers( - MethodDeclaration( - GenericName("global::System.Action").AddTypeArgumentListArguments(PredefinedType(Token(SyntaxKind.ObjectKeyword))), - Identifier("CreateAllPropertiesValidator")).AddAttributeLists( - AttributeList(SingletonSeparatedList( - Attribute(IdentifierName("global::System.ComponentModel.EditorBrowsable")).AddArgumentListArguments( - AttributeArgument(ParseExpression("global::System.ComponentModel.EditorBrowsableState.Never"))))) - .WithOpenBracketToken(Token(TriviaList( - Comment("/// "), - Comment($"/// Creates a validation stub for objects."), - Comment("/// "), - Comment("/// Dummy parameter, only used to disambiguate the method signature."), - Comment($"/// A validation stub for objects.")), SyntaxKind.OpenBracketToken, TriviaList())), - AttributeList(SingletonSeparatedList( - Attribute(IdentifierName("global::System.Obsolete")).AddArgumentListArguments( - AttributeArgument(LiteralExpression( - SyntaxKind.StringLiteralExpression, - Literal("This method is not intended to be called directly by user code"))))))).AddModifiers( - Token(SyntaxKind.PublicKeyword), - Token(SyntaxKind.StaticKeyword)).AddParameterListParameters( - Parameter(Identifier("_")).WithType(IdentifierName(validationInfo.TypeName))) - .WithBody(Block( - LocalFunctionStatement( - PredefinedType(Token(SyntaxKind.VoidKeyword)), - Identifier("ValidateAllProperties")) - .AddModifiers(Token(SyntaxKind.StaticKeyword)) - .AddParameterListParameters( - Parameter(Identifier("obj")).WithType(PredefinedType(Token(SyntaxKind.ObjectKeyword)))) - .WithBody(Block( - LocalDeclarationStatement( - VariableDeclaration(IdentifierName("var")) // Cannot use Token(SyntaxKind.VarKeyword) here (throws an ArgumentException) - .AddVariables( - VariableDeclarator(Identifier("instance")) - .WithInitializer(EqualsValueClause( - CastExpression( - IdentifierName(validationInfo.TypeName), - IdentifierName("obj"))))))) - .AddStatements(EnumerateValidationStatements(validationInfo).ToArray())), - ReturnStatement(IdentifierName("ValidateAllProperties"))))))) - .NormalizeWhitespace(); - } - - /// - /// Gets a sequence of statements to validate declared properties. - /// - /// The input instance to process. - /// The sequence of instances to validate declared properties. - private static ImmutableArray EnumerateValidationStatements(ValidationInfo validationInfo) - { - using ImmutableArrayBuilder statements = ImmutableArrayBuilder.Rent(); - - // This loop produces a sequence of statements as follows: - // - // __ObservableValidatorHelper.ValidateProperty(instance, instance., nameof(instance.)); - // __ObservableValidatorHelper.ValidateProperty(instance, instance., nameof(instance.)); - // ... - // __ObservableValidatorHelper.ValidateProperty(instance, instance., nameof(instance.)); - foreach (string propertyName in validationInfo.PropertyNames) - { - statements.Add( - ExpressionStatement( - InvocationExpression( - MemberAccessExpression( - SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("__ObservableValidatorHelper"), - IdentifierName("ValidateProperty"))) - .AddArgumentListArguments( - Argument(IdentifierName("instance")), - Argument( - MemberAccessExpression( - SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("instance"), - IdentifierName(propertyName))), - Argument( - InvocationExpression(IdentifierName("nameof")) - .AddArgumentListArguments(Argument( - MemberAccessExpression( - SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("instance"), - IdentifierName(propertyName)))))))); - } - - return statements.ToImmutable(); - } - } -} diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservableValidatorValidateAllPropertiesGenerator.cs b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservableValidatorValidateAllPropertiesGenerator.cs deleted file mode 100644 index 4ec9bc6f0..000000000 --- a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservableValidatorValidateAllPropertiesGenerator.cs +++ /dev/null @@ -1,96 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Linq; -using CommunityToolkit.Mvvm.SourceGenerators.ComponentModel.Models; -using CommunityToolkit.Mvvm.SourceGenerators.Extensions; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace CommunityToolkit.Mvvm.SourceGenerators; - -/// -/// A source generator for property validation without relying on compiled LINQ expressions. -/// -[Generator(LanguageNames.CSharp)] -public sealed partial class ObservableValidatorValidateAllPropertiesGenerator : IIncrementalGenerator -{ - /// - public void Initialize(IncrementalGeneratorInitializationContext context) - { - // Get the types that inherit from ObservableValidator and gather their info - IncrementalValuesProvider validationInfo = - context.SyntaxProvider - .CreateSyntaxProvider( - static (node, _) => node is ClassDeclarationSyntax classDeclaration && classDeclaration.HasOrPotentiallyHasBaseTypes(), - static (context, token) => - { - if (!context.SemanticModel.Compilation.HasLanguageVersionAtLeastEqualTo(LanguageVersion.CSharp8)) - { - return default; - } - - INamedTypeSymbol typeSymbol = (INamedTypeSymbol)context.SemanticModel.GetDeclaredSymbol(context.Node, token)!; - - // Skip generating code for abstract types, as that would never be used. The methods that are generated by - // this generator are retrieved through reflection using the type of the invoking instance as discriminator, - // which means a type that is abstract could never be used (since it couldn't be instantiated). - if (typeSymbol is not { IsAbstract: false, IsGenericType: false }) - { - return default; - } - - // Just like in IMessengerRegisterAllGenerator, only select the first declaration for this type symbol - if (!context.Node.IsFirstSyntaxDeclarationForSymbol(typeSymbol)) - { - return default; - } - - token.ThrowIfCancellationRequested(); - - // Only select types inheriting from ObservableValidator - if (!Execute.IsObservableValidator(typeSymbol)) - { - return default; - } - - token.ThrowIfCancellationRequested(); - - return Execute.GetInfo(typeSymbol, token); - }) - .Where(static item => item is not null)!; - - // Check whether the header file is needed - IncrementalValueProvider isHeaderFileNeeded = - validationInfo - .Collect() - .Select(static (item, _) => item.Length > 0); - - // Check whether [DynamicallyAccessedMembers] is available - IncrementalValueProvider isDynamicallyAccessedMembersAttributeAvailable = - context.CompilationProvider - .Select(static (item, _) => item.HasAccessibleTypeWithMetadataName("System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembersAttribute")); - - // Gather the conditional flag and attribute availability - IncrementalValueProvider<(bool IsHeaderFileNeeded, bool IsDynamicallyAccessedMembersAttributeAvailable)> headerFileInfo = - isHeaderFileNeeded.Combine(isDynamicallyAccessedMembersAttributeAvailable); - - // Generate the header file with the attributes - context.RegisterConditionalImplementationSourceOutput(headerFileInfo, static (context, item) => - { - CompilationUnitSyntax compilationUnit = Execute.GetSyntax(item); - - context.AddSource("__ObservableValidatorExtensions.g.cs", compilationUnit); - }); - - // Generate the class with all validation methods - context.RegisterImplementationSourceOutput(validationInfo, static (context, item) => - { - CompilationUnitSyntax compilationUnit = Execute.GetSyntax(item); - - context.AddSource($"{item.FilenameHint}.g.cs", compilationUnit); - }); - } -} diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservableValidatorValidationGenerator.Execute.cs b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservableValidatorValidationGenerator.Execute.cs new file mode 100644 index 000000000..c755d7f28 --- /dev/null +++ b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservableValidatorValidationGenerator.Execute.cs @@ -0,0 +1,346 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading; +using CommunityToolkit.Mvvm.SourceGenerators.ComponentModel.Models; +using CommunityToolkit.Mvvm.SourceGenerators.Extensions; +using CommunityToolkit.Mvvm.SourceGenerators.Helpers; +using CommunityToolkit.Mvvm.SourceGenerators.Models; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace CommunityToolkit.Mvvm.SourceGenerators; + +/// +partial class ObservableValidatorValidationGenerator +{ + /// + /// A container for all the logic for . + /// + private static class Execute + { + /// + /// Checks whether a given type inherits from ObservableValidator. + /// + /// The input instance to check. + /// Whether inherits from ObservableValidator. + public static bool IsObservableValidator(INamedTypeSymbol typeSymbol) + { + return typeSymbol.InheritsFromFullyQualifiedMetadataName("CommunityToolkit.Mvvm.ComponentModel.ObservableValidator"); + } + + /// + /// Gets the instance from an input symbol. + /// + /// The input instance to inspect. + /// The cancellation token for the current operation. + /// The resulting instance for , if available. + public static ValidationInfo? GetInfo(INamedTypeSymbol typeSymbol, CancellationToken token) + { + if (!typeSymbol.IsTypeHierarchyPartial()) + { + return default; + } + + using ImmutableArrayBuilder properties = ImmutableArrayBuilder.Rent(); + + foreach (ISymbol memberSymbol in typeSymbol.GetMembers()) + { + if (memberSymbol is not IPropertySymbol propertySymbol || + propertySymbol.IsStatic || + propertySymbol.IsIndexer || + !propertySymbol.CanBeReferencedByName) + { + continue; + } + + token.ThrowIfCancellationRequested(); + + if (!propertySymbol.GetAttributes().Any(static a => a.AttributeClass?.InheritsFromFullyQualifiedMetadataName( + "System.ComponentModel.DataAnnotations.ValidationAttribute") == true)) + { + continue; + } + + properties.Add(new(propertySymbol.Name)); + } + + token.ThrowIfCancellationRequested(); + + if (properties.WrittenSpan.IsEmpty) + { + return default; + } + + return new( + HierarchyInfo.From(typeSymbol), + typeSymbol.GetFullyQualifiedName(), + properties.ToImmutable()); + } + + /// + /// Gets the validation info for a field-backed observable property, if applicable. + /// + /// The source member declaration for the annotated field. + /// The field symbol annotated with [ObservableProperty]. + /// The semantic model for the current run. + /// The analyzer config options in use. + /// The cancellation token for the current operation. + /// The validation info for , if available. + public static (ValidationTypeInfo Left, PropertyValidationInfo Right) GetGeneratedObservablePropertyValidationInfo( + MemberDeclarationSyntax memberSyntax, + IFieldSymbol fieldSymbol, + SemanticModel semanticModel, + AnalyzerConfigOptions options, + CancellationToken token) + { + if (!fieldSymbol.ContainingType.IsTypeHierarchyPartial() || + !IsObservableValidator(fieldSymbol.ContainingType) || + !ObservablePropertyGenerator.Execute.TryGetInfo(memberSyntax, fieldSymbol, semanticModel, options, token, out PropertyInfo? propertyInfo, out _ ) || + propertyInfo is not { HasValidationAttributes: true }) + { + return default; + } + + return ( + new ValidationTypeInfo(HierarchyInfo.From(fieldSymbol.ContainingType), fieldSymbol.ContainingType.GetFullyQualifiedName()), + new PropertyValidationInfo(propertyInfo.PropertyName)); + } + + /// + /// Gets the instance for the input recipient. + /// + /// The input instance to process. + /// The generated instance for . + public static CompilationUnitSyntax GetSyntax(ValidationInfo validationInfo) + { + ImmutableArray memberDeclarations = GetMemberDeclarations(validationInfo); + + return validationInfo.Hierarchy.GetCompilationUnit(memberDeclarations); + } + + /// + /// Gets the compatibility header instance. + /// + /// Indicates whether [DynamicallyAccessedMembers] should be generated. + /// The header instance with the compatibility type. + public static CompilationUnitSyntax GetHeaderSyntax(bool isDynamicallyAccessedMembersAttributeAvailable) + { + using ImmutableArrayBuilder attributes = ImmutableArrayBuilder.Rent(); + + attributes.Add( + AttributeList(SingletonSeparatedList( + Attribute(IdentifierName("global::System.CodeDom.Compiler.GeneratedCode")).AddArgumentListArguments( + AttributeArgument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(typeof(ObservableValidatorValidationGenerator).FullName))), + AttributeArgument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(typeof(ObservableValidatorValidationGenerator).Assembly.GetName().Version!.ToString())))))) + .WithOpenBracketToken(Token(TriviaList( + Comment("/// "), + Comment("/// A compatibility helper type for validation-related generated code."), + Comment("/// ")), SyntaxKind.OpenBracketToken, TriviaList()))); + attributes.Add(AttributeList(SingletonSeparatedList(Attribute(IdentifierName("global::System.Diagnostics.DebuggerNonUserCode"))))); + attributes.Add(AttributeList(SingletonSeparatedList(Attribute(IdentifierName("global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage"))))); + attributes.Add( + AttributeList(SingletonSeparatedList( + Attribute(IdentifierName("global::System.ComponentModel.EditorBrowsable")).AddArgumentListArguments( + AttributeArgument(ParseExpression("global::System.ComponentModel.EditorBrowsableState.Never")))))); + attributes.Add( + AttributeList(SingletonSeparatedList( + Attribute(IdentifierName("global::System.Obsolete")).AddArgumentListArguments( + AttributeArgument(LiteralExpression( + SyntaxKind.StringLiteralExpression, + Literal("This type is not intended to be used directly by user code"))))))); + + if (isDynamicallyAccessedMembersAttributeAvailable) + { + attributes.Add( + AttributeList(SingletonSeparatedList( + Attribute(IdentifierName("global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembersAttribute")).AddArgumentListArguments( + AttributeArgument(ParseExpression("global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicMethods")))))); + } + + return + CompilationUnit().AddMembers( + NamespaceDeclaration(IdentifierName("CommunityToolkit.Mvvm.ComponentModel.__Internals")).WithLeadingTrivia(TriviaList( + Comment("// "), + Trivia(PragmaWarningDirectiveTrivia(Token(SyntaxKind.DisableKeyword), true)))).AddMembers( + ClassDeclaration("__ObservableValidatorExtensions").AddModifiers( + Token(SyntaxKind.InternalKeyword), + Token(SyntaxKind.StaticKeyword), + Token(SyntaxKind.PartialKeyword)) + .AddAttributeLists(attributes.ToArray()))) + .NormalizeWhitespace(); + } + + /// + /// Creates the generated members for a target type. + /// + /// The validation info for the target type. + /// The generated members for . + private static ImmutableArray GetMemberDeclarations(ValidationInfo validationInfo) + { + using ImmutableArrayBuilder members = ImmutableArrayBuilder.Rent(); + + string generatorTypeName = typeof(ObservableValidatorValidationGenerator).FullName!; + string generatorAssemblyVersion = typeof(ObservableValidatorValidationGenerator).Assembly.GetName().Version!.ToString(); + + string generatedOverrideAttributes = $$""" + [global::System.CodeDom.Compiler.GeneratedCode("{{generatorTypeName}}", "{{generatorAssemblyVersion}}")] + [global::System.Diagnostics.DebuggerNonUserCode] + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + """; + + string generatedMethodAttributes = $$""" + [global::System.CodeDom.Compiler.GeneratedCode("{{generatorTypeName}}", "{{generatorAssemblyVersion}}")] + [global::System.Diagnostics.DebuggerNonUserCode] + """; + + string generatedFieldAttributes = $$""" + [global::System.CodeDom.Compiler.GeneratedCode("{{generatorTypeName}}", "{{generatorAssemblyVersion}}")] + """; + + foreach ((PropertyValidationInfo propertyInfo, int index) in validationInfo.Properties.Select(static (item, index) => (item, index))) + { + string helperName = GetHelperName(propertyInfo.PropertyName, index); + + members.Add(ParseMemberDeclaration($$""" + {{generatedFieldAttributes}} + private static readonly global::System.Reflection.PropertyInfo {{helperName}}PropertyInfo = typeof({{validationInfo.TypeName}}).GetProperty("{{propertyInfo.PropertyName}}")!; + """)!); + + members.Add(ParseMemberDeclaration($$""" + {{generatedFieldAttributes}} + private static readonly string {{helperName}}DisplayName = __GetDisplayName({{helperName}}PropertyInfo, "{{propertyInfo.PropertyName}}"); + """)!); + + members.Add(ParseMemberDeclaration($$""" + {{generatedFieldAttributes}} + private static readonly global::System.ComponentModel.DataAnnotations.ValidationAttribute[] {{helperName}}ValidationAttributes = __GetValidationAttributes({{helperName}}PropertyInfo); + """)!); + + members.Add(ParseMemberDeclaration($$""" + {{generatedMethodAttributes}} + private ValidationStatus {{helperName}}TryValidate(object? value, global::System.Collections.Generic.ICollection errors) + { + return TryValidateValue(value, "{{propertyInfo.PropertyName}}", {{helperName}}DisplayName, {{helperName}}ValidationAttributes, errors); + } + """)!); + } + + members.Add(ParseMemberDeclaration($$""" + {{generatedMethodAttributes}} + private static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] __GetValidationAttributes(global::System.Reflection.PropertyInfo propertyInfo) + { + return global::System.Array.ConvertAll( + propertyInfo.GetCustomAttributes(typeof(global::System.ComponentModel.DataAnnotations.ValidationAttribute), true), + static item => (global::System.ComponentModel.DataAnnotations.ValidationAttribute)item); + } + """)!); + + members.Add(ParseMemberDeclaration($$""" + {{generatedMethodAttributes}} + private static string __GetDisplayName(global::System.Reflection.PropertyInfo propertyInfo, string propertyName) + { + return ((global::System.ComponentModel.DataAnnotations.DisplayAttribute?)global::System.Attribute.GetCustomAttribute(propertyInfo, typeof(global::System.ComponentModel.DataAnnotations.DisplayAttribute)))?.GetName() + ?? ((global::System.ComponentModel.DisplayNameAttribute?)global::System.Attribute.GetCustomAttribute(propertyInfo, typeof(global::System.ComponentModel.DisplayNameAttribute)))?.DisplayName + ?? propertyName; + } + """)!); + + members.Add(ParseMemberDeclaration(GetTryValidatePropertyCoreSource(validationInfo, generatedOverrideAttributes))!); + members.Add(ParseMemberDeclaration(GetValidateAllPropertiesCoreSource(validationInfo, generatedOverrideAttributes))!); + + return members.ToImmutable(); + } + + /// + /// Creates the source for the generated TryValidatePropertyCore override. + /// + /// The validation info for the current type. + /// The generated member attributes to apply. + /// The generated member source. + private static string GetTryValidatePropertyCoreSource(ValidationInfo validationInfo, string attributes) + { + StringBuilder builder = new(); + + builder.AppendLine("/// "); + builder.AppendLine(attributes); + builder.AppendLine("protected override ValidationStatus TryValidatePropertyCore(object? value, string propertyName, global::System.Collections.Generic.ICollection errors)"); + builder.AppendLine("{"); + builder.AppendLine(" return propertyName switch"); + builder.AppendLine(" {"); + + foreach ((PropertyValidationInfo propertyInfo, int index) in validationInfo.Properties.Select(static (item, index) => (item, index))) + { + string helperName = GetHelperName(propertyInfo.PropertyName, index); + + builder.AppendLine($" \"{propertyInfo.PropertyName}\" => {helperName}TryValidate(value, errors),"); + } + + builder.AppendLine(" _ => base.TryValidatePropertyCore(value, propertyName, errors)"); + builder.AppendLine(" };"); + builder.AppendLine("}"); + + return builder.ToString(); + } + + /// + /// Creates the source for the generated ValidateAllPropertiesCore override. + /// + /// The validation info for the current type. + /// The generated member attributes to apply. + /// The generated member source. + private static string GetValidateAllPropertiesCoreSource(ValidationInfo validationInfo, string attributes) + { + StringBuilder builder = new(); + + builder.AppendLine("/// "); + builder.AppendLine(attributes); + builder.AppendLine("protected override void ValidateAllPropertiesCore()"); + builder.AppendLine("{"); + + foreach (PropertyValidationInfo propertyInfo in validationInfo.Properties) + { + builder.AppendLine($" ValidateProperty({propertyInfo.PropertyName}, \"{propertyInfo.PropertyName}\");"); + } + + builder.AppendLine(" base.ValidateAllPropertiesCore();"); + builder.AppendLine("}"); + + return builder.ToString(); + } + + /// + /// Creates a stable helper name for a given property. + /// + /// The property name to process. + /// The index of the current property. + /// A stable helper name for . + private static string GetHelperName(string propertyName, int index) + { + StringBuilder builder = new("__"); + + foreach (char c in propertyName) + { + builder.Append(char.IsLetterOrDigit(c) ? c : '_'); + } + + if (builder.Length == 2 || !char.IsLetter(builder[2]) && builder[2] != '_') + { + builder.Insert(2, '_'); + } + + builder.Append('_'); + builder.Append(index.ToString("D2")); + + return builder.ToString(); + } + } +} diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservableValidatorValidationGenerator.cs b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservableValidatorValidationGenerator.cs new file mode 100644 index 000000000..5c7335812 --- /dev/null +++ b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservableValidatorValidationGenerator.cs @@ -0,0 +1,118 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Linq; +using CommunityToolkit.Mvvm.SourceGenerators.ComponentModel.Models; +using CommunityToolkit.Mvvm.SourceGenerators.Extensions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace CommunityToolkit.Mvvm.SourceGenerators; + +/// +/// A source generator for hook-based ObservableValidator validation. +/// +[Generator(LanguageNames.CSharp)] +public sealed partial class ObservableValidatorValidationGenerator : IIncrementalGenerator +{ + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + IncrementalValuesProvider explicitValidationInfo = + context.SyntaxProvider + .CreateSyntaxProvider( + static (node, _) => node is TypeDeclarationSyntax typeDeclaration && typeDeclaration.HasOrPotentiallyHasBaseTypes(), + static (context, token) => + { + if (!context.SemanticModel.Compilation.HasLanguageVersionAtLeastEqualTo(LanguageVersion.CSharp8)) + { + return default; + } + + INamedTypeSymbol typeSymbol = (INamedTypeSymbol)context.SemanticModel.GetDeclaredSymbol(context.Node, token)!; + + if (!context.Node.IsFirstSyntaxDeclarationForSymbol(typeSymbol)) + { + return default; + } + + token.ThrowIfCancellationRequested(); + + if (!Execute.IsObservableValidator(typeSymbol)) + { + return default; + } + + token.ThrowIfCancellationRequested(); + + return Execute.GetInfo(typeSymbol, token); + }) + .Where(static item => item is not null)!; + + IncrementalValuesProvider<(ValidationTypeInfo Left, PropertyValidationInfo Right)> explicitValidationMembers = + explicitValidationInfo.SelectMany(static (item, _) => + item.Properties.Select(property => (new ValidationTypeInfo(item.Hierarchy, item.TypeName), property))); + + IncrementalValuesProvider<(ValidationTypeInfo Left, PropertyValidationInfo Right)> generatedPropertyValidationMembers = + context.ForAttributeWithMetadataNameAndOptions( + "CommunityToolkit.Mvvm.ComponentModel.ObservablePropertyAttribute", + ObservablePropertyGenerator.Execute.IsCandidatePropertyDeclaration, + static (context, token) => + { + MemberDeclarationSyntax memberSyntax = ObservablePropertyGenerator.Execute.GetCandidateMemberDeclaration(context.TargetNode); + + if (!ObservablePropertyGenerator.Execute.IsCandidateValidForCompilation(memberSyntax, context.SemanticModel) || + !ObservablePropertyGenerator.Execute.IsCandidateSymbolValid(context.TargetSymbol) || + context.TargetSymbol is not IFieldSymbol fieldSymbol) + { + return default; + } + + token.ThrowIfCancellationRequested(); + + return Execute.GetGeneratedObservablePropertyValidationInfo(memberSyntax, fieldSymbol, context.SemanticModel, context.GlobalOptions, token); + }) + .Where(static item => item.Left is not null)!; + + IncrementalValuesProvider<(ValidationTypeInfo Left, PropertyValidationInfo Right)> validationMembers = + explicitValidationMembers + .Collect() + .Combine(generatedPropertyValidationMembers.Collect()) + .SelectMany(static (item, _) => item.Left.Concat(item.Right)); + + IncrementalValuesProvider validationInfo = + validationMembers + .GroupBy(static item => item.Left, static item => item.Right) + .Select(static (item, _) => new ValidationInfo(item.Key.Hierarchy, item.Key.TypeName, item.Right)); + + IncrementalValueProvider isHeaderFileNeeded = + validationInfo + .Collect() + .Select(static (item, _) => item.Length > 0); + + IncrementalValueProvider isDynamicallyAccessedMembersAttributeAvailable = + context.CompilationProvider + .Select(static (item, _) => item.HasAccessibleTypeWithMetadataName("System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembersAttribute")); + + IncrementalValueProvider<(bool Condition, bool State)> headerFileInfo = + isHeaderFileNeeded + .Combine(isDynamicallyAccessedMembersAttributeAvailable) + .Select(static (item, _) => (item.Left, item.Right)); + + context.RegisterConditionalImplementationSourceOutput(headerFileInfo, static (context, item) => + { + CompilationUnitSyntax compilationUnit = Execute.GetHeaderSyntax(item); + + context.AddSource("__ObservableValidatorExtensions.g.cs", compilationUnit); + }); + + context.RegisterSourceOutput(validationInfo, static (context, item) => + { + CompilationUnitSyntax compilationUnit = Execute.GetSyntax(item); + + context.AddSource($"{item.Hierarchy.FilenameHint}.ObservableValidator.g.cs", compilationUnit); + }); + } +} diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/Diagnostics/Analyzers/ObservableValidatorValidationGeneratorPartialTypeAnalyzer.cs b/src/CommunityToolkit.Mvvm.SourceGenerators/Diagnostics/Analyzers/ObservableValidatorValidationGeneratorPartialTypeAnalyzer.cs new file mode 100644 index 000000000..3363bb75e --- /dev/null +++ b/src/CommunityToolkit.Mvvm.SourceGenerators/Diagnostics/Analyzers/ObservableValidatorValidationGeneratorPartialTypeAnalyzer.cs @@ -0,0 +1,99 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Linq; +using CommunityToolkit.Mvvm.SourceGenerators.Extensions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; +using static CommunityToolkit.Mvvm.SourceGenerators.Diagnostics.DiagnosticDescriptors; + +namespace CommunityToolkit.Mvvm.SourceGenerators; + +/// +/// A diagnostic analyzer that reports types requiring generated ObservableValidator hooks that are not partial. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ObservableValidatorValidationGeneratorPartialTypeAnalyzer : DiagnosticAnalyzer +{ + /// + public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create(ObservableValidatorTypeMustBePartial); + + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(static context => + { + ConcurrentDictionary<(SyntaxTree SyntaxTree, TextSpan Span), byte> reportedLocations = new(); + + context.RegisterSymbolAction(context => + { + if (context.Symbol is not INamedTypeSymbol typeSymbol || + typeSymbol.TypeKind != TypeKind.Class) + { + return; + } + + if (!typeSymbol.InheritsFromFullyQualifiedMetadataName("CommunityToolkit.Mvvm.ComponentModel.ObservableValidator") || + !HasLocalValidatableProperties(typeSymbol)) + { + return; + } + + foreach (var typeDeclaration in typeSymbol.GetNonPartialTypeDeclarationNodes()) + { + if (reportedLocations.TryAdd((typeDeclaration.SyntaxTree, typeDeclaration.Span), 0)) + { + context.ReportDiagnostic(Diagnostic.Create( + ObservableValidatorTypeMustBePartial, + typeDeclaration.Identifier.GetLocation(), + typeDeclaration.Identifier.ValueText)); + } + } + }, SymbolKind.NamedType); + }); + } + + /// + /// Checks whether a target type has any locally declared validatable members. + /// + /// The target instance to inspect. + /// Whether has locally declared validatable members. + private static bool HasLocalValidatableProperties(INamedTypeSymbol typeSymbol) + { + foreach (ISymbol memberSymbol in typeSymbol.GetMembers()) + { + if (memberSymbol is IPropertySymbol propertySymbol) + { + if (propertySymbol.IsStatic || + propertySymbol.IsIndexer || + !propertySymbol.CanBeReferencedByName) + { + continue; + } + + if (propertySymbol.GetAttributes().Any(static a => a.AttributeClass?.InheritsFromFullyQualifiedMetadataName( + "System.ComponentModel.DataAnnotations.ValidationAttribute") == true)) + { + return true; + } + } + else if (memberSymbol is IFieldSymbol fieldSymbol && + fieldSymbol.GetAttributes().Any(static a => a.AttributeClass?.HasFullyQualifiedMetadataName( + "CommunityToolkit.Mvvm.ComponentModel.ObservablePropertyAttribute") == true) && + fieldSymbol.GetAttributes().Any(static a => a.AttributeClass?.InheritsFromFullyQualifiedMetadataName( + "System.ComponentModel.DataAnnotations.ValidationAttribute") == true)) + { + return true; + } + } + + return false; + } +} diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs b/src/CommunityToolkit.Mvvm.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs index 7c297892d..e0a7d07e1 100644 --- a/src/CommunityToolkit.Mvvm.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs +++ b/src/CommunityToolkit.Mvvm.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs @@ -49,6 +49,11 @@ internal static class DiagnosticDescriptors /// public const string UseObservablePropertyOnSemiAutoPropertyId = "MVVMTK0056"; + /// + /// The diagnostic id for . + /// + public const string ObservableValidatorTypeMustBePartialId = "MVVMTK0057"; + /// /// Gets a indicating when a duplicate declaration of would happen. /// @@ -944,4 +949,20 @@ internal static class DiagnosticDescriptors isEnabledByDefault: true, description: "Semi-auto properties should be converted to partial properties using [ObservableProperty] when possible, which is recommended (doing so makes the code less verbose and results in more optimized code).", helpLinkUri: "https://aka.ms/mvvmtoolkit/errors/mvvmtk0056"); + + /// + /// Gets a indicating when a type requiring generated validation hooks is not partial. + /// + /// Format: "The type {0} must be partial to enable generated validation support for ObservableValidator". + /// + /// + public static readonly DiagnosticDescriptor ObservableValidatorTypeMustBePartial = new DiagnosticDescriptor( + id: ObservableValidatorTypeMustBePartialId, + title: "ObservableValidator type must be partial", + messageFormat: "The type {0} must be partial to enable generated validation support for ObservableValidator", + category: typeof(ObservableValidatorValidationGenerator).FullName, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Types deriving from ObservableValidator and declaring validatable properties must be partial, and all containing types must be partial as well, so the source generators can emit validation hooks.", + helpLinkUri: "https://aka.ms/mvvmtoolkit/errors/mvvmtk0057"); } diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/Extensions/ITypeSymbolExtensions.cs b/src/CommunityToolkit.Mvvm.SourceGenerators/Extensions/ITypeSymbolExtensions.cs index b79f743b3..4fee3743b 100644 --- a/src/CommunityToolkit.Mvvm.SourceGenerators/Extensions/ITypeSymbolExtensions.cs +++ b/src/CommunityToolkit.Mvvm.SourceGenerators/Extensions/ITypeSymbolExtensions.cs @@ -3,9 +3,12 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Immutable; using System.Linq; using CommunityToolkit.Mvvm.SourceGenerators.Helpers; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; namespace CommunityToolkit.Mvvm.SourceGenerators.Extensions; @@ -174,6 +177,40 @@ public static bool HasOrInheritsAttributeWithType(this ITypeSymbol typeSymbol, I return false; } + /// + /// Gets all non-partial declarations for a target type and its containing types. + /// + /// The target instance. + /// The sequence of all non-partial declarations for and its containing types. + public static ImmutableArray GetNonPartialTypeDeclarationNodes(this INamedTypeSymbol typeSymbol) + { + using ImmutableArrayBuilder builder = ImmutableArrayBuilder.Rent(); + + for (INamedTypeSymbol? currentType = typeSymbol; currentType is not null; currentType = currentType.ContainingType) + { + foreach (SyntaxReference syntaxReference in currentType.DeclaringSyntaxReferences) + { + if (syntaxReference.GetSyntax() is TypeDeclarationSyntax typeDeclaration && + !typeDeclaration.Modifiers.Any(SyntaxKind.PartialKeyword)) + { + builder.Add(typeDeclaration); + } + } + } + + return builder.ToImmutable(); + } + + /// + /// Checks whether a target type and all its containing types are partial across all declarations. + /// + /// The target instance. + /// Whether and all its containing types are partial. + public static bool IsTypeHierarchyPartial(this INamedTypeSymbol typeSymbol) + { + return GetNonPartialTypeDeclarationNodes(typeSymbol).IsEmpty; + } + /// /// Checks whether or not a given inherits a specified attribute. /// If the type has no base type, this method will automatically handle that and return . diff --git a/src/CommunityToolkit.Mvvm/ComponentModel/ObservableValidator.cs b/src/CommunityToolkit.Mvvm/ComponentModel/ObservableValidator.cs index 9ceb794e8..6267789c9 100644 --- a/src/CommunityToolkit.Mvvm/ComponentModel/ObservableValidator.cs +++ b/src/CommunityToolkit.Mvvm/ComponentModel/ObservableValidator.cs @@ -476,7 +476,7 @@ protected void ClearErrors(string? propertyName = null) } else { - ClearErrorsForProperty(propertyName); + ClearErrorsForProperty(propertyName!); } } @@ -497,7 +497,7 @@ IEnumerable GetAllErrors() } // Property-level errors, if any - if (this.errors.TryGetValue(propertyName, out List? errors)) + if (this.errors.TryGetValue(propertyName!, out List? errors)) { return errors; } From cc84b7932d160ae0e7c7a5466ef13090dd1db7bb Mon Sep 17 00:00:00 2001 From: OleRoss Date: Sun, 5 Apr 2026 15:08:06 +0200 Subject: [PATCH 3/7] test: Add tests --- ...tyToolkit.Mvvm.Roslyn5000.UnitTests.csproj | 2 +- ...ObservableValidatorTypePartialCodeFixer.cs | 64 +++++++++++++++++++ .../Test_SourceGeneratorsCodegen.cs | 58 +++++++++++++++++ .../Test_SourceGeneratorsDiagnostics.cs | 47 +++++++++++++- 4 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 tests/CommunityToolkit.Mvvm.SourceGenerators.Roslyn4001.UnitTests/Test_MakeObservableValidatorTypePartialCodeFixer.cs diff --git a/tests/CommunityToolkit.Mvvm.Roslyn5000.UnitTests/CommunityToolkit.Mvvm.Roslyn5000.UnitTests.csproj b/tests/CommunityToolkit.Mvvm.Roslyn5000.UnitTests/CommunityToolkit.Mvvm.Roslyn5000.UnitTests.csproj index 07a20826d..94e0272e6 100644 --- a/tests/CommunityToolkit.Mvvm.Roslyn5000.UnitTests/CommunityToolkit.Mvvm.Roslyn5000.UnitTests.csproj +++ b/tests/CommunityToolkit.Mvvm.Roslyn5000.UnitTests/CommunityToolkit.Mvvm.Roslyn5000.UnitTests.csproj @@ -1,7 +1,7 @@  - net472;net8.0;net9.0 + net472;net8.0;net9.0;net10.0 14.0 true $(DefineConstants);ROSLYN_4_12_0_OR_GREATER;ROSLYN_5_0_0_OR_GREATER diff --git a/tests/CommunityToolkit.Mvvm.SourceGenerators.Roslyn4001.UnitTests/Test_MakeObservableValidatorTypePartialCodeFixer.cs b/tests/CommunityToolkit.Mvvm.SourceGenerators.Roslyn4001.UnitTests/Test_MakeObservableValidatorTypePartialCodeFixer.cs new file mode 100644 index 000000000..f3573658b --- /dev/null +++ b/tests/CommunityToolkit.Mvvm.SourceGenerators.Roslyn4001.UnitTests/Test_MakeObservableValidatorTypePartialCodeFixer.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using CSharpCodeFixTest = Microsoft.CodeAnalysis.CSharp.Testing.CSharpCodeFixTest< + CommunityToolkit.Mvvm.SourceGenerators.ObservableValidatorValidationGeneratorPartialTypeAnalyzer, + CommunityToolkit.Mvvm.CodeFixers.MakeObservableValidatorTypePartialCodeFixer, + Microsoft.CodeAnalysis.Testing.DefaultVerifier>; +using CSharpCodeFixVerifier = Microsoft.CodeAnalysis.CSharp.Testing.CSharpCodeFixVerifier< + CommunityToolkit.Mvvm.SourceGenerators.ObservableValidatorValidationGeneratorPartialTypeAnalyzer, + CommunityToolkit.Mvvm.CodeFixers.MakeObservableValidatorTypePartialCodeFixer, + Microsoft.CodeAnalysis.Testing.DefaultVerifier>; + +namespace CommunityToolkit.Mvvm.SourceGenerators.UnitTests; + +[TestClass] +public class MakeObservableValidatorTypePartialCodeFixer +{ + [TestMethod] + public async Task TopLevelType() + { + string original = """ + using System.ComponentModel.DataAnnotations; + using CommunityToolkit.Mvvm.ComponentModel; + + namespace MyApp; + + class SampleViewModel : ObservableValidator + { + [Required] + public string Name { get; set; } + } + """; + + string @fixed = """ + using System.ComponentModel.DataAnnotations; + using CommunityToolkit.Mvvm.ComponentModel; + + namespace MyApp; + + partial class SampleViewModel : ObservableValidator + { + [Required] + public string Name { get; set; } + } + """; + + CSharpCodeFixTest test = new() + { + TestCode = original, + FixedCode = @fixed, + ReferenceAssemblies = ReferenceAssemblies.Net.Net80 + }; + + test.TestState.AdditionalReferences.Add(typeof(ObservableObject).Assembly); + test.ExpectedDiagnostics.Add(CSharpCodeFixVerifier.Diagnostic("MVVMTK0057").WithSpan(6, 7, 6, 22).WithArguments("SampleViewModel")); + + await test.RunAsync(); + } +} diff --git a/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsCodegen.cs b/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsCodegen.cs index d91dfe2b2..394757e62 100644 --- a/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsCodegen.cs +++ b/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsCodegen.cs @@ -3448,6 +3448,64 @@ public object? A VerifyGenerateSources(source, new[] { new ObservablePropertyGenerator() }, ("MyApp.MyViewModel.g.cs", result)); } + [TestMethod] + public void ObservableValidator_GeneratesValidationHooksForObservablePropertyFields() + { + string source = """ + using System.ComponentModel.DataAnnotations; + using CommunityToolkit.Mvvm.ComponentModel; + + #nullable enable + + namespace MyApp; + + partial class MyViewModel : ObservableValidator + { + [ObservableProperty] + [Required] + private string? name; + } + """; + + Type observableObjectType = typeof(ObservableObject); + Type validationAttributeType = typeof(ValidationAttribute); + + IEnumerable references = + from assembly in AppDomain.CurrentDomain.GetAssemblies() + where !assembly.IsDynamic + let reference = MetadataReference.CreateFromFile(assembly.Location) + select reference; + + SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp10)); + + CSharpCompilation compilation = CSharpCompilation.Create( + "original", + new SyntaxTree[] { syntaxTree }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + GeneratorDriver driver = CSharpGeneratorDriver.Create(new IIncrementalGenerator[] + { + new ObservablePropertyGenerator(), + new ObservableValidatorValidationGenerator() + }).WithUpdatedParseOptions((CSharpParseOptions)syntaxTree.Options); + + _ = driver.RunGeneratorsAndUpdateCompilation(compilation, out Compilation outputCompilation, out ImmutableArray diagnostics); + + CollectionAssert.AreEquivalent(Array.Empty(), diagnostics); + + SyntaxTree generatedTree = outputCompilation.SyntaxTrees.Single(tree => Path.GetFileName(tree.FilePath) == "MyApp.MyViewModel.ObservableValidator.g.cs"); + string generatedText = generatedTree.ToString(); + + StringAssert.Contains(generatedText, "protected override void ValidateAllPropertiesCore()"); + StringAssert.Contains(generatedText, "ValidateProperty(Name, \"Name\");"); + StringAssert.Contains(generatedText, "GetProperty(\"Name\")!"); + StringAssert.Contains(generatedText, "protected override ValidationStatus TryValidatePropertyCore(object? value, string propertyName"); + + GC.KeepAlive(observableObjectType); + GC.KeepAlive(validationAttributeType); + } + /// /// Generates the requested sources /// diff --git a/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsDiagnostics.cs b/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsDiagnostics.cs index 69da07125..1b75cc266 100644 --- a/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsDiagnostics.cs +++ b/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsDiagnostics.cs @@ -327,7 +327,7 @@ public partial class {|MVVMTK0008:SampleViewModel|} } [TestMethod] - public async Task UnsupportedCSharpLanguageVersion_FromObservableValidatorValidateAllPropertiesGenerator() + public async Task UnsupportedCSharpLanguageVersion_FromObservableValidatorValidationGenerator() { string source = """ using System.ComponentModel.DataAnnotations; @@ -346,6 +346,49 @@ public partial class SampleViewModel : ObservableValidator await VerifyAnalyzerDiagnosticsAndSuccessfulGeneration(source, LanguageVersion.CSharp7_3); } + [TestMethod] + public async Task ObservableValidatorValidationGeneratorRequiresPartialType() + { + string source = """ + using System.ComponentModel.DataAnnotations; + using CommunityToolkit.Mvvm.ComponentModel; + + namespace MyApp + { + public class {|MVVMTK0057:SampleViewModel|} : ObservableValidator + { + [Required] + public string Name { get; set; } + } + } + """; + + await VerifyAnalyzerDiagnosticsAndSuccessfulGeneration(source, LanguageVersion.CSharp8); + } + + [TestMethod] + public async Task ObservableValidatorValidationGeneratorRequiresContainingTypesToBePartial() + { + string source = """ + using System.ComponentModel.DataAnnotations; + using CommunityToolkit.Mvvm.ComponentModel; + + namespace MyApp + { + public class {|MVVMTK0057:Outer|} + { + public partial class Inner : ObservableValidator + { + [Required] + public string Name { get; set; } + } + } + } + """; + + await VerifyAnalyzerDiagnosticsAndSuccessfulGeneration(source, LanguageVersion.CSharp8); + } + [TestMethod] public async Task UnsupportedCSharpLanguageVersion_FromRelayCommandGenerator() { @@ -2507,7 +2550,7 @@ internal static async Task VerifyAnalyzerDiagnosticsAndSuccessfulGeneration Date: Sun, 5 Apr 2026 15:08:53 +0200 Subject: [PATCH 4/7] test: Add an aot test project --- dotnet.slnx | 2 + nuget.config | 19 +++++++++ ...CommunityToolkit.Mvvm.Aot.UnitTests.csproj | 41 +++++++++++++++++++ .../TestAot.ps1 | 2 + .../Test_ObservableRecipient.cs | 12 ++++++ .../Test_ObservableValidator.cs | 36 +++++++++++----- 6 files changed, 102 insertions(+), 10 deletions(-) create mode 100644 nuget.config create mode 100644 tests/CommunityToolkit.Mvvm.Aot.UnitTests/CommunityToolkit.Mvvm.Aot.UnitTests.csproj create mode 100644 tests/CommunityToolkit.Mvvm.Aot.UnitTests/TestAot.ps1 diff --git a/dotnet.slnx b/dotnet.slnx index 7cf15708b..812c7e3fb 100644 --- a/dotnet.slnx +++ b/dotnet.slnx @@ -7,6 +7,7 @@ + @@ -30,6 +31,7 @@ + diff --git a/nuget.config b/nuget.config new file mode 100644 index 000000000..6e1bf280b --- /dev/null +++ b/nuget.config @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/tests/CommunityToolkit.Mvvm.Aot.UnitTests/CommunityToolkit.Mvvm.Aot.UnitTests.csproj b/tests/CommunityToolkit.Mvvm.Aot.UnitTests/CommunityToolkit.Mvvm.Aot.UnitTests.csproj new file mode 100644 index 000000000..9ddf27e97 --- /dev/null +++ b/tests/CommunityToolkit.Mvvm.Aot.UnitTests/CommunityToolkit.Mvvm.Aot.UnitTests.csproj @@ -0,0 +1,41 @@ + + + + net10.0 + 14.0 + true + $(DefineConstants);ROSLYN_4_12_0_OR_GREATER;ROSLYN_5_0_0_OR_GREATER + + + $(NoWarn);MVVMTK0042 + + true + true + exe + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/CommunityToolkit.Mvvm.Aot.UnitTests/TestAot.ps1 b/tests/CommunityToolkit.Mvvm.Aot.UnitTests/TestAot.ps1 new file mode 100644 index 000000000..6b234e615 --- /dev/null +++ b/tests/CommunityToolkit.Mvvm.Aot.UnitTests/TestAot.ps1 @@ -0,0 +1,2 @@ +dotnet publish ./CommunityToolkit.Mvvm.Aot.UnitTests.csproj -r win-x64 -c Release -f net10.0 +./bin/Release/net10.0/win-x64/publish/CommunityToolkit.Mvvm.Aot.UnitTests.exe diff --git a/tests/CommunityToolkit.Mvvm.UnitTests/Test_ObservableRecipient.cs b/tests/CommunityToolkit.Mvvm.UnitTests/Test_ObservableRecipient.cs index 28135603c..f78564a4a 100644 --- a/tests/CommunityToolkit.Mvvm.UnitTests/Test_ObservableRecipient.cs +++ b/tests/CommunityToolkit.Mvvm.UnitTests/Test_ObservableRecipient.cs @@ -20,6 +20,18 @@ namespace CommunityToolkit.Mvvm.UnitTests; [TestClass] public class Test_ObservableRecipient { + [TestMethod] + public void Test_EnsureConstructorsArePreserved() + { + // DynamicallyAccessedMembers on test methods do not seem to preserve constructors. + // Therefore, this method calls them + IMessenger strongMessenger = Activator.CreateInstance(); + IMessenger weakMessenger = Activator.CreateInstance(); + + Assert.IsNotNull(strongMessenger); + Assert.IsNotNull(weakMessenger); + } + [TestMethod] [DataRow(typeof(StrongReferenceMessenger))] [DataRow(typeof(WeakReferenceMessenger))] diff --git a/tests/CommunityToolkit.Mvvm.UnitTests/Test_ObservableValidator.cs b/tests/CommunityToolkit.Mvvm.UnitTests/Test_ObservableValidator.cs index 559746e2e..23274e3a2 100644 --- a/tests/CommunityToolkit.Mvvm.UnitTests/Test_ObservableValidator.cs +++ b/tests/CommunityToolkit.Mvvm.UnitTests/Test_ObservableValidator.cs @@ -11,13 +11,14 @@ using System.Text.RegularExpressions; using CommunityToolkit.Mvvm.ComponentModel; using Microsoft.VisualStudio.TestTools.UnitTesting; +using static CommunityToolkit.Mvvm.UnitTests.ObservableValidators; #pragma warning disable CS0618 namespace CommunityToolkit.Mvvm.UnitTests; [TestClass] -public class Test_ObservableValidator +public sealed class Test_ObservableValidator { [TestMethod] public void Test_ObservableValidator_HasErrors() @@ -483,6 +484,18 @@ public void Test_ObservableValidator_ValidationWithFormattedDisplayName() Assert.AreEqual($"SECOND: {nameof(ValidationWithDisplayName.AnotherRequiredField)}.", allErrors[1].ErrorMessage); } + [TestMethod] + public void Test_EnsureConstructorsArePreserved() + { + // DynamicallyAccessedMembers on test methods do not seem to preserve constructors. + // Therefore, this method calls them + ObservableValidatorBase baseValidator = Activator.CreateInstance(); + ObservableValidatorDerived derivedValidator = Activator.CreateInstance(); + + Assert.IsNotNull(baseValidator); + Assert.IsNotNull(derivedValidator); + } + // See: https://github.com/CommunityToolkit/WindowsCommunityToolkit/issues/4272 [TestMethod] [DataRow(typeof(ObservableValidatorBase))] @@ -621,8 +634,11 @@ public void Test_ObservableValidator_HasErrors_IncludeNonAutogenerateAttribute() Assert.IsNotNull(displayAttribute); Assert.IsFalse(displayAttribute.AutoGenerateField); } +} - public class Person : ObservableValidator +public static partial class ObservableValidators +{ + public partial class Person : ObservableValidator { private string? name; @@ -655,7 +671,7 @@ public int Age } } - public class PersonWithDeferredValidation : ObservableValidator + public partial class PersonWithDeferredValidation : ObservableValidator { [MinLength(4)] [MaxLength(20)] @@ -678,7 +694,7 @@ public class PersonWithDeferredValidation : ObservableValidator /// Test model for linked properties, to test instance. /// See https://github.com/CommunityToolkit/WindowsCommunityToolkit/issues/3665 for the original request for this feature. /// - public class ComparableModel : ObservableValidator + public partial class ComparableModel : ObservableValidator { private int a; @@ -731,7 +747,7 @@ protected override ValidationResult IsValid(object? value, ValidationContext val /// Test model for custom validation properties. /// See https://github.com/CommunityToolkit/WindowsCommunityToolkit/issues/3729 for the original request for this feature. /// - public class CustomValidationModel : ObservableValidator + public partial class CustomValidationModel : ObservableValidator { public CustomValidationModel(IDictionary items) : base(items) @@ -777,7 +793,7 @@ public bool Validate(string name) /// Test model for custom validation with an injected service. /// See https://github.com/CommunityToolkit/WindowsCommunityToolkit/issues/3750 for the original request for this feature. /// - public class ValidationWithServiceModel : ObservableValidator + public partial class ValidationWithServiceModel : ObservableValidator { private readonly IFancyService service; @@ -813,7 +829,7 @@ public static ValidationResult ValidateName(string name, ValidationContext conte /// Test model for validation with a formatted display name string on each property. /// This is issue #1 from https://github.com/CommunityToolkit/WindowsCommunityToolkit/issues/3763. /// - public class ValidationWithDisplayName : ObservableValidator + public partial class ValidationWithDisplayName : ObservableValidator { public ValidationWithDisplayName() { @@ -874,14 +890,14 @@ public partial class PersonWithPartialDeclaration public int Number { get; set; } } - public abstract class AbstractModelWithValidatableProperty : ObservableValidator + public abstract partial class AbstractModelWithValidatableProperty : ObservableValidator { [Required] [MinLength(2)] public string? Name { get; set; } } - public class DerivedModelWithValidatableProperties : AbstractModelWithValidatableProperty + public partial class DerivedModelWithValidatableProperties : AbstractModelWithValidatableProperty { [Range(10, 1000)] public int Number { get; set; } @@ -892,7 +908,7 @@ public class DerivedModelWithValidatableProperties : AbstractModelWithValidatabl } } - public class GenericPerson : ObservableValidator + public partial class GenericPerson : ObservableValidator { [Required] [MinLength(1)] From 25537064b8b2732cb79cf38624fbdd14b9822b37 Mon Sep 17 00:00:00 2001 From: OleRoss Date: Sun, 5 Apr 2026 16:29:01 +0200 Subject: [PATCH 5/7] test: Bump mstest version to include partial test classes --- .../CommunityToolkit.Mvvm.Aot.UnitTests.csproj | 6 +++--- .../Test_ObservableValidator.cs | 6 +----- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/CommunityToolkit.Mvvm.Aot.UnitTests/CommunityToolkit.Mvvm.Aot.UnitTests.csproj b/tests/CommunityToolkit.Mvvm.Aot.UnitTests/CommunityToolkit.Mvvm.Aot.UnitTests.csproj index 9ddf27e97..27b3b8be3 100644 --- a/tests/CommunityToolkit.Mvvm.Aot.UnitTests/CommunityToolkit.Mvvm.Aot.UnitTests.csproj +++ b/tests/CommunityToolkit.Mvvm.Aot.UnitTests/CommunityToolkit.Mvvm.Aot.UnitTests.csproj @@ -19,9 +19,9 @@ - - - + + + diff --git a/tests/CommunityToolkit.Mvvm.UnitTests/Test_ObservableValidator.cs b/tests/CommunityToolkit.Mvvm.UnitTests/Test_ObservableValidator.cs index 23274e3a2..1a5bdaf59 100644 --- a/tests/CommunityToolkit.Mvvm.UnitTests/Test_ObservableValidator.cs +++ b/tests/CommunityToolkit.Mvvm.UnitTests/Test_ObservableValidator.cs @@ -11,14 +11,13 @@ using System.Text.RegularExpressions; using CommunityToolkit.Mvvm.ComponentModel; using Microsoft.VisualStudio.TestTools.UnitTesting; -using static CommunityToolkit.Mvvm.UnitTests.ObservableValidators; #pragma warning disable CS0618 namespace CommunityToolkit.Mvvm.UnitTests; [TestClass] -public sealed class Test_ObservableValidator +public sealed partial class Test_ObservableValidator { [TestMethod] public void Test_ObservableValidator_HasErrors() @@ -634,10 +633,7 @@ public void Test_ObservableValidator_HasErrors_IncludeNonAutogenerateAttribute() Assert.IsNotNull(displayAttribute); Assert.IsFalse(displayAttribute.AutoGenerateField); } -} -public static partial class ObservableValidators -{ public partial class Person : ObservableValidator { private string? name; From c23b13f4695d68f145f90db28336667d8293271d Mon Sep 17 00:00:00 2001 From: OleRoss Date: Sun, 5 Apr 2026 18:39:24 +0200 Subject: [PATCH 6/7] refactor: Simplify generator output and add tests for Display attribute --- .../Models/PropertyValidationInfo.cs | 3 +- ...bleValidatorValidationGenerator.Execute.cs | 169 ++++++++++-------- .../Test_SourceGeneratorsCodegen.cs | 86 ++++++++- .../Test_ObservableValidator.cs | 36 ++++ 4 files changed, 212 insertions(+), 82 deletions(-) diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/PropertyValidationInfo.cs b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/PropertyValidationInfo.cs index 4b1b51a52..8238958e7 100644 --- a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/PropertyValidationInfo.cs +++ b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/PropertyValidationInfo.cs @@ -8,4 +8,5 @@ namespace CommunityToolkit.Mvvm.SourceGenerators.ComponentModel.Models; /// A model with gathered info on a locally declared validatable property. /// /// The name of the property to validate. -internal sealed record PropertyValidationInfo(string PropertyName); +/// Whether the property has a DisplayAttribute. +internal sealed record PropertyValidationInfo(string PropertyName, bool HasDisplayAttribute); diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservableValidatorValidationGenerator.Execute.cs b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservableValidatorValidationGenerator.Execute.cs index c755d7f28..8596dbbec 100644 --- a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservableValidatorValidationGenerator.Execute.cs +++ b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservableValidatorValidationGenerator.Execute.cs @@ -70,7 +70,7 @@ public static bool IsObservableValidator(INamedTypeSymbol typeSymbol) continue; } - properties.Add(new(propertySymbol.Name)); + properties.Add(new(propertySymbol.Name, HasDisplayAttribute(propertySymbol.GetAttributes()))); } token.ThrowIfCancellationRequested(); @@ -112,7 +112,7 @@ public static (ValidationTypeInfo Left, PropertyValidationInfo Right) GetGenerat return ( new ValidationTypeInfo(HierarchyInfo.From(fieldSymbol.ContainingType), fieldSymbol.ContainingType.GetFullyQualifiedName()), - new PropertyValidationInfo(propertyInfo.PropertyName)); + new PropertyValidationInfo(propertyInfo.PropertyName, HasDisplayAttribute(propertyInfo.ForwardedAttributes))); } /// @@ -124,7 +124,12 @@ public static CompilationUnitSyntax GetSyntax(ValidationInfo validationInfo) { ImmutableArray memberDeclarations = GetMemberDeclarations(validationInfo); - return validationInfo.Hierarchy.GetCompilationUnit(memberDeclarations); + return validationInfo.Hierarchy.GetCompilationUnit(memberDeclarations) + .AddUsings( + UsingDirective(ParseName("System.Collections.Generic")), + UsingDirective(ParseName("System.ComponentModel.DataAnnotations")), + UsingDirective(ParseName("System.Reflection"))) + .NormalizeWhitespace(); } /// @@ -188,74 +193,28 @@ private static ImmutableArray GetMemberDeclarations(Val { using ImmutableArrayBuilder members = ImmutableArrayBuilder.Rent(); - string generatorTypeName = typeof(ObservableValidatorValidationGenerator).FullName!; - string generatorAssemblyVersion = typeof(ObservableValidatorValidationGenerator).Assembly.GetName().Version!.ToString(); - - string generatedOverrideAttributes = $$""" - [global::System.CodeDom.Compiler.GeneratedCode("{{generatorTypeName}}", "{{generatorAssemblyVersion}}")] - [global::System.Diagnostics.DebuggerNonUserCode] - [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - """; - - string generatedMethodAttributes = $$""" - [global::System.CodeDom.Compiler.GeneratedCode("{{generatorTypeName}}", "{{generatorAssemblyVersion}}")] - [global::System.Diagnostics.DebuggerNonUserCode] - """; - - string generatedFieldAttributes = $$""" - [global::System.CodeDom.Compiler.GeneratedCode("{{generatorTypeName}}", "{{generatorAssemblyVersion}}")] - """; - - foreach ((PropertyValidationInfo propertyInfo, int index) in validationInfo.Properties.Select(static (item, index) => (item, index))) + foreach (PropertyValidationInfo propertyInfo in validationInfo.Properties) { - string helperName = GetHelperName(propertyInfo.PropertyName, index); - - members.Add(ParseMemberDeclaration($$""" - {{generatedFieldAttributes}} - private static readonly global::System.Reflection.PropertyInfo {{helperName}}PropertyInfo = typeof({{validationInfo.TypeName}}).GetProperty("{{propertyInfo.PropertyName}}")!; - """)!); + string validationHelperName = GetValidationAttributesHelperName(propertyInfo.PropertyName); members.Add(ParseMemberDeclaration($$""" - {{generatedFieldAttributes}} - private static readonly string {{helperName}}DisplayName = __GetDisplayName({{helperName}}PropertyInfo, "{{propertyInfo.PropertyName}}"); + private static readonly IEnumerable {{validationHelperName}} = + typeof({{validationInfo.TypeName}}).GetProperty(nameof({{propertyInfo.PropertyName}}))!.GetCustomAttributes(); """)!); - members.Add(ParseMemberDeclaration($$""" - {{generatedFieldAttributes}} - private static readonly global::System.ComponentModel.DataAnnotations.ValidationAttribute[] {{helperName}}ValidationAttributes = __GetValidationAttributes({{helperName}}PropertyInfo); - """)!); - - members.Add(ParseMemberDeclaration($$""" - {{generatedMethodAttributes}} - private ValidationStatus {{helperName}}TryValidate(object? value, global::System.Collections.Generic.ICollection errors) - { - return TryValidateValue(value, "{{propertyInfo.PropertyName}}", {{helperName}}DisplayName, {{helperName}}ValidationAttributes, errors); - } - """)!); - } - - members.Add(ParseMemberDeclaration($$""" - {{generatedMethodAttributes}} - private static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] __GetValidationAttributes(global::System.Reflection.PropertyInfo propertyInfo) + if (propertyInfo.HasDisplayAttribute) { - return global::System.Array.ConvertAll( - propertyInfo.GetCustomAttributes(typeof(global::System.ComponentModel.DataAnnotations.ValidationAttribute), true), - static item => (global::System.ComponentModel.DataAnnotations.ValidationAttribute)item); - } - """)!); + string displayHelperName = GetDisplayAttributeHelperName(propertyInfo.PropertyName); - members.Add(ParseMemberDeclaration($$""" - {{generatedMethodAttributes}} - private static string __GetDisplayName(global::System.Reflection.PropertyInfo propertyInfo, string propertyName) - { - return ((global::System.ComponentModel.DataAnnotations.DisplayAttribute?)global::System.Attribute.GetCustomAttribute(propertyInfo, typeof(global::System.ComponentModel.DataAnnotations.DisplayAttribute)))?.GetName() - ?? ((global::System.ComponentModel.DisplayNameAttribute?)global::System.Attribute.GetCustomAttribute(propertyInfo, typeof(global::System.ComponentModel.DisplayNameAttribute)))?.DisplayName - ?? propertyName; + members.Add(ParseMemberDeclaration($$""" + private static readonly DisplayAttribute {{displayHelperName}} = + typeof({{validationInfo.TypeName}}).GetProperty(nameof({{propertyInfo.PropertyName}}))!.GetCustomAttribute()!; + """)!); } - """)!); + } - members.Add(ParseMemberDeclaration(GetTryValidatePropertyCoreSource(validationInfo, generatedOverrideAttributes))!); - members.Add(ParseMemberDeclaration(GetValidateAllPropertiesCoreSource(validationInfo, generatedOverrideAttributes))!); + members.Add(ParseMemberDeclaration(GetTryValidatePropertyCoreSource(validationInfo))!); + members.Add(ParseMemberDeclaration(GetValidateAllPropertiesCoreSource(validationInfo))!); return members.ToImmutable(); } @@ -264,24 +223,25 @@ private static string __GetDisplayName(global::System.Reflection.PropertyInfo pr /// Creates the source for the generated TryValidatePropertyCore override. /// /// The validation info for the current type. - /// The generated member attributes to apply. /// The generated member source. - private static string GetTryValidatePropertyCoreSource(ValidationInfo validationInfo, string attributes) + private static string GetTryValidatePropertyCoreSource(ValidationInfo validationInfo) { StringBuilder builder = new(); builder.AppendLine("/// "); - builder.AppendLine(attributes); - builder.AppendLine("protected override ValidationStatus TryValidatePropertyCore(object? value, string propertyName, global::System.Collections.Generic.ICollection errors)"); + builder.AppendLine("protected override ValidationStatus TryValidatePropertyCore(object? value, string propertyName, ICollection errors)"); builder.AppendLine("{"); - builder.AppendLine(" return propertyName switch"); + builder.AppendLine(" return (propertyName) switch"); builder.AppendLine(" {"); - foreach ((PropertyValidationInfo propertyInfo, int index) in validationInfo.Properties.Select(static (item, index) => (item, index))) + foreach (PropertyValidationInfo propertyInfo in validationInfo.Properties) { - string helperName = GetHelperName(propertyInfo.PropertyName, index); + string validationHelperName = GetValidationAttributesHelperName(propertyInfo.PropertyName); + string displayNameExpression = propertyInfo.HasDisplayAttribute + ? $"{GetDisplayAttributeHelperName(propertyInfo.PropertyName)}.GetName() ?? {SymbolDisplay.FormatLiteral(propertyInfo.PropertyName, true)}" + : SymbolDisplay.FormatLiteral(propertyInfo.PropertyName, true); - builder.AppendLine($" \"{propertyInfo.PropertyName}\" => {helperName}TryValidate(value, errors),"); + builder.AppendLine($" nameof({propertyInfo.PropertyName}) => TryValidateValue(value, nameof({propertyInfo.PropertyName}), {displayNameExpression}, {validationHelperName}, errors),"); } builder.AppendLine(" _ => base.TryValidatePropertyCore(value, propertyName, errors)"); @@ -295,20 +255,18 @@ private static string GetTryValidatePropertyCoreSource(ValidationInfo validation /// Creates the source for the generated ValidateAllPropertiesCore override. /// /// The validation info for the current type. - /// The generated member attributes to apply. /// The generated member source. - private static string GetValidateAllPropertiesCoreSource(ValidationInfo validationInfo, string attributes) + private static string GetValidateAllPropertiesCoreSource(ValidationInfo validationInfo) { StringBuilder builder = new(); builder.AppendLine("/// "); - builder.AppendLine(attributes); builder.AppendLine("protected override void ValidateAllPropertiesCore()"); builder.AppendLine("{"); foreach (PropertyValidationInfo propertyInfo in validationInfo.Properties) { - builder.AppendLine($" ValidateProperty({propertyInfo.PropertyName}, \"{propertyInfo.PropertyName}\");"); + builder.AppendLine($" ValidateProperty({propertyInfo.PropertyName}, nameof({propertyInfo.PropertyName}));"); } builder.AppendLine(" base.ValidateAllPropertiesCore();"); @@ -321,9 +279,29 @@ private static string GetValidateAllPropertiesCoreSource(ValidationInfo validati /// Creates a stable helper name for a given property. /// /// The property name to process. - /// The index of the current property. /// A stable helper name for . - private static string GetHelperName(string propertyName, int index) + private static string GetValidationAttributesHelperName(string propertyName) + { + return GetHelperName(propertyName, "ValidationAttributes"); + } + + /// + /// Creates a stable display helper name for a given property. + /// + /// The property name to process. + /// A stable display helper name for . + private static string GetDisplayAttributeHelperName(string propertyName) + { + return GetHelperName(propertyName, "DisplayAttribute"); + } + + /// + /// Creates a stable helper name for a given property and suffix. + /// + /// The property name to process. + /// The suffix to append. + /// A stable helper name for . + private static string GetHelperName(string propertyName, string suffix) { StringBuilder builder = new("__"); @@ -337,10 +315,45 @@ private static string GetHelperName(string propertyName, int index) builder.Insert(2, '_'); } - builder.Append('_'); - builder.Append(index.ToString("D2")); + builder.Append(suffix); return builder.ToString(); } + + /// + /// Checks whether a property has a DisplayAttribute declared. + /// + /// The attributes declared on the property. + /// Whether the property has a display attribute. + private static bool HasDisplayAttribute(ImmutableArray attributes) + { + foreach (AttributeData attributeData in attributes) + { + if (attributeData.AttributeClass?.HasFullyQualifiedMetadataName("System.ComponentModel.DataAnnotations.DisplayAttribute") == true) + { + return true; + } + } + + return false; + } + + /// + /// Checks whether a generated property has a forwarded DisplayAttribute. + /// + /// The forwarded property attributes. + /// Whether the property has a display attribute. + private static bool HasDisplayAttribute(EquatableArray attributes) + { + foreach (AttributeInfo attributeInfo in attributes) + { + if (attributeInfo.TypeName == "global::System.ComponentModel.DataAnnotations.DisplayAttribute") + { + return true; + } + } + + return false; + } } } diff --git a/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsCodegen.cs b/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsCodegen.cs index 394757e62..a82b975a4 100644 --- a/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsCodegen.cs +++ b/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsCodegen.cs @@ -3497,10 +3497,90 @@ from assembly in AppDomain.CurrentDomain.GetAssemblies() SyntaxTree generatedTree = outputCompilation.SyntaxTrees.Single(tree => Path.GetFileName(tree.FilePath) == "MyApp.MyViewModel.ObservableValidator.g.cs"); string generatedText = generatedTree.ToString(); + StringAssert.Contains(generatedText, "using System.Collections.Generic;"); + StringAssert.Contains(generatedText, "using System.ComponentModel.DataAnnotations;"); + StringAssert.Contains(generatedText, "using System.Reflection;"); + StringAssert.Contains(generatedText, "private static readonly IEnumerable __NameValidationAttributes ="); + StringAssert.Contains(generatedText, "typeof(global::MyApp.MyViewModel).GetProperty(nameof(Name))!.GetCustomAttributes();"); StringAssert.Contains(generatedText, "protected override void ValidateAllPropertiesCore()"); - StringAssert.Contains(generatedText, "ValidateProperty(Name, \"Name\");"); - StringAssert.Contains(generatedText, "GetProperty(\"Name\")!"); - StringAssert.Contains(generatedText, "protected override ValidationStatus TryValidatePropertyCore(object? value, string propertyName"); + StringAssert.Contains(generatedText, "ValidateProperty(Name, nameof(Name));"); + StringAssert.Contains(generatedText, "protected override ValidationStatus TryValidatePropertyCore(object? value, string propertyName, ICollection errors)"); + StringAssert.Contains(generatedText, "nameof(Name) => TryValidateValue(value, nameof(Name), \"Name\", __NameValidationAttributes, errors),"); + Assert.DoesNotContain(generatedText, "GetProperty(\"Name\")!"); + Assert.DoesNotContain(generatedText, "PropertyInfo __"); + Assert.DoesNotContain(generatedText, "__GetDisplayName"); + Assert.DoesNotContain(generatedText, "__GetValidationAttributes"); + + GC.KeepAlive(observableObjectType); + GC.KeepAlive(validationAttributeType); + } + + [TestMethod] + public void ObservableValidator_UsesDeclaredDisplayNamesInValidationHooks() + { + string source = """ + using System.ComponentModel.DataAnnotations; + using CommunityToolkit.Mvvm.ComponentModel; + + #nullable enable + + namespace MyApp; + + partial class MyViewModel : ObservableValidator + { + private string? userName; + + [Required] + [Display(Name = "User Name")] + public string? UserName + { + get => this.userName; + set => SetProperty(ref this.userName, value, validate: true); + } + + [ObservableProperty] + [Required] + [Display(Name = "Email Address")] + private string? email; + } + """; + + Type observableObjectType = typeof(ObservableObject); + Type validationAttributeType = typeof(ValidationAttribute); + + IEnumerable references = + from assembly in AppDomain.CurrentDomain.GetAssemblies() + where !assembly.IsDynamic + let reference = MetadataReference.CreateFromFile(assembly.Location) + select reference; + + SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp10)); + + CSharpCompilation compilation = CSharpCompilation.Create( + "original", + new SyntaxTree[] { syntaxTree }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + GeneratorDriver driver = CSharpGeneratorDriver.Create(new IIncrementalGenerator[] + { + new ObservablePropertyGenerator(), + new ObservableValidatorValidationGenerator() + }).WithUpdatedParseOptions((CSharpParseOptions)syntaxTree.Options); + + _ = driver.RunGeneratorsAndUpdateCompilation(compilation, out Compilation outputCompilation, out ImmutableArray diagnostics); + + CollectionAssert.AreEquivalent(Array.Empty(), diagnostics); + + SyntaxTree generatedTree = outputCompilation.SyntaxTrees.Single(tree => Path.GetFileName(tree.FilePath) == "MyApp.MyViewModel.ObservableValidator.g.cs"); + string generatedText = generatedTree.ToString(); + + StringAssert.Contains(generatedText, "private static readonly DisplayAttribute __UserNameDisplayAttribute ="); + StringAssert.Contains(generatedText, "typeof(global::MyApp.MyViewModel).GetProperty(nameof(UserName))!.GetCustomAttribute();"); + StringAssert.Contains(generatedText, "private static readonly DisplayAttribute __EmailDisplayAttribute ="); + StringAssert.Contains(generatedText, "typeof(global::MyApp.MyViewModel).GetProperty(nameof(Email))!.GetCustomAttribute();"); + StringAssert.Contains(generatedText, "nameof(UserName) => TryValidateValue(value, nameof(UserName), __UserNameDisplayAttribute.GetName() ?? \"UserName\", __UserNameValidationAttributes, errors),"); + StringAssert.Contains(generatedText, "nameof(Email) => TryValidateValue(value, nameof(Email), __EmailDisplayAttribute.GetName() ?? \"Email\", __EmailValidationAttributes, errors),"); GC.KeepAlive(observableObjectType); GC.KeepAlive(validationAttributeType); diff --git a/tests/CommunityToolkit.Mvvm.UnitTests/Test_ObservableValidator.cs b/tests/CommunityToolkit.Mvvm.UnitTests/Test_ObservableValidator.cs index 1a5bdaf59..c5cb29766 100644 --- a/tests/CommunityToolkit.Mvvm.UnitTests/Test_ObservableValidator.cs +++ b/tests/CommunityToolkit.Mvvm.UnitTests/Test_ObservableValidator.cs @@ -483,6 +483,19 @@ public void Test_ObservableValidator_ValidationWithFormattedDisplayName() Assert.AreEqual($"SECOND: {nameof(ValidationWithDisplayName.AnotherRequiredField)}.", allErrors[1].ErrorMessage); } + [TestMethod] + public void Test_ObservableValidator_ValidationWithLocalizedDisplayName() + { + ValidationWithLocalizedDisplayName? model = new(); + + Assert.IsTrue(model.HasErrors); + + ValidationResult error = model.GetErrors(nameof(ValidationWithLocalizedDisplayName.UserName)).Cast().Single(); + + Assert.AreEqual(nameof(ValidationWithLocalizedDisplayName.UserName), error.MemberNames.Single()); + Assert.AreEqual("LOCALIZED: Localized User Name.", error.ErrorMessage); + } + [TestMethod] public void Test_EnsureConstructorsArePreserved() { @@ -851,6 +864,29 @@ public string? AnotherRequiredField } } + public partial class ValidationWithLocalizedDisplayName : ObservableValidator + { + public ValidationWithLocalizedDisplayName() + { + ValidateAllProperties(); + } + + private string? userName; + + [Required(AllowEmptyStrings = false, ErrorMessage = "LOCALIZED: {0}.")] + [Display(Name = nameof(ValidationDisplayNameResources.LocalizedUserName), ResourceType = typeof(ValidationDisplayNameResources))] + public string? UserName + { + get => this.userName; + set => SetProperty(ref this.userName, value, true); + } + } + + public static class ValidationDisplayNameResources + { + public static string LocalizedUserName => "Localized User Name"; + } + public class ObservableValidatorBase : ObservableValidator { public int? MyDummyInt { get; set; } = 0; From 935d66e6233e52e37bff637a7ccd49fb11e5f15d Mon Sep 17 00:00:00 2001 From: OleRoss Date: Sun, 5 Apr 2026 23:01:17 +0200 Subject: [PATCH 7/7] fix: Remove the unreferenced attribute from generated set accessors --- .../ComponentModel/Models/PropertyInfo.cs | 2 - .../ObservablePropertyGenerator.Execute.cs | 21 ------ .../ComponentModel/ObservableValidator.cs | 2 +- .../Test_SourceGeneratorsCodegen.cs | 60 ---------------- .../Test_SourceGeneratorsCodegen.cs | 68 +------------------ 5 files changed, 4 insertions(+), 149 deletions(-) diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/PropertyInfo.cs b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/PropertyInfo.cs index 3ee8349ec..07a05384b 100644 --- a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/PropertyInfo.cs +++ b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/PropertyInfo.cs @@ -28,7 +28,6 @@ namespace CommunityToolkit.Mvvm.SourceGenerators.ComponentModel.Models; /// Whether the old property value is being directly referenced. /// Indicates whether the property is of a reference type or an unconstrained type parameter. /// Indicates whether to include nullability annotations on the setter. -/// Indicates whether to annotate the setter as requiring unreferenced code. /// The sequence of forwarded attributes for the generated property. internal sealed record PropertyInfo( SyntaxKind AnnotatedMemberKind, @@ -48,5 +47,4 @@ internal sealed record PropertyInfo( bool IsOldPropertyValueDirectlyReferenced, bool IsReferenceTypeOrUnconstrainedTypeParameter, bool IncludeMemberNotNullOnSetAccessor, - bool IncludeRequiresUnreferencedCodeOnSetAccessor, EquatableArray ForwardedAttributes); diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservablePropertyGenerator.Execute.cs b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservablePropertyGenerator.Execute.cs index 0dc69ca27..75deb4ffb 100644 --- a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservablePropertyGenerator.Execute.cs +++ b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservablePropertyGenerator.Execute.cs @@ -360,13 +360,6 @@ public static bool TryGetInfo( token.ThrowIfCancellationRequested(); - // We should generate [RequiresUnreferencedCode] on the setter if [NotifyDataErrorInfo] was used and the attribute is available - bool includeRequiresUnreferencedCodeOnSetAccessor = - notifyDataErrorInfo && - semanticModel.Compilation.HasAccessibleTypeWithMetadataName("System.Diagnostics.CodeAnalysis.RequiresUnreferencedCodeAttribute"); - - token.ThrowIfCancellationRequested(); - // Prepare the effective property changing/changed names. For the property changing names, // there are two possible cases: if the mode is disabled, then there are no names to report // at all. If the mode is enabled, then the list is just the same as for property changed. @@ -416,7 +409,6 @@ public static bool TryGetInfo( isOldPropertyValueDirectlyReferenced, isReferenceTypeOrUnconstrainedTypeParameter, includeMemberNotNullOnSetAccessor, - includeRequiresUnreferencedCodeOnSetAccessor, forwardedAttributes.ToImmutable()); diagnostics = builder.ToImmutable(); @@ -1386,19 +1378,6 @@ public static MemberDeclarationSyntax GetPropertySyntax(PropertyInfo propertyInf AttributeArgument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(propertyInfo.FieldName))))))); } - // Add the [RequiresUnreferencedCode] attribute if needed: - // - // [RequiresUnreferencedCode("The type of the current instance cannot be statically discovered.")] - // - if (propertyInfo.IncludeRequiresUnreferencedCodeOnSetAccessor) - { - setAccessor = setAccessor.AddAttributeLists( - AttributeList(SingletonSeparatedList( - Attribute(IdentifierName("global::System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode")) - .AddArgumentListArguments( - AttributeArgument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal("The type of the current instance cannot be statically discovered."))))))); - } - // Also add any forwarded attributes setAccessor = setAccessor.AddAttributeLists(forwardedSetAccessorAttributes); diff --git a/src/CommunityToolkit.Mvvm/ComponentModel/ObservableValidator.cs b/src/CommunityToolkit.Mvvm/ComponentModel/ObservableValidator.cs index 6267789c9..cc071bcaf 100644 --- a/src/CommunityToolkit.Mvvm/ComponentModel/ObservableValidator.cs +++ b/src/CommunityToolkit.Mvvm/ComponentModel/ObservableValidator.cs @@ -577,7 +577,7 @@ protected ValidationStatus TryValidateValue(object? value, string propertyName, ValidationContext updatedContext = GetOrCreateUpdatedValidationContext(propertyName, displayName); - return Validator.TryValidateValue(value, updatedContext, errors, validationAttributes) + return Validator.TryValidateValue(value!, updatedContext, errors, validationAttributes) ? ValidationStatus.Success : ValidationStatus.Error; } diff --git a/tests/CommunityToolkit.Mvvm.SourceGenerators.Roslyn4120.UnitTests/Test_SourceGeneratorsCodegen.cs b/tests/CommunityToolkit.Mvvm.SourceGenerators.Roslyn4120.UnitTests/Test_SourceGeneratorsCodegen.cs index 314f49e8c..a32224693 100644 --- a/tests/CommunityToolkit.Mvvm.SourceGenerators.Roslyn4120.UnitTests/Test_SourceGeneratorsCodegen.cs +++ b/tests/CommunityToolkit.Mvvm.SourceGenerators.Roslyn4120.UnitTests/Test_SourceGeneratorsCodegen.cs @@ -1107,7 +1107,6 @@ partial class MyViewModel : ObservableValidator } """; -#if NET6_0_OR_GREATER string result = """ // #pragma warning disable @@ -1123,7 +1122,6 @@ partial class MyViewModel public partial string Name { get => field; - [global::System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("The type of the current instance cannot be statically discovered.")] set { if (!global::System.Collections.Generic.EqualityComparer.Default.Equals(field, value)) @@ -1165,64 +1163,6 @@ public partial string Name } } """; -#else - string result = """ - // - #pragma warning disable - #nullable enable - namespace MyApp - { - /// - partial class MyViewModel - { - /// - [global::System.CodeDom.Compiler.GeneratedCode("CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator", )] - [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - public partial string Name - { - get => field; - set - { - if (!global::System.Collections.Generic.EqualityComparer.Default.Equals(field, value)) - { - OnNameChanging(value); - OnNameChanging(default, value); - OnPropertyChanging(global::CommunityToolkit.Mvvm.ComponentModel.__Internals.__KnownINotifyPropertyChangingArgs.Name); - field = value; - ValidateProperty(value, "Name"); - OnNameChanged(value); - OnNameChanged(default, value); - OnPropertyChanged(global::CommunityToolkit.Mvvm.ComponentModel.__Internals.__KnownINotifyPropertyChangedArgs.Name); - } - } - } - - /// Executes the logic for when is changing. - /// The new property value being set. - /// This method is invoked right before the value of is changed. - [global::System.CodeDom.Compiler.GeneratedCode("CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator", )] - partial void OnNameChanging(string value); - /// Executes the logic for when is changing. - /// The previous property value that is being replaced. - /// The new property value being set. - /// This method is invoked right before the value of is changed. - [global::System.CodeDom.Compiler.GeneratedCode("CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator", )] - partial void OnNameChanging(string oldValue, string newValue); - /// Executes the logic for when just changed. - /// The new property value that was set. - /// This method is invoked right after the value of is changed. - [global::System.CodeDom.Compiler.GeneratedCode("CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator", )] - partial void OnNameChanged(string value); - /// Executes the logic for when just changed. - /// The previous property value that was replaced. - /// The new property value that was set. - /// This method is invoked right after the value of is changed. - [global::System.CodeDom.Compiler.GeneratedCode("CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator", )] - partial void OnNameChanged(string oldValue, string newValue); - } - } - """; -#endif VerifyGenerateSources(source, new[] { new ObservablePropertyGenerator() }, LanguageVersion.Preview, ("MyApp.MyViewModel.g.cs", result)); } diff --git a/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsCodegen.cs b/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsCodegen.cs index a82b975a4..6ef52be80 100644 --- a/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsCodegen.cs +++ b/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsCodegen.cs @@ -2788,7 +2788,7 @@ internal static class __KnownINotifyPropertyChangedArgs } [TestMethod] - public void ObservableProperty_NotifyDataErrorInfo_EmitsTrimAnnotationsWhenNeeded() + public void ObservableProperty_NotifyDataErrorInfo_WorksCorrectly() { string source = """ using System.ComponentModel.DataAnnotations; @@ -2807,66 +2807,6 @@ partial class MyViewModel : ObservableValidator } """; -#if NET6_0_OR_GREATER - string result = """ - // - #pragma warning disable - #nullable enable - namespace MyApp - { - /// - partial class MyViewModel - { - /// - [global::System.CodeDom.Compiler.GeneratedCode("CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator", )] - [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - [global::System.ComponentModel.DataAnnotations.RequiredAttribute()] - public string? Name - { - get => name; - [global::System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("The type of the current instance cannot be statically discovered.")] - set - { - if (!global::System.Collections.Generic.EqualityComparer.Default.Equals(name, value)) - { - OnNameChanging(value); - OnNameChanging(default, value); - OnPropertyChanging(global::CommunityToolkit.Mvvm.ComponentModel.__Internals.__KnownINotifyPropertyChangingArgs.Name); - name = value; - ValidateProperty(value, "Name"); - OnNameChanged(value); - OnNameChanged(default, value); - OnPropertyChanged(global::CommunityToolkit.Mvvm.ComponentModel.__Internals.__KnownINotifyPropertyChangedArgs.Name); - } - } - } - - /// Executes the logic for when is changing. - /// The new property value being set. - /// This method is invoked right before the value of is changed. - [global::System.CodeDom.Compiler.GeneratedCode("CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator", )] - partial void OnNameChanging(string? value); - /// Executes the logic for when is changing. - /// The previous property value that is being replaced. - /// The new property value being set. - /// This method is invoked right before the value of is changed. - [global::System.CodeDom.Compiler.GeneratedCode("CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator", )] - partial void OnNameChanging(string? oldValue, string? newValue); - /// Executes the logic for when just changed. - /// The new property value that was set. - /// This method is invoked right after the value of is changed. - [global::System.CodeDom.Compiler.GeneratedCode("CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator", )] - partial void OnNameChanged(string? value); - /// Executes the logic for when just changed. - /// The previous property value that was replaced. - /// The new property value that was set. - /// This method is invoked right after the value of is changed. - [global::System.CodeDom.Compiler.GeneratedCode("CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator", )] - partial void OnNameChanged(string? oldValue, string? newValue); - } - } - """; -#else string result = """ // #pragma warning disable @@ -2924,13 +2864,12 @@ public string? Name } } """; -#endif VerifyGenerateSources(source, new[] { new ObservablePropertyGenerator() }, ("MyApp.MyViewModel.g.cs", result)); } [TestMethod] - public void ObservableProperty_NotifyDataErrorInfo_EmitsTrimAnnotationsWhenNeeded_AppendToNullableAttribute() + public void ObservableProperty_NotifyDataErrorInfo_AppendToNullableAttribute_WorksCorrectly() { string source = """ using System.ComponentModel.DataAnnotations; @@ -2967,7 +2906,6 @@ public string Name { get => name; [global::System.Diagnostics.CodeAnalysis.MemberNotNull("name")] - [global::System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("The type of the current instance cannot be statically discovered.")] set { if (!global::System.Collections.Generic.EqualityComparer.Default.Equals(name, value)) @@ -3576,7 +3514,7 @@ from assembly in AppDomain.CurrentDomain.GetAssemblies() string generatedText = generatedTree.ToString(); StringAssert.Contains(generatedText, "private static readonly DisplayAttribute __UserNameDisplayAttribute ="); - StringAssert.Contains(generatedText, "typeof(global::MyApp.MyViewModel).GetProperty(nameof(UserName))!.GetCustomAttribute();"); + StringAssert.Contains(generatedText, "typeof(global::MyApp.MyViewModel).GetProperty(nameof(UserName))!.GetCustomAttribute()!;"); StringAssert.Contains(generatedText, "private static readonly DisplayAttribute __EmailDisplayAttribute ="); StringAssert.Contains(generatedText, "typeof(global::MyApp.MyViewModel).GetProperty(nameof(Email))!.GetCustomAttribute();"); StringAssert.Contains(generatedText, "nameof(UserName) => TryValidateValue(value, nameof(UserName), __UserNameDisplayAttribute.GetName() ?? \"UserName\", __UserNameValidationAttributes, errors),");