// 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; using System.Collections.Generic; using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Contracts; 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. /// public abstract class ObservableValidator : ObservableObject, INotifyDataErrorInfo { /// /// The instance used to track compiled delegates to validate entities. /// private static readonly ConditionalWeakTable> EntityValidatorMap = new(); /// /// The instance used to track display names for properties to validate. /// /// /// This is necessary because we want to reuse the same instance for all validations, but /// with the same behavior with repsect 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(); /// /// The cached for . /// private static readonly PropertyChangedEventArgs HasErrorsChangedEventArgs = new(nameof(HasErrors)); /// /// The instance currenty in use. /// private readonly ValidationContext validationContext; /// /// The instance used to store previous validation results. /// private readonly Dictionary> errors = new(); /// /// Indicates the total number of properties with errors (not total errors). /// This is used to allow to operate in O(1) time, as it can just /// check whether this value is not 0 instead of having to traverse . /// private int totalErrors; /// public event EventHandler? ErrorsChanged; /// /// 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. /// 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. /// /// A set of key/value pairs to make available to consumers. protected ObservableValidator(IDictionary? items) { this.validationContext = new ValidationContext(this, 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. /// /// An instance to make available during validation. /// A set of key/value pairs to make available to consumers. protected ObservableValidator(IServiceProvider? serviceProvider, IDictionary? items) { this.validationContext = new ValidationContext(this, serviceProvider, 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. /// /// /// 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. /// /// /// Thrown if is . protected ObservableValidator(ValidationContext validationContext) { ArgumentNullException.ThrowIfNull(validationContext); this.validationContext = validationContext; } /// public bool HasErrors => this.totalErrors > 0; /// /// Compares the current and new values for a given property. If the value has changed, /// raises the event, updates the property with /// the new value, then raises the event. /// /// The type of the property that changed. /// The field storing the property's value. /// The property's value after the change occurred. /// If , will also be validated. /// (optional) The name of the property that changed. /// if the property was changed, otherwise. /// /// This method is just like , just with the addition /// of the parameter. If that is set to , the new value will be /// validated and will be raised if needed. Following the behavior of the base method, /// the and events /// are not raised if the current and new value for the target property are the same. /// /// Thrown if is . protected bool SetProperty([NotNullIfNotNull("newValue")] ref T field, T newValue, bool validate, [CallerMemberName] string propertyName = null!) { ArgumentNullException.ThrowIfNull(propertyName); bool propertyChanged = SetProperty(ref field, newValue, propertyName); if (propertyChanged && validate) { ValidateProperty(newValue, propertyName); } return propertyChanged; } /// /// Compares the current and new values for a given property. If the value has changed, /// raises the event, updates the property with /// the new value, then raises the event. /// See additional notes about this overload in . /// /// The type of the property that changed. /// The field storing the property's value. /// The property's value after the change occurred. /// The instance to use to compare the input values. /// If , will also be validated. /// (optional) The name of the property that changed. /// if the property was changed, otherwise. /// Thrown if or are . protected bool SetProperty([NotNullIfNotNull("newValue")] ref T field, T newValue, IEqualityComparer comparer, bool validate, [CallerMemberName] string propertyName = null!) { ArgumentNullException.ThrowIfNull(comparer); ArgumentNullException.ThrowIfNull(propertyName); bool propertyChanged = SetProperty(ref field, newValue, comparer, propertyName); if (propertyChanged && validate) { ValidateProperty(newValue, propertyName); } return propertyChanged; } /// /// Compares the current and new values for a given property. If the value has changed, /// raises the event, updates the property with /// the new value, then raises the event. Similarly to /// the method, this overload should only be /// used when can't be used directly. /// /// The type of the property that changed. /// The current property value. /// The property's value after the change occurred. /// A callback to invoke to update the property value. /// If , will also be validated. /// (optional) The name of the property that changed. /// if the property was changed, otherwise. /// /// This method is just like , just with the addition /// of the parameter. As such, following the behavior of the base method, /// the and events /// are not raised if the current and new value for the target property are the same. /// /// Thrown if or are . protected bool SetProperty(T oldValue, T newValue, Action callback, bool validate, [CallerMemberName] string propertyName = null!) { ArgumentNullException.ThrowIfNull(callback); ArgumentNullException.ThrowIfNull(propertyName); bool propertyChanged = SetProperty(oldValue, newValue, callback, propertyName); if (propertyChanged && validate) { ValidateProperty(newValue, propertyName); } return propertyChanged; } /// /// Compares the current and new values for a given property. If the value has changed, /// raises the event, updates the property with /// the new value, then raises the event. /// See additional notes about this overload in . /// /// The type of the property that changed. /// The current property value. /// The property's value after the change occurred. /// The instance to use to compare the input values. /// A callback to invoke to update the property value. /// If , will also be validated. /// (optional) The name of the property that changed. /// if the property was changed, otherwise. /// Thrown if , or are . protected bool SetProperty(T oldValue, T newValue, IEqualityComparer comparer, Action callback, bool validate, [CallerMemberName] string propertyName = null!) { ArgumentNullException.ThrowIfNull(comparer); ArgumentNullException.ThrowIfNull(callback); ArgumentNullException.ThrowIfNull(propertyName); bool propertyChanged = SetProperty(oldValue, newValue, comparer, callback, propertyName); if (propertyChanged && validate) { ValidateProperty(newValue, propertyName); } return propertyChanged; } /// /// Compares the current and new values for a given nested property. If the value has changed, /// raises the event, updates the property and then raises the /// event. The behavior mirrors that of /// , with the difference being that this /// method is used to relay properties from a wrapped model in the current instance. For more info, see the docs for /// . /// /// The type of model whose property (or field) to set. /// The type of property (or field) to set. /// The current property value. /// The property's value after the change occurred. /// The model /// The callback to invoke to set the target property value, if a change has occurred. /// If , will also be validated. /// (optional) The name of the property that changed. /// if the property was changed, otherwise. /// Thrown if , or are . protected bool SetProperty(T oldValue, T newValue, TModel model, Action callback, bool validate, [CallerMemberName] string propertyName = null!) where TModel : class { ArgumentNullException.ThrowIfNull(model); ArgumentNullException.ThrowIfNull(callback); ArgumentNullException.ThrowIfNull(propertyName); bool propertyChanged = SetProperty(oldValue, newValue, model, callback, propertyName); if (propertyChanged && validate) { ValidateProperty(newValue, propertyName); } return propertyChanged; } /// /// Compares the current and new values for a given nested property. If the value has changed, /// raises the event, updates the property and then raises the /// event. The behavior mirrors that of /// , /// with the difference being that this method is used to relay properties from a wrapped model in the /// current instance. For more info, see the docs for /// . /// /// The type of model whose property (or field) to set. /// The type of property (or field) to set. /// The current property value. /// The property's value after the change occurred. /// The instance to use to compare the input values. /// The model /// The callback to invoke to set the target property value, if a change has occurred. /// If , will also be validated. /// (optional) The name of the property that changed. /// if the property was changed, otherwise. /// Thrown if , , or are . protected bool SetProperty(T oldValue, T newValue, IEqualityComparer comparer, TModel model, Action callback, bool validate, [CallerMemberName] string propertyName = null!) where TModel : class { ArgumentNullException.ThrowIfNull(comparer); ArgumentNullException.ThrowIfNull(model); ArgumentNullException.ThrowIfNull(callback); ArgumentNullException.ThrowIfNull(propertyName); bool propertyChanged = SetProperty(oldValue, newValue, comparer, model, callback, propertyName); if (propertyChanged && validate) { ValidateProperty(newValue, propertyName); } return propertyChanged; } /// /// Tries to validate a new value for a specified property. If the validation is successful, /// is called, otherwise no state change is performed. /// /// The type of the property that changed. /// The field storing the property's value. /// The property's value after the change occurred. /// The resulting validation errors, if any. /// (optional) The name of the property that changed. /// Whether the validation was successful and the property value changed as well. /// Thrown if is . protected bool TrySetProperty(ref T field, T newValue, out IReadOnlyCollection errors, [CallerMemberName] string propertyName = null!) { ArgumentNullException.ThrowIfNull(propertyName); return TryValidateProperty(newValue, propertyName, out errors) && SetProperty(ref field, newValue, propertyName); } /// /// Tries to validate a new value for a specified property. If the validation is successful, /// is called, otherwise no state change is performed. /// /// The type of the property that changed. /// The field storing the property's value. /// The property's value after the change occurred. /// The instance to use to compare the input values. /// The resulting validation errors, if any. /// (optional) The name of the property that changed. /// Whether the validation was successful and the property value changed as well. /// Thrown if or are . protected bool TrySetProperty(ref T field, T newValue, IEqualityComparer comparer, out IReadOnlyCollection errors, [CallerMemberName] string propertyName = null!) { ArgumentNullException.ThrowIfNull(comparer); ArgumentNullException.ThrowIfNull(propertyName); return TryValidateProperty(newValue, propertyName, out errors) && SetProperty(ref field, newValue, comparer, propertyName); } /// /// Tries to validate a new value for a specified property. If the validation is successful, /// is called, otherwise no state change is performed. /// /// The type of the property that changed. /// The current property value. /// The property's value after the change occurred. /// A callback to invoke to update the property value. /// The resulting validation errors, if any. /// (optional) The name of the property that changed. /// Whether the validation was successful and the property value changed as well. /// Thrown if or are . protected bool TrySetProperty(T oldValue, T newValue, Action callback, out IReadOnlyCollection errors, [CallerMemberName] string propertyName = null!) { ArgumentNullException.ThrowIfNull(callback); ArgumentNullException.ThrowIfNull(propertyName); return TryValidateProperty(newValue, propertyName, out errors) && SetProperty(oldValue, newValue, callback, propertyName); } /// /// Tries to validate a new value for a specified property. If the validation is successful, /// is called, otherwise no state change is performed. /// /// The type of the property that changed. /// The current property value. /// The property's value after the change occurred. /// The instance to use to compare the input values. /// A callback to invoke to update the property value. /// The resulting validation errors, if any. /// (optional) The name of the property that changed. /// Whether the validation was successful and the property value changed as well. /// Thrown if , or are . protected bool TrySetProperty(T oldValue, T newValue, IEqualityComparer comparer, Action callback, out IReadOnlyCollection errors, [CallerMemberName] string propertyName = null!) { ArgumentNullException.ThrowIfNull(comparer); ArgumentNullException.ThrowIfNull(callback); ArgumentNullException.ThrowIfNull(propertyName); return TryValidateProperty(newValue, propertyName, out errors) && SetProperty(oldValue, newValue, comparer, callback, propertyName); } /// /// Tries to validate a new value for a specified property. If the validation is successful, /// is called, otherwise no state change is performed. /// /// The type of model whose property (or field) to set. /// The type of property (or field) to set. /// The current property value. /// The property's value after the change occurred. /// The model /// The callback to invoke to set the target property value, if a change has occurred. /// The resulting validation errors, if any. /// (optional) The name of the property that changed. /// Whether the validation was successful and the property value changed as well. /// Thrown if , or are . protected bool TrySetProperty(T oldValue, T newValue, TModel model, Action callback, out IReadOnlyCollection errors, [CallerMemberName] string propertyName = null!) where TModel : class { ArgumentNullException.ThrowIfNull(model); ArgumentNullException.ThrowIfNull(callback); ArgumentNullException.ThrowIfNull(propertyName); return TryValidateProperty(newValue, propertyName, out errors) && SetProperty(oldValue, newValue, model, callback, propertyName); } /// /// Tries to validate a new value for a specified property. If the validation is successful, /// is called, otherwise no state change is performed. /// /// The type of model whose property (or field) to set. /// The type of property (or field) to set. /// The current property value. /// The property's value after the change occurred. /// The instance to use to compare the input values. /// The model /// The callback to invoke to set the target property value, if a change has occurred. /// The resulting validation errors, if any. /// (optional) The name of the property that changed. /// Whether the validation was successful and the property value changed as well. /// Thrown if , , or are . protected bool TrySetProperty(T oldValue, T newValue, IEqualityComparer comparer, TModel model, Action callback, out IReadOnlyCollection errors, [CallerMemberName] string propertyName = null!) where TModel : class { ArgumentNullException.ThrowIfNull(comparer); ArgumentNullException.ThrowIfNull(model); ArgumentNullException.ThrowIfNull(callback); ArgumentNullException.ThrowIfNull(propertyName); return TryValidateProperty(newValue, propertyName, out errors) && SetProperty(oldValue, newValue, comparer, model, callback, propertyName); } /// /// Clears the validation errors for a specified property or for the entire entity. /// /// /// The name of the property to clear validation errors for. /// If a or empty name is used, all entity-level errors will be cleared. /// protected void ClearErrors(string? propertyName = null) { // Clear entity-level errors when the target property is null or empty if (string.IsNullOrEmpty(propertyName)) { ClearAllErrors(); } else { ClearErrorsForProperty(propertyName!); } } /// [Pure] public IEnumerable GetErrors(string? propertyName = null) { // Get entity-level errors when the target property is null or empty if (string.IsNullOrEmpty(propertyName)) { // Local function to gather all the entity-level errors [Pure] [MethodImpl(MethodImplOptions.NoInlining)] IEnumerable GetAllErrors() { return this.errors.Values.SelectMany(static errors => errors); } return GetAllErrors(); } // Property-level errors, if any if (this.errors.TryGetValue(propertyName!, out List? errors)) { return errors; } // The INotifyDataErrorInfo.GetErrors method doesn't specify exactly what to // return when the input property name is invalid, but given that the return // type is marked as a non-nullable reference type, here we're returning an // empty array to respect the contract. This also matches the behavior of // this method whenever errors for a valid properties are retrieved. return Array.Empty(); } /// [Pure] 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. /// /// /// 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. /// 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. 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); } // 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 _ => { }; } // 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) })); return Expression.Lambda>(body, arg0).Compile(); } // 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(), static t => GetValidationAction(t))(this); } /// /// Validates a property with a specified name and a given input value. /// If any changes are detected, the event will be raised. /// /// The value to test for the specified property. /// The name of the property to validate. /// Thrown when is . protected internal void ValidateProperty(object? value, [CallerMemberName] string propertyName = null!) { ArgumentNullException.ThrowIfNull(propertyName); // Check if the property had already been previously validated, and if so retrieve // the reusable list of validation errors from the errors dictionary. This list is // used to add new validation errors below, if any are produced by the validator. // If the property isn't present in the dictionary, add it now to avoid allocations. if (!this.errors.TryGetValue(propertyName, out List? propertyErrors)) { propertyErrors = new List(); this.errors.Add(propertyName, propertyErrors); } bool errorsChanged = false; // Clear the errors for the specified property, if any if (propertyErrors.Count > 0) { propertyErrors.Clear(); 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); // Update the shared counter for the number of errors, and raise the // property changed event if necessary. We decrement the number of total // errors if the current property is valid but it wasn't so before this // 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 (errorsChanged) { this.totalErrors--; if (this.totalErrors == 0) { OnPropertyChanged(HasErrorsChangedEventArgs); } } } else if (!errorsChanged) { this.totalErrors++; if (this.totalErrors == 1) { OnPropertyChanged(HasErrorsChangedEventArgs); } } // 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) { 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. /// /// The value to test for the specified property. /// The name of the property to validate. /// The resulting validation errors, if any. 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)) { propertyErrors = new List(); 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); // 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) { propertyErrors.Clear(); this.totalErrors--; if (this.totalErrors == 0) { OnPropertyChanged(HasErrorsChangedEventArgs); } ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName)); } errors = localErrors; return isValid; } /// /// Clears all the current errors for the entire entity. /// private void ClearAllErrors() { if (this.totalErrors == 0) { return; } // Clear the errors for all properties with at least one error, and raise the // ErrorsChanged event for those properties. Other properties will be ignored. foreach (KeyValuePair> propertyInfo in this.errors) { bool hasErrors = propertyInfo.Value.Count > 0; propertyInfo.Value.Clear(); if (hasErrors) { ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyInfo.Key)); } } this.totalErrors = 0; OnPropertyChanged(HasErrorsChangedEventArgs); } /// /// Clears all the current errors for a target property. /// /// The name of the property to clear errors for. private void ClearErrorsForProperty(string propertyName) { if (!this.errors.TryGetValue(propertyName!, out List? propertyErrors) || propertyErrors.Count == 0) { return; } propertyErrors.Clear(); this.totalErrors--; if (this.totalErrors == 0) { OnPropertyChanged(HasErrorsChangedEventArgs); } ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName)); } /// /// Gets the display name for a given property. It could be a custom name or just the property name. /// /// The target property name being validated. /// The display name for the property. private string GetDisplayNameForProperty(string propertyName) { 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); } } return displayNames; } // 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 displayName ?? propertyName; } }