[C#]ValidationAttributeを継承して2つの項目の大小関係を検証する

# これを読んでできること
- 2つの入力項目を検証し、定義した大小関係を満たしているか検証できる
- カスタムバリデーションを作れるようになる

# ValidationAttributeとは

> すべての検証属性の基本クラスとして機能します。
> [ValidationAttribute クラス - docs.microsoft.com](https://docs.microsoft.com/ja-jp/dotnet/api/system.componentmodel.dataannotations.validationattribute)

MSのドキュメントを引用しましたが、これだけではよくわかりませんね。

`ValidationAttribute` とはフォームからの入力値やモデルオブジェクトのプロパティが正しいかどうかの検証を強制し、また正しさを定義する属性の基底クラスです。
カスタム検証属性を定義する場合はこれを継承していきます。

参考によく使われる検証属性を以下に記載します。

|属性名|機能|
|---|---|
|Required|必須項目。nullや未入力の場合エラーとします|
|StringLength(int)|最大文字長。指定した文字数を超えるとエラーとします|
|EmailAddress|メールアドレス形式の項目。メールアドレスの形式を満たさない場合エラーとします|

`System.ComponentModel.DataAnnotations` には標準で複数の検証属性が実装されています。しかし、開始時刻と終了時刻のように他のプロパティに依存する検証属性はありません[^compare]。そのためカスタム検証属性を作る必要があります。

[^compare]: `Compare` 属性がありますが、これは同値のみを検証するため今回のような大小関係を検証する用途では使用できません。

# カスタム検証属性を作ってみた

結果として、検証属性を4種類作りました。
どれもほぼ内容が同じなので「AよりBが大きい」を検証する `GreaterThanAttribute` [^GreaterThanAttribute]のソースコードを添付します。

[^GreaterThanAttribute]: シェルの `gt` よりこの名称を採用。他には `GreaterThanOrEqual` `LessThan` `LessThanOrEqual` を定義。それぞれ `ge` `lt` `le`から。

```cs:GreaterThanAttribute.cs
[AttributeUsage(AttributeTargets.Property)]
    public class GreaterThanAttribute : ValidationAttribute
    {
        public string OtherProperty { get; private set; }
        public string OtherPropertyDisplayName { get; internal set; }

        public GreaterThanAttribute(string otherProperty)
        {
            OtherProperty = otherProperty;
            ErrorMessage = "{0}は{1}より大きい値を指定してください。";
        }

        public override string FormatErrorMessage(string name)
        {
            // エラーメッセージを返す
            return String.Format(CultureInfo.CurrentCulture, ErrorMessageString, name, OtherPropertyDisplayName ?? OtherProperty);
        }

        protected override ValidationResult IsValid(object value, ValidationContext validationContext)
        {
            // 比較対象のPropertyInfo
            PropertyInfo propertyInfo = validationContext.ObjectType.GetProperty(OtherProperty);
            // 比較対象のプロパティの値
            object propertyValue = propertyInfo.GetValue(validationContext.ObjectInstance, null);
            Type type = propertyInfo.PropertyType;

            if (type == typeof(DateTime))
            {
                // ここで値の比較。条件を満たしていれば検証成功を返す
                if ((DateTime)value > (DateTime)propertyValue)
                {
                    return ValidationResult.Success;
                }
            }
            else if (type == typeof(int))
            {
                if ((int)value > (int)propertyValue)
                {
                    return ValidationResult.Success;
                }
            }
            // ...other type

            if (OtherPropertyDisplayName == null)
            {
                OtherPropertyDisplayName = GetDisplayNameForProperty(validationContext.ObjectType, OtherProperty);
            }

            return new ValidationResult(FormatErrorMessage(validationContext.DisplayName));
        }

        // 比較対象のプロパティ名を取得する。ここはオマケなので削っても問題ない
        private static string GetDisplayNameForProperty(Type containerType, string propertyName)
        {
            ICustomTypeDescriptor typeDescriptor = GetTypeDescriptor(containerType);
            PropertyDescriptor property = typeDescriptor.GetProperties().Find(propertyName, true);

            if (property == null)
            {
                throw new ArgumentException();
            }

            IEnumerable<Attribute> attributes = property.Attributes.Cast<Attribute>();
            DisplayAttribute display = attributes.OfType<DisplayAttribute>().FirstOrDefault();

            if (display != null)
            {
                // DisplayAttributeがついてたらその名称を返す
                return display.GetName();
            }

            DisplayNameAttribute displayName = attributes.OfType<DisplayNameAttribute>().FirstOrDefault();

            if (displayName != null)
            {
                // DisplayNameAttributeがついてたらその名称を返す
                return displayName.DisplayName;
            }

            return propertyName;
        }

        private static ICustomTypeDescriptor GetTypeDescriptor(Type type)
        {
            return new AssociatedMetadataTypeTypeDescriptionProvider(type).GetTypeDescriptor(type);
        }
    }
```

カスタム検証属性を使用するモデル側は以下の通りです。

```cs:Model.cs
        // 開始時刻
        // LessThanAttributeのコードは割愛
        [LessThan("EndDateTime")]
        public DateTime StartDateTime { get; set; }

        // 終了時刻
        [GreaterThan("StartDateTime")]
        public DateTime EndDateTime { get; set; }
```

# 使い方
`開始時刻` < `終了時刻` であることを検証したい場合、 `StartDateTime` に `[LessThan]` 属性、 `EndDateTime` に `[GreaterThan]` 属性を付けます。引数には比較対象のプロパティを `string` で指定します。`Compare` 属性と似ていますね。

属性をつけることで[ASP.NET MVCのモデル検証](https://docs.microsoft.com/ja-jp/aspnet/core/mvc/models/validation)や[Blazorのフォーム検証](https://docs.microsoft.com/ja-jp/aspnet/core/blazor/forms-validation)時に大小関係が正しいか検証されます。大小関係を満たさない場合、「**StartDateTimeはEndDateTimeより小さい値を指定してください。**」や「**EndDateTimeはStartDateTimeより大きい値を指定してください。**」がエラーメッセージとして表示されます。

# おわりに
もっと良い書き方があるような気もしますが、一旦ここで切り上げます。型の分岐とかもっとイケてる書き方[^switch]ができそうですね。元気があるときに書き直したいと思います。

[^switch]: C# 8.0では[switch式](https://docs.microsoft.com/ja-jp/dotnet/csharp/whats-new/csharp-8#switch-expressions)が使えるのでこのあたりをうまくやれば・・・

# 参考
- CompareAttribute.cs（ベースとしたソース）
  - https://github.com/microsoft/referencesource/blob/master/System.ComponentModel.DataAnnotations/DataAnnotations/CompareAttribute.cs