dry-rb/dry-structメモ

2021/08/14時点、v1.4.0で確認。

参照:https://dry-rb.org/gems/dry-struct/master/

dry-structの導入

gem 'dry-struct'

dry-typesはdry-structが依存しているので、自動的にインストールされる。

基本的な使い方

# dry-typesを導入
module Types
  include Dry.Types()
end

class Book < Dry::Struct
  attribute :name, Types::String
  attribute :price, Types::Coercible::Integer

  def to_s
    "タイトル:#{name}/値段:#{price}円"
  end
end

book = Book.new(name: 'よくわかるRuby', price: 2800)
book.name # => "よくわかるRuby"
book.to_s # => "タイトル:よくわかるRuby/値段:2800円"

attribute 属性名, 型で属性を宣言する。型の性質についてはdry-typesを参照。

ValueObject的な振る舞いをする

dry-structの属性は変更する方法がない。厳密にはbook.attributes[:price] = 100とすれば変更は可能ではあるが動作保証外のやり方だろう。

等価比較もできる。

book = Book.new(name: 'よくわかるRuby', price: 2800)
book2 = Book.new(name: 'よくわかるRuby', price: 2800)
book3 = Book.new(name: 'よくわからないRuby', price: 2800)
book == book2 # => true
book == book3 # => false

シンボル以外のキーを受け取る

デフォルトではコンストラクタの引数はキーがシンボルのハッシュしか受け付けない。

Book.new('name' => 'よくわかるRuby', 'price' => 2800)
# => Dry::Struct::Error ([Book.new] :name is missing in Hash input)

しかし、transform_keys(&:to_sym)を追加すると問題なくインスタンス化できるようになる。

class Book < Dry::Struct
  transform_keys(&:to_sym)
  attribute :name, Types::String
  attribute :price, Types::Coercible::Integer
end

# 文字列key
Book.new('name' => 'よくわかるRuby', 'price' => 2800)
# => #<Book name="よくわかるRuby" price=2800>
# シンボルkey
Book.new(name: 'よくわかるRuby', price: 2800)
# => #<Book name="よくわかるRuby" price=2800>

こうすると、デフォルトでは渡せなかったControllerのparamsも渡すことができるようになる。

# ControllerのparamsはActionController::Parametersのインスタンス
params = ActionController::Parameters.new(name: 'よくわかるRuby', price: 2800)
# デフォルトではエラーになるが`transform_keys(&:to_sym)`を
# 指定しているのでインスタンス化できる
Book.new(params.permit(:name, :price))
# => #<Book name="よくわかるRuby" price=2800>

余分なキーを受け入れるかどうか

デフォルトでは余分なキーが指定されていたとしても単に無視される。

Book.new(name: 'よくわかるRuby', price: 2800, publisher: 'ABC出版')
# => #<Book name="よくわかるRuby" price=2800>

以下のようにschema schema.strictを使うと余分なキーが指定されていた場合、例外が返されるようになる。

class Book < Dry::Struct
  schema schema.strict
  attribute :name, Types::String
  attribute :price, Types::Coercible::Integer
end

Book.new(name: 'よくわかるRuby', price: 2800, publisher: 'ABC出版')
# => Dry::Struct::Error ([Book.new] unexpected keys [:publisher] in Hash input)

属性の欠損を受け入れるかどうか

デフォルトではすべての属性についてコンストラクタで指定しなければならない。

Book.new(name: 'よくわかるRuby')
# => Dry::Struct::Error ([Book.new] :price is missing in Hash input)

以下のように、?をつけると未指定も許容されるようになる。

class Book < Dry::Struct
  # 属性名に?をつけるケース。
  # ドキュメントには載っていないので`attribute?`の方がよいだろう
  attribute :name?, Types::String
  # `attribute?`を使うケース。ドキュメントに記載されている方法
  attribute? :price, Types::Coercible::Integer
end
Book.new(name: 'よくわかるRuby') # => #<Book name="よくわかるRuby" price=nil>
Book.new(price: 2800) # => #<Book name=nil price=2800>
Book.new # => #<Book name=nil price=nil>

デフォルト値をつける

以下のようにするとデフォルト値を指定できる。

class Book < Dry::Struct
  attribute :name, Types::String.default('名称未定'.freeze)
  attribute :price, Types::Coercible::Integer
end
Book.new(price: 2800) # => #<Book name="名称未定" price=2800>
# しかし、nilを指定された場合はデフォルト値にはならない
Book.new(price: 2800, name: nil)
# => Dry::Struct::Error ([Book.new] nil (NilClass) has invalid type for :name violates constraints (type?(String, nil) failed))

もし、nilが指定された場合にもデフォルト値に置き換えたい場合は、transform_typesを使った方法で対処しなければいけない。

ネスト

構造体をネストさせることができる。

class Book < Dry::Struct
  attribute :name, Types::String
  attribute :price, Types::Coercible::Integer
  attribute :author do
    attribute :first_name, Types::String
    attribute :last_name, Types::String
  end
end

book = Book.new(name: 'よくわかるRuby', price: 2800, author: { first_name: '太郎', last_name: '山田' })
# => #<Book name="よくわかるRuby" price=2800 author=#<Book::Author first_name="太郎" last_name="山田">>
book.author # => #<Book::Author first_name="太郎" last_name="山田">

デフォルトではネストした属性(上記ならauthor)のクラス(上記ならBook::Author)はDry::Structを継承したものになる。別のクラスを継承させたい場合、attributeの第二引数に指定する。

class Book < Dry::Struct
  attribute :author, MyStruct do
    # ...
  end
end

ネストした構造体は配列にすることもできる。

class Book < Dry::Struct
  attribute :name, Types::String
  attribute :price, Types::Coercible::Integer
  attribute :authors, Types::Array do
    attribute :first_name, Types::String
    attribute :last_name, Types::String
  end
end
Book.new(name: 'よくわかるRuby', price: 2800, authors: [{ first_name: '太郎', last_name: '山田' }])
# => #<Book name="よくわかるRuby" price=2800 authors=[#<Book::Author first_name="太郎" last_name="山田">]>

ネストした構造体のデフォルト値

attributeメソッドのネストで表現することはできず、以下のようにしなければならない。

class Book < Dry::Struct
  attribute :name, Types::String
  attribute :price, Types::Coercible::Integer
  class Author < Dry::Struct
    attribute :first_name, Types::String
    attribute :last_name, Types::String
  end
  attribute :author, Author.default { Author.new(first_name: '権兵衛', last_name: '名無しの') }
end
Book.new(name: 'よくわかるRuby', price: 2800)
# => #<Book name="よくわかるRuby" price=2800 author=#<Book::Author first_name="権兵衛" last_name="名無しの">>

構造体の合成

他の構造体で定義されている属性をあたかも自分の構造体で定義された属性であるかのように合成することができる。

class Product < Dry::Struct
  attribute :name, Types::String
  attribute :price, Types::Coercible::Integer
end

class Name < Dry::Struct
  attribute :first_name, Types::String
  attribute :last_name, Types::String
end

class Book < Dry::Struct
  # Product構造体から属性を合成
  attributes_from Product
  attribute :author do
    # ネストした構造体でも合成できる
    attributes_from Name
  end
end

Book.new(name: 'よくわかるRuby', price: 2800, author: { first_name: '太郎', last_name: '山田' })
# => #<Book name="よくわかるRuby" price=2800 author=#<Book::Author first_name="太郎" last_name="山田">>

ベースクラスを作っておく

class BaseStruct < Dry::Struct
  # 文字列キーでもエラーにならないようにする
  transform_keys(&:to_sym)
  # 余計なキー指定を拒否する
  schema schema.strict

  module Types
    include Dry.Types()
  end

  # include NilToDefault
  # の形で使う
  # 値にnilが指定された場合、
  # default指定がある属性ならデフォルト値に置き換える
  module NilToDefault
    def self.included(mod)
      mod.transform_types do |type|
        if type.default?
          type.constructor do |value|
            value.nil? ? Dry::Types::Undefined : value
          end
        else
          type
        end
      end
    end
  end
end

class Book < BaseStruct
  attribute :name, Types::String
  # ...
end