Mongoid での set の挙動を整理する

Mongoid が提供する set メソッドの挙動を整理する。

# set はバリデーションをチェックしない

```rb:app/models/item.rb
class Item
  include Mongoid::Document

  field :name
  field :status

  validates_inclusion_of :status, in: ['open', 'hidden']
end
```

上記のように `status` に `open`, `hidden` のどちらかが入ることを前提とする。
このバリデーションは以下のように保存時にチェックされる。

```rb
irb(main):001:0> item = Item.find_by(name: 'item01')
MONGODB | [7] localhost:28001 #1 | sandbox.find | STARTED | {"find"=>"items", "filter"=>{"name"=>"item01"}, "lsid"=>{"id"=><BSON::Binary:0x70200774064200 type=uuid data=0x83b34ce665194b82...>}}
MONGODB | [7] localhost:28001 | sandbox.find | SUCCEEDED | 0.002s
=> #<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "open">
irb(main):002:0> item.status = 'closed'
=> "closed"
irb(main):003:0> item.save
=> false
irb(main):004:0> item.errors
=> #<ActiveModel::Errors:0x00007fb198914640 @base=#<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "closed">, @messages={:status=>["is not included in the list"]}, @details={:status=>[{:error=>:inclusion, :value=>"closed"}]}>
```

代入して`save` する代わりに `set` を使って即座に変更を反映しようとすると以下のようにバリデーションエラーにならずに保存される。

```rb
irb(main):005:0> item = Item.find_by(name: 'item01')
MONGODB | [8] localhost:28001 #1 | sandbox.find | STARTED | {"find"=>"items", "filter"=>{"name"=>"item01"}, "lsid"=>{"id"=><BSON::Binary:0x70200774064200 type=uuid data=0x83b34ce665194b82...>}}
MONGODB | [8] localhost:28001 | sandbox.find | SUCCEEDED | 0.002s
=> #<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "open">
irb(main):006:0> item.set(status: 'closed')
MONGODB | [9] localhost:28001 #1 | sandbox.update | STARTED | {"update"=>"items", "ordered"=>true, "updates"=>[{"q"=>{"_id"=>BSON::ObjectId('5df5288495243d5498999e2a')}, "u"=>{"$set"=>{"status"=>"closed"}}}], "lsid"=>{"id"=><BSON::Binary:0x70200774064200 type=uuid data=0x83b34ce665194b82...>}}
MONGODB | [9] localhost:28001 | sandbox.update | SUCCEEDED | 0.002s
=> #<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "closed">
irb(main):007:0> Item.find_by(name: 'item01')
MONGODB | [10] localhost:28001 #1 | sandbox.find | STARTED | {"find"=>"items", "filter"=>{"name"=>"item01"}, "lsid"=>{"id"=><BSON::Binary:0x70200774064200 type=uuid data=0x83b34ce665194b82...>}}
MONGODB | [10] localhost:28001 | sandbox.find | SUCCEEDED | 0.002s
=> #<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "closed">

```

# set はコールバックを呼ばない

`item.rb` に `after_save` を追加する。

```rb:app/models/item.rb
class Item
  include Mongoid::Document

  field :name
  field :status

  validates_inclusion_of :status, in: ['open', 'hidden']
  after_save { |d| p "#{d.name}, #{d.status}" }
end
```

先程と同様に値をセットして更新すると以下のように保存後に `after_save` が呼ばれる。

```rb
irb(main):001:0> item = Item.find_by(name: 'item01')
MONGODB | [7] localhost:28001 #1 | sandbox.find | STARTED | {"find"=>"items", "filter"=>{"name"=>"item01"}, "lsid"=>{"id"=><BSON::Binary:0x70200973293780 type=uuid data=0x1b1ac6692cff4cd9...>}}
MONGODB | [7] localhost:28001 | sandbox.find | SUCCEEDED | 0.002s
=> #<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "open">
irb(main):002:0> item.status = 'hidden'
=> "hidden"
irb(main):003:0> item.save
MONGODB | [8] localhost:28001 #1 | sandbox.update | STARTED | {"update"=>"items", "ordered"=>true, "updates"=>[{"q"=>{"_id"=>BSON::ObjectId('5df5288495243d5498999e2a')}, "u"=>{"$set"=>{"status"=>"hidden"}}}], "lsid"=>{"id"=><BSON::Binary:0x70200973293780 type=uuid data=0x1b1ac6692cff4cd9...>}}
MONGODB | [8] localhost:28001 | sandbox.update | SUCCEEDED | 0.002s
"item01, hidden"
=> true
```

`set` を使って保存をすると `after_save` は呼ばれない。

```rb
irb(main):004:0> item = Item.find_by(name: 'item01')
MONGODB | [9] localhost:28001 #1 | sandbox.find | STARTED | {"find"=>"items", "filter"=>{"name"=>"item01"}, "lsid"=>{"id"=><BSON::Binary:0x70200973293780 type=uuid data=0x1b1ac6692cff4cd9...>}}
MONGODB | [9] localhost:28001 | sandbox.find | SUCCEEDED | 0.002s
=> #<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "hidden">
irb(main):005:0> item.set(status: 'open')
MONGODB | [10] localhost:28001 #1 | sandbox.update | STARTED | {"update"=>"items", "ordered"=>true, "updates"=>[{"q"=>{"_id"=>BSON::ObjectId('5df5288495243d5498999e2a')}, "u"=>{"$set"=>{"status"=>"open"}}}], "lsid"=>{"id"=><BSON::Binary:0x70200973293780 type=uuid data=0x1b1ac6692cff4cd9...>}}
MONGODB | [10] localhost:28001 | sandbox.update | SUCCEEDED | 0.002s
=> #<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "open">
```

# set でリレーションに保存

## has_one/has_many

以下のようなモデルを想定する。

```rb:app/models/item.rb
class Item
  include Mongoid::Document

  field :name
  field :status

  has_one :manufacturer

  validates_inclusion_of :status, in: ['open', 'hidden']
  after_save { |d| p "#{d.name}, #{d.status}" }
end
```

```rb:app/models/manufacturer.rb
class Manufacturer
  include Mongoid::Document

  field :name
  field :country

  belongs_to :item

  validates_inclusion_of :country, in: ['japan']
  after_save { |d| p "manufacturer: #{d.name}, #{d.country}" }
end
```

### has_one のフィールドに対して set する

以下のように ``undefined method `_association'`` で NoMethodError になる。

```rb
irb(main):001:0> item = Item.find_by(name: 'item01')
MONGODB | [7] localhost:28001 #1 | sandbox.find | STARTED | {"find"=>"items", "filter"=>{"name"=>"item01"}, "lsid"=>{"id"=><BSON::Binary:0x70200973615600 type=uuid data=0x9c51763e627b42a7...>}}
MONGODB | [7] localhost:28001 | sandbox.find | SUCCEEDED | 0.002s
=> #<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "open">
irb(main):002:0> item.set('manufacturer.country' => 'canada')
MONGODB | [8] localhost:28001 #1 | sandbox.find | STARTED | {"find"=>"manufacturers", "filter"=>{"item_id"=>BSON::ObjectId('5df5288495243d5498999e2a')}, "limit"=>1, "singleBatch"=>true, "lsid"=>{"id"=><BSON::Binary:0x70200973615600 type=uuid data=0x9c51763e627b42a7...>}}
MONGODB | [8] localhost:28001 | sandbox.find | SUCCEEDED | 0.002s
Traceback (most recent call last):
        1: from (irb):3
NoMethodError (undefined method `_association' for {"country"=>"canada"}:Hash)
```

### has_one そのものを set する

`Manufacturer.new` した結果をセットすれば通る。
ただし、この場合別途 `item.save` しなければ `item.manufacturer` の結果は変わらない（これについては別記事で整理する）。
また、 Manufacturer 側のバリデーションが走るが、その結果がエラーの場合でも set はエラーを出力しない。

```rb
irb(main):003:0> item.set(manufacturer: {name: 'creator2', country: 'japan'})
Traceback (most recent call last):
        2: from (irb):4
        1: from (irb):4:in rescue in irb_binding'
NoMethodError (undefined method `_association' for {:name=>"creator2", :country=>"japan"}:Hash)
irb(main):004:0> item.set(manufacturer: Manufacturer.new(name: 'creator2', country: 'japan'))
MONGODB | [9] localhost:28001 #1 | sandbox.insert | STARTED | {"insert"=>"manufacturers", "ordered"=>true, "documents"=>[{"_id"=>BSON::ObjectId('5df7360f21b62043a2b04334'), "name"=>"creator2", "country"=>"japan", "item_id"=>BSON::ObjectId('5df5288495243d5498999e2a')}], "lsid"=>{"id"=><BSON::Binary:0x702009736156...
MONGODB | [9] localhost:28001 | sandbox.insert | SUCCEEDED | 0.002s
"manufacturer: creator2, japan"
=> #<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "open">
irb(main):007:0> item.set(manufacturer: Manufacturer.new(name: 'creator3', country: 'canada')) # エラーが出ていないが Manufacturer の保存に失敗している
=> #<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "open">
```

### has_one のフィールドに紐づく変数経由で set する

この方法は本質的には最初に説明したのと同じ手順であり、バリデーションチェックも走らずコールバックも呼ばれず、保存される。

```rb
irb(main):001:0> item = Item.find_by(name: 'item01')
MONGODB | [7] localhost:28001 #1 | sandbox.find | STARTED | {"find"=>"items", "filter"=>{"name"=>"item01"}, "lsid"=>{"id"=><BSON::Binary:0x70200374638340 type=uuid data=0x241a5c9ba6d748d7...>}}
MONGODB | [7] localhost:28001 | sandbox.find | SUCCEEDED | 0.002s
=> #<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "open">
irb(main):002:0> item.manufacturer
MONGODB | [8] localhost:28001 #1 | sandbox.find | STARTED | {"find"=>"manufacturers", "filter"=>{"item_id"=>BSON::ObjectId('5df5288495243d5498999e2a')}, "limit"=>1, "singleBatch"=>true, "lsid"=>{"id"=><BSON::Binary:0x70200374638340 type=uuid data=0x241a5c9ba6d748d7...>}}
MONGODB | [8] localhost:28001 | sandbox.find | SUCCEEDED | 0.002s
=> #<Manufacturer _id: 5df6ff5921b62081adb04334, name: "creator", country: "japan", item_id: BSON::ObjectId('5df5288495243d5498999e2a')>
irb(main):003:0> item.manufacturer.set(country: 'canada')
MONGODB | [9] localhost:28001 #1 | sandbox.update | STARTED | {"update"=>"manufacturers", "ordered"=>true, "updates"=>[{"q"=>{"_id"=>BSON::ObjectId('5df6ff5921b62081adb04334')}, "u"=>{"$set"=>{"country"=>"canada"}}}], "lsid"=>{"id"=><BSON::Binary:0x70200374638340 type=uuid data=0x241a5c9ba6d748d7...>}}
MONGODB | [9] localhost:28001 | sandbox.update | SUCCEEDED | 0.002s
=> #<Manufacturer _id: 5df6ff5921b62081adb04334, name: "creator", country: "canada", item_id: BSON::ObjectId('5df5288495243d5498999e2a')>
irb(main):004:0> item.reload
MONGODB | [10] localhost:28001 #1 | sandbox.find | STARTED | {"find"=>"items", "filter"=>{"_id"=>BSON::ObjectId('5df5288495243d5498999e2a')}, "lsid"=>{"id"=><BSON::Binary:0x70200374638340 type=uuid data=0x241a5c9ba6d748d7...>}}
MONGODB | [10] localhost:28001 | sandbox.find | SUCCEEDED | 0.002s
=> #<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "open">
irb(main):005:0> item.manufacturer
MONGODB | [11] localhost:28001 #1 | sandbox.find | STARTED | {"find"=>"manufacturers", "filter"=>{"item_id"=>BSON::ObjectId('5df5288495243d5498999e2a')}, "limit"=>1, "singleBatch"=>true, "lsid"=>{"id"=><BSON::Binary:0x70200374638340 type=uuid data=0x241a5c9ba6d748d7...>}}
MONGODB | [11] localhost:28001 | sandbox.find | SUCCEEDED | 0.002s
=> #<Manufacturer _id: 5df6ff5921b62081adb04334, name: "creator", country: "canada", item_id: BSON::ObjectId('5df5288495243d5498999e2a')>

```

## embeds_one/embeds_many

以下のようなモデルを想定する。

```rb:app/models/item.rb
class Item
  include Mongoid::Document

  field :name
  field :status

  embeds_one :manufacturer

  validates_inclusion_of :status, in: ['open', 'hidden']
  after_save { |d| p "#{d.name}, #{d.status}" }
end
```

```rb:app/models/manufacturer.rb
class Manufacturer
  include Mongoid::Document

  field :name
  field :country

  embedded_in :item

  validates_inclusion_of :country, in: ['japan']
  after_save { |d| p "manufacturer: #{d.name}, #{d.country}" }
end
```

### embeds_one のフィールドに対して set する

これはエラーにならない。

```rb
irb(main):001:0> item = Item.find_by(name: 'item01')
MONGODB | [7] localhost:28001 #1 | sandbox.find | STARTED | {"find"=>"items", "filter"=>{"name"=>"item01"}, "lsid"=>{"id"=><BSON::Binary:0x70200373029300 type=uuid data=0x049d23d448434f31...>}}
MONGODB | [7] localhost:28001 | sandbox.find | SUCCEEDED | 0.002s
=> #<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "open">
irb(main):002:0> item.set('manufacturer.name' => 'creator02')
MONGODB | [8] localhost:28001 #1 | sandbox.update | STARTED | {"update"=>"items", "ordered"=>true, "updates"=>[{"q"=>{"_id"=>BSON::ObjectId('5df5288495243d5498999e2a')}, "u"=>{"$set"=>{"manufacturer"=>{"_id"=>BSON::ObjectId('5df747b521b6202786b04334'), "name"=>"creator02", "country"=>"japan"}}}}], "lsid"=>{"id"=...
MONGODB | [8] localhost:28001 | sandbox.update | SUCCEEDED | 0.002s
"manufacturer: creator02, japan"
=> #<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "open">
```

が、この方法の利用には注意が必要である。
なぜなら同じ方法を2回以上使おうとするとエラーになるためだ。
`reload` すれば問題は解消するが、そもそもこの方法で更新をするべきではなさそう。

```rb
irb(main):003:0> item.set('manufacturer.name' => 'creator03')
Traceback (most recent call last):
        1: from (irb):3
FrozenError (can't modify frozen BSON::Document)
irb(main):004:0> item.reload
MONGODB | [9] localhost:28001 #1 | sandbox.find | STARTED | {"find"=>"items", "filter"=>{"_id"=>BSON::ObjectId('5df5288495243d5498999e2a')}, "lsid"=>{"id"=><BSON::Binary:0x70200372631360 type=uuid data=0xd67817a79db44896...>}}
MONGODB | [9] localhost:28001 | sandbox.find | SUCCEEDED | 0.002s
=> #<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "open">
irb(main):005:0> item.set('manufacturer.name' => 'creator04')
MONGODB | [10] localhost:28001 #1 | sandbox.update | STARTED | {"update"=>"items", "ordered"=>true, "updates"=>[{"q"=>{"_id"=>BSON::ObjectId('5df5288495243d5498999e2a')}, "u"=>{"$set"=>{"manufacturer"=>{"_id"=>BSON::ObjectId('5df747b521b6202786b04334'), "name"=>"creator04", "country"=>"japan"}}}}], "lsid"=>{"id"=...
MONGODB | [10] localhost:28001 | sandbox.update | SUCCEEDED | 0.002s
"manufacturer: creator04, japan"
=> #<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "open">
```

ちなみにこの場合でもバリデーションはチェックされる。

```rb
irb(main):001:0> item = Item.find_by(name: 'item01')
MONGODB | [7] localhost:28001 #1 | sandbox.find | STARTED | {"find"=>"items", "filter"=>{"name"=>"item01"}, "lsid"=>{"id"=><BSON::Binary:0x70200772647620 type=uuid data=0x1f23c2846fb44ee3...>}}
MONGODB | [7] localhost:28001 | sandbox.find | SUCCEEDED | 0.001s
=> #<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "open">
irb(main):002:0> item.set('manufacturer.country' => 'canada')
=> #<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "open">
irb(main):003:0> item.manufacturer.errors
=> #<ActiveModel::Errors:0x00007fb1a808c6e8 @base=#<Manufacturer _id: 5df747b521b6202786b04334, name: "creator", country: "canada">, @messages={:country=>["is not included in the list"]}, @details={:country=>[{:error=>:inclusion, :value=>"canada"}]}>
```



### embeds_one そのものを set する

has_one と違って hash でも set できる。
Manufacturer のコールバックは呼ばれているしバリデーションもチェックされる。

```rb
irb(main):001:0> item = Item.find_by(name: 'item01')
MONGODB | [7] localhost:28001 #1 | sandbox.find | STARTED | {"find"=>"items", "filter"=>{"name"=>"item01"}, "lsid"=>{"id"=><BSON::Binary:0x70200372582840 type=uuid data=0x8193f419dbb54950...>}}
MONGODB | [7] localhost:28001 | sandbox.find | SUCCEEDED | 0.003s
=> #<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "open">
irb(main):002:0> item.set(manufacturer: {name: 'creator02', country: 'japan'})
MONGODB | [8] localhost:28001 #1 | sandbox.update | STARTED | {"update"=>"items", "ordered"=>true, "updates"=>[{"q"=>{"_id"=>BSON::ObjectId('5df5288495243d5498999e2a')}, "u"=>{"$set"=>{"manufacturer"=>{"_id"=>BSON::ObjectId('5df74aaa21b6204c99b04334'), "name"=>"creator02", "country"=>"japan"}}}}], "lsid"=>{"id"=...
MONGODB | [8] localhost:28001 | sandbox.update | SUCCEEDED | 0.002s
"manufacturer: creator02, japan"
=> #<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "open">
irb(main):003:0> item.set(manufacturer: {name: 'creator03', country: 'japan'})
MONGODB | [9] localhost:28001 #1 | sandbox.update | STARTED | {"update"=>"items", "ordered"=>true, "updates"=>[{"q"=>{"_id"=>BSON::ObjectId('5df5288495243d5498999e2a')}, "u"=>{"$set"=>{"manufacturer"=>{"_id"=>BSON::ObjectId('5df74ab921b6204c99b04335'), "name"=>"creator03", "country"=>"japan"}}}}], "lsid"=>{"id"=...
MONGODB | [9] localhost:28001 | sandbox.update | SUCCEEDED | 0.003s
"manufacturer: creator03, japan"
=> #<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "open">
irb(main):004:0> item.set(manufacturer: {name: 'creator04', country: 'canada'})
=> #<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "open">
irb(main):005:0> item.manufacturer.errors
=> #<ActiveModel::Errors:0x00007fb198a752c8 @base=#<Manufacturer _id: 5df74ad921b6204c99b04336, name: "creator04", country: "canada">, @messages={:country=>["is not included in the list"]}, @details={:country=>[{:error=>:inclusion, :value=>"canada"}]}>
```

もちろん Manufacturer のインスタンスを代入して set もできる。

```rb
irb(main):006:0> item.set(manufacturer: Manufacturer.new(name: 'creator05', country: 'japan'))
MONGODB | [10] localhost:28001 #1 | sandbox.update | STARTED | {"update"=>"items", "ordered"=>true, "updates"=>[{"q"=>{"_id"=>BSON::ObjectId('5df5288495243d5498999e2a')}, "u"=>{"$set"=>{"manufacturer"=>{"_id"=>BSON::ObjectId('5df74b6c21b6204c99b04337'), "name"=>"creator05", "country"=>"japan"}}}}], "lsid"=>{"id"=...
MONGODB | [10] localhost:28001 | sandbox.update | SUCCEEDED | 0.002s
"manufacturer: creator05, japan"
=> #<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "open">
```

### embeds_one のフィールドに紐づく変数経由で set する

ここまでの流れから自明かとは思うが、これはもちろん通る。

```rb
irb(main):001:0> item = Item.find_by(name: 'item01')
MONGODB | [7] localhost:28001 #1 | sandbox.find | STARTED | {"find"=>"items", "filter"=>{"name"=>"item01"}, "lsid"=>{"id"=><BSON::Binary:0x70200375178620 type=uuid data=0xbf7b298badda4b22...>}}
MONGODB | [7] localhost:28001 | sandbox.find | SUCCEEDED | 0.001s
=> #<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "open">
irb(main):002:0> item.manufacturer
=> #<Manufacturer _id: 5df74b6c21b6204c99b04337, name: "creator", country: "japan">
irb(main):003:0> item.manufacturer.set(country: 'canada')
MONGODB | [8] localhost:28001 #1 | sandbox.update | STARTED | {"update"=>"items", "ordered"=>true, "updates"=>[{"q"=>{"_id"=>BSON::ObjectId('5df5288495243d5498999e2a'), "manufacturer._id"=>BSON::ObjectId('5df74b6c21b6204c99b04337')}, "u"=>{"$set"=>{"manufacturer.country"=>"canada"}}}], "lsid"=>{"id"=><BSON::Bina...
MONGODB | [8] localhost:28001 | sandbox.update | SUCCEEDED | 0.002s
=> #<Manufacturer _id: 5df74b6c21b6204c99b04337, name: "creator", country: "canada">
irb(main):004:0> item.reload
MONGODB | [9] localhost:28001 #1 | sandbox.find | STARTED | {"find"=>"items", "filter"=>{"_id"=>BSON::ObjectId('5df5288495243d5498999e2a')}, "lsid"=>{"id"=><BSON::Binary:0x70200375178620 type=uuid data=0xbf7b298badda4b22...>}}
MONGODB | [9] localhost:28001 | sandbox.find | SUCCEEDED | 0.002s
=> #<Item _id: 5df5288495243d5498999e2a, name: "item01", status: "open">
irb(main):005:0> item.manufacturer
=> #<Manufacturer _id: 5df74b6c21b6204c99b04337, name: "creator", country: "canada">
```

# まとめ

- set を使ってフィールドを変更すると、バリデーションは検証されないしコールバックは呼ばれない
- has/embeds なフィールドに対しての set はやり方によってはできる
    - 多くのケースではバリデーションは検証されるしコールバックも呼ばれる