From 57a611533cd4d7e7e426853bb1d6474c7cddeecc Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 18 Aug 2016 23:08:05 +0200 Subject: [PATCH] Added Data Annotations validation. --- samples/BindingTest/BindingTest.csproj | 2 + samples/BindingTest/MainWindow.xaml | 4 + .../DataAnnotationsErrorViewModel.cs | 14 +++ .../ViewModels/MainWindowViewModel.cs | 1 + .../Avalonia.Markup/Avalonia.Markup.csproj | 1 + .../Data/ExpressionObserver.cs | 1 + .../DataAnnotationsValidationPlugin.cs | 81 +++++++++++++ .../Data/Plugins/ExceptionValidationPlugin.cs | 2 +- .../Data/Plugins/IDataValidationPlugin.cs | 3 +- .../Data/Plugins/IndeiValidationPlugin.cs | 2 +- .../Data/PropertyAccessorNode.cs | 2 +- src/Markup/Avalonia.Markup/packages.config | 1 + .../Avalonia.Markup.UnitTests.csproj | 5 + .../DataAnnotationsValidationPluginTests.cs | 111 ++++++++++++++++++ .../Avalonia.Markup.UnitTests/packages.config | 1 + 15 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 samples/BindingTest/ViewModels/DataAnnotationsErrorViewModel.cs create mode 100644 src/Markup/Avalonia.Markup/Data/Plugins/DataAnnotationsValidationPlugin.cs create mode 100644 tests/Avalonia.Markup.UnitTests/Data/Plugins/DataAnnotationsValidationPluginTests.cs diff --git a/samples/BindingTest/BindingTest.csproj b/samples/BindingTest/BindingTest.csproj index ac783d1471..a719dd2517 100644 --- a/samples/BindingTest/BindingTest.csproj +++ b/samples/BindingTest/BindingTest.csproj @@ -50,6 +50,7 @@ True + ..\..\packages\System.Reactive.Core.3.0.0\lib\net45\System.Reactive.Core.dll @@ -80,6 +81,7 @@ TestItemView.xaml + diff --git a/samples/BindingTest/MainWindow.xaml b/samples/BindingTest/MainWindow.xaml index 614e3b80c7..f0bb169f3c 100644 --- a/samples/BindingTest/MainWindow.xaml +++ b/samples/BindingTest/MainWindow.xaml @@ -79,6 +79,10 @@ + + + + diff --git a/samples/BindingTest/ViewModels/DataAnnotationsErrorViewModel.cs b/samples/BindingTest/ViewModels/DataAnnotationsErrorViewModel.cs new file mode 100644 index 0000000000..b622ee9e18 --- /dev/null +++ b/samples/BindingTest/ViewModels/DataAnnotationsErrorViewModel.cs @@ -0,0 +1,14 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System.ComponentModel.DataAnnotations; + +namespace BindingTest.ViewModels +{ + public class DataAnnotationsErrorViewModel + { + [Phone] + [MaxLength(10)] + public string PhoneNumber { get; set; } + } +} diff --git a/samples/BindingTest/ViewModels/MainWindowViewModel.cs b/samples/BindingTest/ViewModels/MainWindowViewModel.cs index e38d19612e..94f7ff595a 100644 --- a/samples/BindingTest/ViewModels/MainWindowViewModel.cs +++ b/samples/BindingTest/ViewModels/MainWindowViewModel.cs @@ -69,6 +69,7 @@ namespace BindingTest.ViewModels public ReactiveCommand StringValueCommand { get; } + public DataAnnotationsErrorViewModel DataAnnotationsValidation { get; } = new DataAnnotationsErrorViewModel(); public ExceptionErrorViewModel ExceptionDataValidation { get; } = new ExceptionErrorViewModel(); public IndeiErrorViewModel IndeiDataValidation { get; } = new IndeiErrorViewModel(); } diff --git a/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj b/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj index 780c05d18d..411effd212 100644 --- a/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj +++ b/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj @@ -47,6 +47,7 @@ + diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs b/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs index 512741702b..486ee59469 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs @@ -35,6 +35,7 @@ namespace Avalonia.Markup.Data public static readonly IList DataValidators = new List { + new DataAnnotationsValidationPlugin(), new IndeiValidationPlugin(), new ExceptionValidationPlugin(), }; diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/DataAnnotationsValidationPlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/DataAnnotationsValidationPlugin.cs new file mode 100644 index 0000000000..859438636a --- /dev/null +++ b/src/Markup/Avalonia.Markup/Data/Plugins/DataAnnotationsValidationPlugin.cs @@ -0,0 +1,81 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Reflection; +using Avalonia.Data; + +namespace Avalonia.Markup.Data.Plugins +{ + /// + /// Validates properties on that have s. + /// + public class DataAnnotationsValidationPlugin : IDataValidationPlugin + { + /// + public bool Match(WeakReference reference, string memberName) + { + return reference.Target? + .GetType() + .GetRuntimeProperty(memberName)? + .GetCustomAttributes() + .Any() ?? false; + } + + /// + public IPropertyAccessor Start(WeakReference reference, string name, IPropertyAccessor inner) + { + return new Accessor(reference, name, inner); + } + + private class Accessor : DataValidatiorBase + { + private ValidationContext _context; + + public Accessor(WeakReference reference, string name, IPropertyAccessor inner) + : base(inner) + { + _context = new ValidationContext(reference.Target); + _context.MemberName = name; + } + + public override bool SetValue(object value, BindingPriority priority) + { + return base.SetValue(value, priority); + } + + protected override void InnerValueChanged(object value) + { + var errors = new List(); + + if (Validator.TryValidateProperty(value, _context, errors)) + { + base.InnerValueChanged(value); + } + else + { + base.InnerValueChanged(new BindingNotification( + CreateException(errors), + BindingErrorType.DataValidationError, + value)); + } + } + + private Exception CreateException(IList errors) + { + if (errors.Count == 1) + { + return new ValidationException(errors[0].ErrorMessage); + } + else + { + return new AggregateException( + errors.Select(x => new ValidationException(x.ErrorMessage))); + } + } + } + } +} diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/ExceptionValidationPlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/ExceptionValidationPlugin.cs index 8c3c10aeb9..e0b6bcfd7c 100644 --- a/src/Markup/Avalonia.Markup/Data/Plugins/ExceptionValidationPlugin.cs +++ b/src/Markup/Avalonia.Markup/Data/Plugins/ExceptionValidationPlugin.cs @@ -13,7 +13,7 @@ namespace Avalonia.Markup.Data.Plugins public class ExceptionValidationPlugin : IDataValidationPlugin { /// - public bool Match(WeakReference reference) => true; + public bool Match(WeakReference reference, string memberName) => true; /// public IPropertyAccessor Start(WeakReference reference, string name, IPropertyAccessor inner) diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/IDataValidationPlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/IDataValidationPlugin.cs index 7449c65245..0952e2edab 100644 --- a/src/Markup/Avalonia.Markup/Data/Plugins/IDataValidationPlugin.cs +++ b/src/Markup/Avalonia.Markup/Data/Plugins/IDataValidationPlugin.cs @@ -15,8 +15,9 @@ namespace Avalonia.Markup.Data.Plugins /// Checks whether this plugin can handle data validation on the specified object. /// /// A weak reference to the object. + /// The name of the member to validate. /// True if the plugin can handle the object; otherwise false. - bool Match(WeakReference reference); + bool Match(WeakReference reference, string memberName); /// /// Starts monitoring the data validation state of a property on an object. diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/IndeiValidationPlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/IndeiValidationPlugin.cs index 8fb2568f30..82bc87c207 100644 --- a/src/Markup/Avalonia.Markup/Data/Plugins/IndeiValidationPlugin.cs +++ b/src/Markup/Avalonia.Markup/Data/Plugins/IndeiValidationPlugin.cs @@ -16,7 +16,7 @@ namespace Avalonia.Markup.Data.Plugins public class IndeiValidationPlugin : IDataValidationPlugin { /// - public bool Match(WeakReference reference) => reference.Target is INotifyDataErrorInfo; + public bool Match(WeakReference reference, string memberName) => reference.Target is INotifyDataErrorInfo; /// public IPropertyAccessor Start(WeakReference reference, string name, IPropertyAccessor accessor) diff --git a/src/Markup/Avalonia.Markup/Data/PropertyAccessorNode.cs b/src/Markup/Avalonia.Markup/Data/PropertyAccessorNode.cs index a0f0ef000c..ebeebcd07d 100644 --- a/src/Markup/Avalonia.Markup/Data/PropertyAccessorNode.cs +++ b/src/Markup/Avalonia.Markup/Data/PropertyAccessorNode.cs @@ -44,7 +44,7 @@ namespace Avalonia.Markup.Data { foreach (var validator in ExpressionObserver.DataValidators) { - if (validator.Match(reference)) + if (validator.Match(reference, PropertyName)) { accessor = validator.Start(reference, PropertyName, accessor); } diff --git a/src/Markup/Avalonia.Markup/packages.config b/src/Markup/Avalonia.Markup/packages.config index 9f732f1bcb..bcef21429a 100644 --- a/src/Markup/Avalonia.Markup/packages.config +++ b/src/Markup/Avalonia.Markup/packages.config @@ -1,5 +1,6 @@  + diff --git a/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj b/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj index 4f3f76c15e..55160f1698 100644 --- a/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj +++ b/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj @@ -44,6 +44,10 @@ True + + C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETPortable\v4.5\Profile\Profile7\System.ComponentModel.Annotations.dll + + ..\..\packages\System.Reactive.Core.3.0.0\lib\net45\System.Reactive.Core.dll @@ -92,6 +96,7 @@ + diff --git a/tests/Avalonia.Markup.UnitTests/Data/Plugins/DataAnnotationsValidationPluginTests.cs b/tests/Avalonia.Markup.UnitTests/Data/Plugins/DataAnnotationsValidationPluginTests.cs new file mode 100644 index 0000000000..b873971e7f --- /dev/null +++ b/tests/Avalonia.Markup.UnitTests/Data/Plugins/DataAnnotationsValidationPluginTests.cs @@ -0,0 +1,111 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Avalonia.Data; +using Avalonia.Markup.Data.Plugins; +using Xunit; + +namespace Avalonia.Markup.UnitTests.Data.Plugins +{ + public class DataAnnotationsValidationPluginTests + { + [Fact] + public void Should_Match_Property_With_ValidatorAttribute() + { + var target = new DataAnnotationsValidationPlugin(); + var data = new Data(); + + Assert.True(target.Match(new WeakReference(data), nameof(Data.Between5And10))); + } + + [Fact] + public void Should_Match_Property_With_Multiple_ValidatorAttributes() + { + var target = new DataAnnotationsValidationPlugin(); + var data = new Data(); + + Assert.True(target.Match(new WeakReference(data), nameof(Data.PhoneNumber))); + } + + [Fact] + public void Should_Not_Match_Property_Without_ValidatorAttribute() + { + var target = new DataAnnotationsValidationPlugin(); + var data = new Data(); + + Assert.False(target.Match(new WeakReference(data), nameof(Data.Unvalidated))); + } + + [Fact] + public void Produces_Range_BindingNotificationsx() + { + var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); + var validatorPlugin = new DataAnnotationsValidationPlugin(); + var data = new Data(); + var accessor = inpcAccessorPlugin.Start(new WeakReference(data), nameof(data.Between5And10)); + var validator = validatorPlugin.Start(new WeakReference(data), nameof(data.Between5And10), accessor); + var result = new List(); + + validator.Subscribe(x => result.Add(x)); + validator.SetValue(3, BindingPriority.LocalValue); + validator.SetValue(7, BindingPriority.LocalValue); + validator.SetValue(11, BindingPriority.LocalValue); + + Assert.Equal(new[] + { + new BindingNotification(5), + new BindingNotification( + new ValidationException("The field Between5And10 must be between 5 and 10."), + BindingErrorType.DataValidationError, + 3), + new BindingNotification(7), + new BindingNotification( + new ValidationException("The field Between5And10 must be between 5 and 10."), + BindingErrorType.DataValidationError, + 11), + }, result); + } + + [Fact] + public void Produces_Aggregate_BindingNotificationsx() + { + var inpcAccessorPlugin = new InpcPropertyAccessorPlugin(); + var validatorPlugin = new DataAnnotationsValidationPlugin(); + var data = new Data(); + var accessor = inpcAccessorPlugin.Start(new WeakReference(data), nameof(data.PhoneNumber)); + var validator = validatorPlugin.Start(new WeakReference(data), nameof(data.PhoneNumber), accessor); + var result = new List(); + + validator.Subscribe(x => result.Add(x)); + validator.SetValue("123456", BindingPriority.LocalValue); + validator.SetValue("abcdefghijklm", BindingPriority.LocalValue); + + Assert.Equal(new[] + { + new BindingNotification(null), + new BindingNotification("123456"), + new BindingNotification( + new AggregateException( + new ValidationException("The PhoneNumber field is not a valid phone number."), + new ValidationException("The field PhoneNumber must be a string or array type with a maximum length of '10'.")), + BindingErrorType.DataValidationError, + "abcdefghijklm"), + }, result); + } + + private class Data + { + [Range(5, 10)] + public int Between5And10 { get; set; } = 5; + + public int Unvalidated { get; set; } + + [Phone] + [MaxLength(10)] + public string PhoneNumber { get; set; } + } + } +} diff --git a/tests/Avalonia.Markup.UnitTests/packages.config b/tests/Avalonia.Markup.UnitTests/packages.config index ff66c030c1..d264c076fd 100644 --- a/tests/Avalonia.Markup.UnitTests/packages.config +++ b/tests/Avalonia.Markup.UnitTests/packages.config @@ -2,6 +2,7 @@ +