ActiveModel::Validationsを使ってrubyでもpythonのTypedDictのようなクラスを作ってみた
概要
rubyの関数の引数などの型定義で、定型的なHashが欲しかったので自作してみた。
型定義の仕方をDSL風にしてみた。実行時にしか型チェックはできない。型チェックライブラリによって不要になりそうではあるが色々な事情でrubyバージョンを上げれない時にも簡易的に利用できる。副産物的にActiveModel::Validationsに対応したvalidatorもセットできるようにしてattr_accessorなどとtypeによるvalidatesが増えすぎないようにしてある。ただ、実際はvalidatesを律儀に書いていくほうが使い勝手が良い気はする。実装は以下の通り。
class TypedHash < Hash
include ActiveModel::Validations
BUILTIN_TYPES = %i[
string
number
int
boolean
callable
var
].freeze
def self.type(key_name, *rest_validators, type: :var, required: true)
key_sym = key_name.to_sym
# getter
define_method(key_sym) do
self[key_sym]
end
# setter
define_method("#{key_name}=".to_sym) do |_val|
store(key_name, value)
end
validates(key_sym, exclusion: [nil]) if required
is_exitsted = proc { |c| !c[key_sym].nil? }
case type
when :string
validates_each key_sym, if: [is_exitsted] do |record, attribute, value|
record.errors.add(attribute, 'はStringではありません。') unless value.is_a? String
end
when :int
validates key_sym, numericality: {
only_integer: true
}, if: [is_exitsted]
when :number
validates key_sym, numericality: true, if: [is_exitsted]
when :boolean
validates key_sym, inclusion: [true, false], if: [is_exitsted]
when :callable
validates_each key_sym, if: [is_exitsted] do |record, attribute, value|
record.errors.add(attribute, 'はCallableではありません。') unless value.respond_to? :call
end
when :var
nil
else
validates_each key_sym, if: [is_exitsted] do |record, attribute, value|
record.errors.add(attribute, "は#{type.name}ではありません。") unless value.is_a? type
end
end
validates(key_sym, *rest_validators) if rest_validators.present?
end
BUILTIN_TYPES.each do |a_type|
define_singleton_method(a_type, lambda { |key_name, *validators|
type(key_name, *validators, type: a_type, required: true)
})
define_singleton_method("#{a_type}?", lambda { |key_name, *validators|
type(key_name, *validators, type: a_type, required: false)
})
end
define_singleton_method(:cls, lambda { |kls, key_name, *validators|
type(key_name, *validators, type: kls, required: true)
})
define_singleton_method(:cls?, lambda { |kls, key_name, *validators|
type(key_name, *validators, type: kls, required: false)
})
def initialize(initial_hash = {}, **alternatives)
super()
initial_hash.merge(alternatives).each do |key, value|
store(key, value)
end
validate!
end
end
使い方
型定義を行うクラスメソッドを定義して型指定っぽく実装している。現在はstring,number,int,boolean,callable,var,cls(class)+?を末尾につけることでOptionalな型に対応している。テストはしていないがネストしている型も多分いけるはず。
class Example < TypedHash
callable :acallable
string? :str
number :num
boolean :false
cls Date, :date1
cls? Date, :date2
var :any
var? :nullable
end
Example.new(
acallable: ->{},
str: 'dummy',
num: 1,
false: false,
date1: Date.new,
any: 'any'
)
出力はこんな感じ。初期化時のバリデーションエラーが出た場合はそのままraiseエラーされる。
{:acallable=>#<Proc:0x------(pry):82 (lambda)>,
:str=>"dummy",
:num=>1,
:false=>false,
:date1=>Mon, 01 Jan -4712,
:any=>"any"}
method_missingなどでアクセサ経由のkeyの追加はできないようにしているが[]オペレータは書き換えていないので、動的にキーと値を追加することはできる。ここの仕様は少し迷ったが普通のhashとして扱う以上動的に追加できる余地を残しておくことにした。実用上も今のところ困っていることはない。
ActiveModel::Validationsの必要な機能だけ抜き出して再実装しても良いかなと思ったが、validatorメソッドを利用できると実装が楽だったのでそのままにした。
もし、型のないrubyで困っている人がいればこういうこともできるよという参考にしていただければというお話でした。
Discussion