概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Rails Form Objects With dry-rb
- 原文公開日: 2016/09/06
- 著者: Michał Gutowski
- サイト: http://cucumbersome.net/
Rails: dry-rbでForm Objectを作る(翻訳)
現代のRailsでは、Form Objectを作るのは珍しくありません。多くのRuby開発者はVirtusやActiveModel::ValidationsをincludeしてForm Objectを作成することに慣れています。本記事では、dry-typeとdry-validationを使ってForm Objectを作成する方法をご紹介したいと思います。
絵ハガキ(postcard)を作成する簡単なForm Objectを作ってみましょう。このアプリには次の3つのモデルがあります。
Country
: フィールドはname
とis_state_required
。2つ目のフィールドは正しいアドレスの作成に使われ、米国などのユーザーは州名の入力が必要です。Country::State
: フィールドはname
とcountry_id
。Postcard
: フィールドはstate_id
、country_id
、content
、address
。
完了までの作業を定義する
- フォームで新しい絵ハガキを作成する(だいたいおわかりですね)
- 住所、市町村、郵便番号、コンテンツ、国のバリデーションを行う
- 郵便番号フォーマットのバリデーションを行う
- コンテンツの長さのバリデーションを行う(ツィートやテキストメッセージ並に短くしたい場合)
- 選択した国で州名が必要な場合、州名の存在のバリデーションも必要
属性と型
まずは属性の定義から行います。Form ObjectはDry::Types::Struct
から継承する必要があります。必要なゲッターやコンストラクタはDryで定義されます。
class Postcard
module Types
include Dry::Types.module
end
class CreateForm < Dry::Types::Struct
attribute :address, Types::Coercible::String
attribute :city, Types::Coercible::String
attribute :zip_code, Types::Coercible::String
attribute :content, Types::Coercible::String
end
end
Dry::Types.module
をinclude
するだけでDry-typesの型を使えるようになります。Dry-typesでは変更に応じた多くのプリミティブ型を選択できます。
Railsモデルを使う場合はもう少し複雑です。これらの型で属性を作成するには、型を登録する必要があり、TypeName = Dry::Types::Definition.new(::MyRubyClass)
のように行います。.constructor
をブロック付きで呼び出すと、dry-typesで構成される型を指定できます。
定義は以下のような感じになります。
module Types
include Dry::Types.module
Country = Dry::Types::Definition.new(::Country)
CountryState = Dry::Types::Definition.new(::Country::State)
end
これで、Country
とCountryState
を型として使えるようになりました。最終的なフォームの定義は次のようになります。
class CreateForm < Dry::Types::Struct
attribute :address, Types::Coercible::String
attribute :city, Types::Coercible::String
attribute :zip_code, Types::Coercible::String
attribute :content, Types::Coercible::String
attribute :country, Types::Country
attribute :state, Types::CountryState
end
これでやっと、シンプルなstructを作成できました。
メモ: dry-typesのstructコンストラクタについて
コンストラクタの種類を指定しないと、strictコンストラクタが生成されます。この場合、属性が見つからないとArgumentError
をスローします。存在のバリデーションはdry-validationで行うので、より多くの情報を含むコンストラクタであるschemaコンストラクタやsymbolizedコンストラクタを使うことになります。schemaコンストラクタを使うには、クラス本体の中でconstructor_type(:schema)
を呼ぶ必要があります。
バリデーション
Form Object内部でバリデーションを実行するには、dry-validation gemを使います。これにはさまざまな述語(メソッド)が含まれており、使い方も簡単です。まずは存在のバリデーションを行ってみましょう。
PostcardSchema = Dry::Validation.Schema do
required(:address).filled
required(:city).filled
required(:zip_code).filled
required(:content).filled
required(:country).filled
end
先ほど定義したモデルの属性を渡すスキーマを次のように定義します。
errors = PostcardSchema.call(to_hash).messages(full: true)
それではこの動作を見てみましょう。
to_hash
(またはto_h
): 属性をハッシュベースで生成する.messages(full: true)
: 完全なエラーメッセージを返す。
フォーマットや長さなど、渡すバリデーションの要件を増やすには、単に.filled
メソッドにパラメータを渡します。content
を例に取ると、存在バリデーションの他に、20文字より長いこともバリデーションされます。
required(:content).filled(min_size?: 20)
利用できる述語の全リストはこちらをご覧ください。
バリデーションロジックがさらに複雑な場合
存在や長さのバリデーション機能はdry-validationによって提供されます。残念なことに(?)、実際に動くアプリではこれだけでは不十分です。そのため、dry-validationで独自の述語を書けるようになっています。
まずは簡単なものから。バリデーションに渡されたcountry
にstate
が必要な場合は以下のように書きます。
PostcardSchema = Dry::Validation.Schema do
configure do
config.messages_file = Rails.root.join('config/locales/errors.yml')
def state_required?(country)
country.is_state_required
end
end
# (...)
end
このとおり簡単です。errors.yml
に正しいエラーメッセージを書いておくのをお忘れなく。エラーファイルについて詳しくはこちらをどうぞ。
次はいよいよ、country
で必要になった場合にのみstate
の存在をチェックしましょう。state
が存在するかどうかをバリデーションに伝える必要があります。これは、スキーマに以下の行を書くだけでできます。
required(:state).maybe
ルール自体を定義する
ルール自体は次のように定義します。
rule(country_requires_state: [:country, :state]) do |country, state|
country.state_required? > state.filled?
end
これも見てのとおり簡単です。
- ルール内で必要となるフィールドに沿ったルール名を渡します。ここではcountryとstateを使います。
- これらの変数はブロックに
yield
されます。 - 「stateが必要な場合は、stateの存在をチェックする」というようにルールが変換されます。
より高度なルールについて詳しくはこちらをどうぞ。
完成したForm Object
class Postcard
module Types
include Dry::Types.module
Country = Dry::Types::Definition
.new(::Country)
CountryState = Dry::Types::Definition
.new(::Country::State)
end
class CreateForm < Dry::Types::Struct
constructor_type(:schema)
ZIP_CODE_FORMAT = /\d{5}/
MINIMAL_CONTENT_LENGTH = 20
attribute :address, Types::Coercible::String
attribute :city, Types::Coercible::String
attribute :zip_code, Types::Coercible::String
attribute :content, Types::Coercible::String
attribute :country, Types::Country
attribute :state, Types::CountryState
def save!
errors = PostcardSchema.call(to_hash).messages(full: true)
raise CommandValidationFailed, errors if errors.present?
Postcard.create!(to_hash)
end
private
PostcardSchema = Dry::Validation.Schema do
configure do
config.messages_file = Rails.root.join('config/locales/errors.yml')
def state_required?(country)
country.is_state_required
end
end
required(:address).filled
required(:city).filled
required(:zip_code).filled(format?: ZIP_CODE_FORMAT)
required(:content).filled(min_size?: MINIMAL_CONTENT_LENGTH)
required(:state).maybe
required(:country).filled
rule(country_requires_state: [:country, :state]) do |country, state|
country.state_required? > state.filled?
end
end
end
end
モデルやspecを含む完全なプロジェクトは私のGitHubに公開してあります。
適用できるリファクタリング
記事を読みやすくするため、私はオブジェクト自身に関連するものをすべてひとつのファイルに書きました。このような書き方は、おそらく実際のアプリにおけるコードベースの編成法として最適ではありません。次のリファクタリングが考えられます。
Types
モジュールを別のモジュールに配置する(場合によってはグローバルスコープに)PostcardSchema
はForm Objectの外部に配置し、UpdateForm
などでも使うZIP_CODE_FORMAT
やMINIMAL_CONTENT_LENGTH
についても同様
まとめ
dry-typesを使うと、アプリで型安全なコンポーネントを書けるようになります。このライブラリでは多数の型が利用可能で、独自定義も簡単です。
私にとって、dry-validationによるアプローチはActiveModelを使ったものよりも明快に感じられます。バリデーションロジックをすべて明確に区切られた場所に集められます。これらのバリデーションは他のフォーム(UpdateFor
など)での再利用も簡単です。
dry-rbシリーズの最大の問題は(ROMやRodaにも同種の問題があるのですが)、初めてのユーザーが簡単に使えるようなドキュメントがないことです。信じていただけるかどうかはともかく、私はこのForm Objectの作成に2時間かかりました。原因のほとんどは、ドキュメントの問題と、ブログ記事がないことです。本記事が皆さまの2時間を節約するのに役立てばと願っています。