こんにちは、サービス開発課の丸山です。
今回はRubyの小ネタの紹介です。
Hashの構造を検査するための方法いろいろ
アプリケーションを書いていると、Hashの構造を確認してバリデーションしたい場合があると思います。
例えば、引数で渡されるあるHashが次のような構造であることを担保したい場合などです。
{ name: String, # ex: Tarou age: Integer, # ex: 20 }
モデルのjson型やjsonb型カラムのバリデーションを行う場合であれば activerecord_json_validator gem のようなライブラリもありますが、今回はバリューオブジェクトの初期化時に渡されたHashの検査という使い道だったので使えませんでした。
引数のHashのチェックを簡単に行う分には ActiveSupport の Hash#assert_valid_keysも便利です。
{ name: 'Rob', years: '28' }.assert_valid_keys(:name, :age) # => raises "ArgumentError: Unknown key: :years. Valid keys are: :name, :age"
ただし、これだとキーしか確認できず、値も確認したい場合には使えません。
他にはclassy_hash gemを使うと、簡単に構造のバリデーションができます。
schema = { name: String, age: Integer, } hash = { name: "Tarou", age: 20, } ClassyHash.validate(hash, schema) # => true
とはいえ、gemを導入するとプロジェクトの依存が増えることにもなりますし、ちょっとしたバリデーションにgemを導入するのは少し及び腰になってしまいます。
そこで Ruby 2.7 から実験的に導入されRuby3から正式な機能になった「case/in」のパターンマッチ構文を使うと簡単にHashの構造をチェックできました。
パターンマッチを使ったHashの構造検査
例えば次のように記述します。
def check_hash_schema!(user_hash) case user_hash in { name: String, age: Integer } else raise ArgumentError end end check_hash_schema!({name: "Tarou", age: 20}) # 妥当 # => nil check_hash_schema!({name: "Tarou", year: 20}) # キーが異なる # => ArgumentError check_hash_schema!({name: "Tarou", age: "22"}) # 値の型が異なる # => ArgumentError
パターンマッチ構文はマッチした値をin
で捉えて何らかの操作をすることが多いと思いますが、
今回の場合はマッチした場合には何もせず、マッチしなかった場合をelse
で捉えて例外を発生させることで引数チェックをしています。 *1
ただし、このようなパターンマッチ構文でマッチできるのはキーがシンボルのHashだけであることには注意が必要です。
(文字列がキーのHashに対して今回紹介したようなパターンマッチを行いたい場合は、ActiveSupport::HashWithIndifferentAccess に変換しシンボルでの呼び出しに対応させることで動作しましたが、全てのパターンマッチの構文に対応しているかは確認していません。)
in
キーワードに渡すパターンにはクラスでなくリテラルも指定できます。
例えば次のコードでは、typeキーのバリューが:admin
以外のHashには全て例外を発生します。
case user_hash in { name: String, age: Integer, type: :admin } else raise ArgumentError end
さらに、オプショナルなキーも同様に表現できます。
例えばname
とage
の他にaddress
も受け取れる場合は、次のようにします。
case user_hash in { name: String, age: Integer } in { name: String, age: Integer, address: String } else raise ArgumentError end
最初のin
でaddress
キーがないHashをマッチさせ、2つ目のin
でaddress
キーがある場合をマッチさせています。
そして、「case/in」構文ではガード節と合わせて使うことができるので、型(クラス)以外の情報もパターンマッチの中で使うことができます。 例えば、「ageが偶数の場合」にのみマッチさせたい場合は、次のようにかけます。
case user_hash in { name: String, age: age } if age.is_a?(Integer) && age.even? else raise ArgumentError end
:age
キーの値をage
変数に代入させて、if文の条件に当てはまるかチェックしています。
もちろんin
の内側でも同じように書けますが、この程度ならガード節にした方が見た目がスッキリして良いですね。
以上のようにRuby2.7から登場した「case/in」構文を使うことで、簡単にHashの構造を検査できました。*2 まだ目がなれず、いろいろ調べたりして調査も必要でしたが、gemを導入せずに済んで個人的には満足です。
*1:パターンにマッチしない場合、何も記述しなくとも NoMatchingPatternError が発生しますが、今回は引数のチェックを行うロジックであるのでわかりやすさを重視して else で ArgumentError を発生させています。
*2:Ruby2.7の時点では「case/in」は実験的な機能で実行時に「warning: Pattern matching is experimental, and the behavior may change in future versions of Ruby!」という警告が出力されます。Ruby3以降では正式な機能となったため「case/in」を使っても警告は出力されなくなりました。
丸山 礼 (記事一覧)
サービス開発課でCloud Automatorを開発しています。