validator クレートで使える条件変更可能な validator を書く

ふとしです。

社で Rust を使えないかと画策しています。そこで Rails が持つ機能を Rust ではどうやって実現すれば良いかを調べています。

最近はレコードのバリデーションを行うバリデーターを調べていました。定番と思われる validator クレートは作り付けのバリデーターの他に自作の関数でバリデーションを行えますが、inclusion のように受け入れる値だけを差し替えたい場合に少し苦労したので、日記にします。

まず Rails の inclusion バリデーターとは

与えられた条件に合致する値ならパス、合致しなければエラーとするバリデーターです。ActiveModel::Validations::InclusionValidator

Rails だと boolean を取る必須カラムでお馴染みですね。

validates :ok, inclusion: { in: [true, false] }}

include?cover? を実装したクラスや検査用の Proc が条件として渡せます。(試してないので間違っているかもですが、だいたいこんな雰囲気)

special_memvers = Set.new(['mmmpa', 'om2'])
validates :special_member, inclusion: { in: ->(m) { special_members.include?(m.name) }}
validates :odd_member, inclusion: { in: ->(m) { m.id % 2 != 0 }}

このように条件だけを変更できる custom バリデーターを実装するのが今回の目的です。

validator クレートの custom バリデーター、そして縛り

custom バリデーターは対象となるフィールドの値の参照もしくはコピーを引数に取る関数です。おおむね数値型とその Option 型はコピーが、それ以外は参照が引数の型になります。

#[test]
fn test() {
    struct Member {
        name: String,
        id: usize,
    }

    #[derive(Debug, Validate)]
    struct Form {
        #[validate(custom = "validate_a")]
        special_member: String,
        #[validate(custom = "validate_b")]
        odd_member: usize,
    }

    fn validate_a(value: &str) -> Result<(), ValidationError> {
        todo!()
    }

    fn validate_b(value: usize) -> Result<(), ValidationError> {
        todo!()
    }
}

custom バリデーターは以下の縛りがあります。

この制限下で条件のみを変更したいと思す。

いろいろ実装してみる

というわけで、レシーバー (&self など) をとらない関数を、引数を変えずに振る舞いを変えていく感じのやつを試していきました。

まずは素直に一つずつ定義

一番単純な方法です。

#[test]
fn test1() {
    struct Member {
        name: String,
        id: usize,
    }

    #[derive(Debug, Validate)]
    struct Form {
        #[validate(custom = "validate_a")]
        special_member: String,
        #[validate(custom = "validate_b")]
        odd_member: usize,
    }

    fn validate_a(value: &str) -> Result<(), ValidationError> {
        static OK_MEMBER: once_cell::sync::Lazy<HashSet<&'static str>> =
            once_cell::sync::Lazy::new(|| {
                let mut set = HashSet::new();
                set.insert("mmmpa");
                set.insert("om2");
                set
            });

        if OK_MEMBER.contains(value) {
            Err(ValidationError::new("not accepted"))
        } else {
            Ok(())
        }
    }

    fn validate_b(value: usize) -> Result<(), ValidationError> {
        if value % 2 != 0 {
            Err(ValidationError::new("not accepted"))
        } else {
            Ok(())
        }
    }
}

型引数 + Trait でなんとかする

任意のマーカー型に共通の Trait を実装して、テンプレートメソッドパターンのようにしました。

関数の引数に使う

おおむね良い感じに切り替わりましたが、フィールドの型を derive 側にも書かなくてはいけないのが気に食いません。

#[test]
fn test() {
    #[derive(Debug, Serialize)]
    struct Member {
        name: String,
        id: usize,
    }

    #[derive(Debug, Validate)]
    struct Form {
        #[validate(custom = "inclusion::<&Member, SpecialMember>")]
        special_member: Member,
        #[validate(custom = "inclusion::<&Member, OddMember>")]
        odd_member: Member,
    }

    trait Contains<T> {
        fn contains(v: T) -> bool;
    }

    fn inclusion<T, V: Contains<T>>(value: T) -> Result<(), ValidationError> {
        if V::contains(value) {
            Err(ValidationError::new("not accepted"))
        } else {
            Ok(())
        }
    }

    struct SpecialMember;
    impl Contains<&Member> for SpecialMember {
        fn contains(value: &Member) -> bool {
            static OK_MEMBER: once_cell::sync::Lazy<HashSet<&str>> =
                once_cell::sync::Lazy::new(|| {
                    let mut set = HashSet::new();
                    set.insert("mmmpa");
                    set.insert("om2");
                    set
                });
            OK_MEMBER.contains(&value.name as &str)
        }
    }

    struct OddMember;
    impl Contains<&Member> for OddMember {
        fn contains(value: &Member) -> bool {
            value.id % 2 != 0
        }
    }
}

値の型にバリデーション関数を定義する + 関数の引数

そこで今度は Member 自体に inclusion を実装しました。

custom バリデーターは &Member を渡します。したがって Member::exec(value)value.exec() のように扱えます。

これで型は表に出なくなりました。

#[test]
fn test() {
    #[derive(Debug, Serialize)]
    struct Member {
        name: String,
        id: usize,
    }

    #[derive(Debug, Validate)]
    struct Form {
        #[validate(custom = "Inclusion::<SpecialMember>::exec")]
        special_member: Member,
        #[validate(custom = "Inclusion::<OddMember>::exec")]
        odd_member: Member,
    }

    trait Contains<T: ?Sized> {
        fn contains(v: &T) -> bool;
    }

    trait Inclusion<T: Contains<Self>> {
        fn exec(&self) -> Result<(), ValidationError>;
    }

    struct SpecialMember;
    impl Contains<Member> for SpecialMember {
        fn contains(value: &Member) -> bool {
            static OK_MEMBER: once_cell::sync::Lazy<HashSet<&str>> =
                once_cell::sync::Lazy::new(|| {
                    let mut set = HashSet::new();
                    set.insert("mmmpa");
                    set.insert("om2");
                    set
                });
            OK_MEMBER.contains(&value.name as &str)
        }
    }

    struct OddMember;
    impl Contains<Member> for OddMember {
        fn contains(value: &Member) -> bool {
            value.id % 2 != 0
        }
    }

    impl<T: Contains<Self>> Inclusion<T> for Member {
        fn exec(&self) -> Result<(), ValidationError> {
            if T::contains(self) {
                Err(ValidationError::new("not accepted"))
            } else {
                Ok(())
            }
        }
    }
}

構造体の型引数を使う

ジェネリック型を取る構造体は、異なる型を取った場合は異なる型として扱われます。つまり同名のメソッドで異なるふるまいを定義できます。

Inclusion<T> からは Inclusion<SpecialMember> を認識できないので complete を各型から呼ばなくてはならないのが残念ですが、こちらでも可能です。

#[test]
fn test() {
    #[derive(Debug, Serialize)]
    struct Member {
        name: String,
        id: usize,
    }

    #[derive(Debug, Validate)]
    struct Form {
        #[validate(custom = "Inclusion::<SpecialMember>::exec")]
        special_member: Member,
        #[validate(custom = "Inclusion::<SpecialMember>::exec")]
        odd_member: Member,
    }

    struct Inclusion<T>(PhantomData<T>);

    impl<T> Inclusion<T> {
        fn complete(b: bool) -> Result<(), ValidationError> {
            if b {
                Err(ValidationError::new("not accepted"))
            } else {
                Ok(())
            }
        }
    }

    struct SpecialMember;
    impl Inclusion<SpecialMember> {
        fn exec(value: &Member) -> Result<(), ValidationError> {
            static OK_MEMBER: once_cell::sync::Lazy<HashSet<&str>> =
                once_cell::sync::Lazy::new(|| {
                    let mut set = HashSet::new();
                    set.insert("mmmpa");
                    set.insert("om2");
                    set
                });
            Self::complete(OK_MEMBER.contains(&value.name as &str))
        }
    }

    struct OddMember;
    impl Inclusion<OddMember> {
        fn exec(value: &Member) -> Result<(), ValidationError> {
            Self::complete(value.id % 2 != 1)
        }
    }
}

おわりに

なんとかして同じ呼び方で振る舞いだけを変えたいと思って色々やってみました。

最後のジェネリック構造体は各型で自由気ままに定義できてしまうので、使うなら同じシグネチャが要求される Trait が良いかも知れません。

まぁしかし、変にこねないで、最初の例のように一つずつ custom バリデーターを定義したほうがシンプルでわかりやすい感じはしますね……。