概要
Imageに1:1で紐付くNoteレコードがあるような状態で、意図せず同一のImageに対してcreate_noteを二回叩いてしまった。
すると、例外発生して、Imageに紐付くNoteがなくなってしまった・・・。
これはほんの一例。
ActiveModelの制約によって、create_xxxを2回叩いた時の挙動が変わります。
みなさんも知らないうちにレコード消されたり、二重登録されてたりしませんか?
そうならないためにも、制約と挙動のセットをこの機会に覚えましょう!
まとめ
create_xxxを2回叩いたときの挙動
最初にまとめておくと
- uniqueness制約があると、2回目で作成するレコードの保存に失敗(false)する
- dependent destroyがあると、1回目で作成したレコードが削除される
- dependent destroyがない場合、外部キーにnot null制約があると、例外が発生する
- presence制約 : ActiveRecord::RecordNotFound
- DBのNOT NULL制約 : ActiveRecord::StatementInvalid
- 保存に成功するかどうかは前述のunqueness制約の有無による
以上の組合わせで、create_xxx1回目、2回目で作成されたレコードがどうなるかが決まる。
詳細
前提
基本は以下のModelとMigration。
class Image < ActiveRecord::Base
has_one :note
end
class Note < ActiveRecord::Base
attr_accessible :image_id
belongs_to :image
end
class CreateImages < ActiveRecord::Migration
def change
create_table :images do |t|
end
end
end
class CreateNotes < ActiveRecord::Migration
def change
create_table :notes do |t|
t.integer :image_id
end
end
実験
条件
以下の5つの条件を組み合わせる (計2^5=32通り)
- has_one :note, :dependent => :destroy # image.rb
- t.integer :image_id, :null => false # create_notes.rb (Migrationファイル)
- validates :image_id, :presence => true # note.rb
- validates :image_id, :uniqueness => true # note.rb
- acts_as_paranoid # note.rb (deleted_atカラムも追加)
内容
上記条件で以下のcreate_duplicate_note.rbを実行する。
この時の2回目のcreate_noteの挙動や、各インスタンス、DBの状況がどうなるかを調べる。
image = Image.create #=> #<Image id: 1>
note1 = image.create_note #=> #<Note id: 1, image_id: 1>
note2 = image.create_note #=> ???
note1 #=> ???
note2 #=> ???
image.note #=> ???
Note.all #=> [ ??? ]
結果
計10通りの挙動が見られた。
詳細は後述。先にまとめておくと、以下の通り。
- 2回目のcreate_noteでは、既に紐づいているnote1に対しても処理が走る
- note2作成 -> note1処理 の順
- 条件によっては例外が発生する
- dependent destroyの有無でnote1に対する処理が決まる
- 無 : image_idの更新
- 有 : note1の削除 (acts_as_paranoidがある場合は論理削除)
- uniqueness制約の有無でnote2の保存の成功/失敗が決まる
- 有 : 失敗
- 無 : 成功
- dependent destroyが無い場合、presence制約やNOT NULL制約があるとnote1のimage_idの更新で例外が発生する
- NOT NULL制約 ... ActiveRecord::StatementInvalid
- presence制約 ... ActiveRecord::RecordNotSaved
dependent destroyがあるとnote1が削除されるのには驚いた。
その他は割と納得の結果。
どの制約をつけたらどうなるを分岐木にまとめると以下のようになる。
- 分岐図の補足
- T : 有り
- F : 無
- 番号は分岐図下の表を参照
- 図の見方 (例 : 一番上の段)
- Imageにdependent destroyがないこと
- Note#image_idにpresence制約がないこと
- notesテーブルのimage_idカラムにNOT NULL制約がかかっていないこと
- Note#image_idにuniqueness制約がないこと
- 以上の条件でcreate_duplicate_note.rbを実行すると、note2が作成され、note1はimage_idがnilに更新される
(NUM)
[dependent destroy] --+-- F -- [presence] --+-- F -- [NOT NULL] ----+-- F -- [uniqueness] --+-- F -- ... 1
(Image) | (Note#image_id) | (image_id column) | (Note#image_id) |
| | | +-- T -- ... 2
| | |
| | +-- T -- [uniqueness] --+-- F -- ... 3
| | (Note#image_id) |
| | +-- T -- ... 4
| |
| +-- T -------------------------- [uniqueness] --+-- F -- ... 5
| (Note#image_id) |
| +-- T -- ... 6
|
+-- T -- [acts_as_paranoid] --+-- F ------------------ [uniqueness] --+-- F -- ... 7
(Note) | (Note#image_id) |
| +-- T -- ... 8
|
+-- T ------------------ [uniqueness] --+-- F -- ... 9
(Note#image_id) |
+-- T -- ... 10
NUM | 2回目のcreate_noteで行われる処理 | DBに存在するNote | 補足 |
---|---|---|---|
1 | note2をcreate -> note1のsphere_idをnilにしてsave | 1,2 | |
2 | note2をcreate (保存失敗) -> note1のsphere_idをnilにしてsave | 1 | image.noteはnote2 |
3 | note2をcreate -> note1のsphere_idをnilにしてsave (例外) | 1,2 | ActiveRecord::StatementInvalid |
4 | note2をcreate (保存失敗) -> note1のsphere_idをnilにしてsave (例外) | 1 | ActiveRecord::StatementInvalid |
5 | note2をcreate -> note1のsphere_idをnilにしてsave (例外) | 1,2 | ActiveRecord::RecordNotSaved |
6 | note2をcreate (保存失敗) -> note1のsphere_idをnilにしてsave (例外) | 1 | ActiveRecord::RecordNotSaved |
7 | note2をcreate -> note1をdestroy | 2 | 物理削除 |
8 | note2をcreate (保存失敗) -> note1をdestroy | --- | 物理削除 |
9 | note2をcreate -> note1をdestroy | 1,2 | note1は論理削除 |
10 | note2をcreate (保存失敗) -> note1をdestroy | 1 | note1は論理削除 |
2回目のcreate_note実行後の各変数やDBの状況
- imageが指すnote
- 例外が発生しない場合、image.noteはnote2を指す (1,2,7,8,9,10)
- 例外が発生する場合、image.noteはnote1を指す (3,4,5,6)
- DBの登録状況
- dependent destroyがなく、uniqueness制約がない場合、note1, note2両方が登録される (1,3,5)
- dependent destroyがなく、uniqueness制約がある場合、note1だけが登録される (2,4,6)
- dependent destroyがあり、uniqeuness制約がない場合、note2だけが登録される (7,9)
- dependent destroyがあり、uniqeuness制約がある場合、空になる (acts_as_paranoidがあればnote1は論理削除) (8,10)
気持ち悪いケース
例外が発生してもnote2が保存されるケース(3, 5)がある。
これはnote1の更新で例外が発生しており、transactionがnote1、note2それぞれでかかっているためである。
実際のSQLは以下のようになる(5のケースのログ)。
(0.1ms) begin transaction
Note Exists (0.1ms) SELECT 1 AS one FROM "notes" WHERE "notes"."image_id" = 1 LIMIT 1
[deprecated] I18n.enforce_available_locales will default to true in the future. If you really want to skip validation of your locale you can set I18n.enforce_available_locales = false to avoid this message.
(0.1ms) rollback transaction
(0.0ms) begin transaction
Note Exists (0.1ms) SELECT 1 AS one FROM "notes" WHERE ("notes"."image_id" IS NULL AND "notes"."id" != 1) LIMIT 1
(0.2ms) UPDATE "notes" SET "image_id" = NULL WHERE "notes"."id" = 1
(0.1ms) rollback transaction
ActiveRecord::StatementInvalid: SQLite3::ConstraintException: notes.image_id may not be NULL: UPDATE "notes" SET "image_id" = NULL WHERE "notes"."id" = 1
activerecord実装の方は、lib/active_record/associations/has_one_association.rb:7辺りのreplaceでnote1の置き換えを行っている。
例外発生もこの辺りなので、興味のある方は見てもらうと理解しやすいかと思います。
create_duplicate_note.rbの実行結果
1. dependent destroy, presence, NOT NULL, uniqueness いずれも無し
# image.rb
class Image < ActiveRecord::Base
has_one :note
end
# note.rb
class Note < ActiveRecord::Base
belongs_to :image
end
# 001_create_images.rb
class CreateImages < ActiveRecord::Migration
def change
create_table :images do |t|
end
end
end
# 002_create_note.rb
class CreateNotes < ActiveRecord::Migration
def change
create_table :notes do |t|
t.integer :image_id
end
end
end
image = Image.create #=> #<Image id: 1>
note1 = image.create_note #=> #<Note id: 1, image_id: 1>
note2 = image.create_note #=> #<Note id: 2, image_id: 1>
note1 #=> #<Note id: 1, image_id: nil>
note2 #=> #<Note id: 2, image_id: 1>
image.note #=> #<Note id: 2, image_id: 1>
Note.all #=> [#<Note id: 1, image_id: nil>, #<Note id: 2, image_id: 1>]
noteが2つ登録されるが、note1のimage_idはnilに更新される。
2. dependent destroy, presence, NOT NULL無し, uniqueness 有り
class Image < ActiveRecord::Base
has_one :note
end
class Note < ActiveRecord::Base
belongs_to :image
validates :image_id, :uniqueness => true
end
class CreateImages < ActiveRecord::Migration
def change
create_table :images do |t|
end
end
end
class CreateNotes < ActiveRecord::Migration
def change
create_table :notes do |t|
t.integer :image_id
end
end
end
image = Image.create #=> #<Image id: 1>
note1 = image.create_note #=> #<Note id: 1, image_id: 1>
note2 = image.create_note #=> #<Note id: nil, image_id: 1>
note1 #=> #<Note id: 1, image_id: nil>
note2 #=> #<Note id: nil, image_id: 1>
image.note #=> #<Note id: nil, image_id: 1>
Note.all #=> [#<Note id: 1, image_id: nil>]
uniqueness制約によってnote2の保存に失敗。
しかし、note1のimage_idは更新されてしまう。
3. dependent destroy, presence, uniqueness無し, NOT NULL有り
class Image < ActiveRecord::Base
has_one :note
end
class Note < ActiveRecord::Base
belongs_to :image
end
class CreateImages < ActiveRecord::Migration
def change
create_table :images do |t|
end
end
end
class CreateNotes < ActiveRecord::Migration
def change
create_table :notes do |t|
t.integer :image_id, :null => false
end
end
end
image = Image.create #=> #<Image id: 1>
note1 = image.create_note #=> #<Note id: 1, image_id: 1>
note2 = image.create_note #=> ActiveRecord::StatementInvalid
note1 #=> #<Note id: 1, image_id: nil>
note2 #=> nil
image.note #=> #<Note id: 1, image_id: nil>
Note.all #=> [#<Note id: 1, image_id: 1>, #<Note id: 2, image_id: 1>]
NOT NULL制約により、note1のimage_id更新で例外発生。
例外発生タイミングがnote2の保存後のため、note1がロールバックされ、
note1・note2両方がDBに登録された状態になる。
4. dependent destroy, presence無し, NOT NULL, uniqueness 有り
class Image < ActiveRecord::Base
has_one :note
end
class Note < ActiveRecord::Base
belongs_to :image
validates :image_id, :uniqueness => true
end
class CreateImages < ActiveRecord::Migration
def change
create_table :images do |t|
end
end
end
class CreateNotes < ActiveRecord::Migration
def change
create_table :notes do |t|
t.integer :image_id, :null => false
end
end
end
image = Image.create #=> #<Image id: 1>
note1 = image.create_note #=> #<Note id: 1, image_id: 1>
note2 = image.create_note #=> ActiveRecord::StatementInvalid
note1 #=> #<Note id: 1, image_id: nil>
note2 #=> nil
image.note #=> #<Note id: 1, image_id: nil>
Note.all #=> [#<Note id: 1, image_id: 1>]
uniqueness制約により、note2の保存に失敗。
その後note1のimage_idをnilに更新しようとするが、NOT NULL制約により例外発生。
note1はロールバックされ、元の状態になる。
5. dependent destroy, uniqueness無し, presence有り
class Image < ActiveRecord::Base
has_one :note
end
class Note < ActiveRecord::Base
belongs_to :image
validates :image_id, :presence => true
end
class CreateImages < ActiveRecord::Migration
def change
create_table :images do |t|
end
end
end
class CreateNotes < ActiveRecord::Migration
def change
create_table :notes do |t|
t.integer :image_id
end
end
end
image = Image.create #=> #<Image id: 1>
note1 = image.create_note #=> #<Note id: 1, image_id: 1>
note2 = image.create_note #=> ActiveRecord::RecordNotSaved
note1 #=> #<Note id: 1, image_id: 1>
note2 #=> nil
image.note #=> #<Note id: 1, image_id: 1>
Note.all #=> [#<Note id: 1, image_id: 1>, #<Note id: 2, image_id: 1>]
note2の保存に成功後、presence制約によってnote1のimage_id更新で例外発生。
note1はロールバックされ、元の状態になる。
6. dependent destroy無し, presence, uniqueness 有り
class Image < ActiveRecord::Base
has_one :note
end
class Note < ActiveRecord::Base
belongs_to :image
validates :image_id, :presence => true, :uniqueness => true
end
class CreateImages < ActiveRecord::Migration
def change
create_table :images do |t|
end
end
end
class CreateNotes < ActiveRecord::Migration
def change
create_table :notes do |t|
t.integer :image_id
end
end
end
image = Image.create #=> #<Image id: 1>
note1 = image.create_note #=> #<Note id: 1, image_id: 1>
note2 = image.create_note #=> ActiveRecord::RecordNotSaved
note1 #=> #<Note id: 1, image_id: 1>
note2 #=> nil
image.note #=> #<Note id: 1, image_id: 1>
Note.all #=> [#<Note id: 1, image_id: 1>]
uniqueness制約により、note2の保存に失敗。
presence制約により、note1のimage_id更新で例外発生。
note1はロールバックされ、元の状態になる。
7. dependent destroy有り, uniqueness無し
class Image < ActiveRecord::Base
has_one :note, :dependent => :destroy
end
class Note < ActiveRecord::Base
belongs_to :image
end
class CreateImages < ActiveRecord::Migration
def change
create_table :images do |t|
end
end
end
class CreateNotes < ActiveRecord::Migration
def change
create_table :notes do |t|
t.integer :image_id
end
end
end
image = Image.create #=> #<Image id: 1>
note1 = image.create_note #=> #<Note id: 1, image_id: 1>
note2 = image.create_note #=> #<Note id: 2, image_id: 1>
note1 #=> #<Note id: 1, image_id: 1>
note2 #=> #<Note id: 2, image_id: 1>
image.note #=> #<Note id: 2, image_id: 1>
Note.all #=> [#<Note id: 2, image_id: 1>]
note2の保存後、dependent destroyにより、note1が物理削除される。
8. dependent destroy, uniqueness有り
class Image < ActiveRecord::Base
has_one :note, :dependent => :destroy
end
class Note < ActiveRecord::Base
belongs_to :image
validates :image_id, :uniqueness => true
end
class CreateImages < ActiveRecord::Migration
def change
create_table :images do |t|
end
end
end
class CreateNotes < ActiveRecord::Migration
def change
create_table :notes do |t|
t.integer :image_id
end
end
end
image = Image.create #=> #<Image id: 1>
note1 = image.create_note #=> #<Note id: 1, image_id: 1>
note2 = image.create_note #=> #<Note id: nil, image_id: 1>
note1 #=> #<Note id: 1, image_id: 1>
note2 #=> #<Note id: nil, image_id: 1>
image.note #=> #<Note id: nil, image_id: 1>
Note.all #=> []
uniqueness制約により、note2の保存に失敗。
dependent destroyにより、note1が物理削除される。
結果、DBにnoteが存在しなくなる。
9. dependent destroy, acts_as_paranoid有り, uniqueness無し
class Image < ActiveRecord::Base
has_one :note, :dependent => :destroy
end
class Note < ActiveRecord::Base
acts_as_paranoid
belongs_to :image
end
class CreateImages < ActiveRecord::Migration
def change
create_table :images do |t|
end
end
end
class CreateNotes < ActiveRecord::Migration
def change
create_table :notes do |t|
t.integer :image_id
t.datetime :deleted_at
end
end
end
image = Image.create #=> #<Image id: 1>
note1 = image.create_note #=> #<Note id: 1, image_id: 1>
note2 = image.create_note #=> #<Note id: 2, image_id: 1, deleted_at: nil>
note1 #=> #<Note id: 1, image_id: 1, deleted_at: "2014-12-01 01:46:22">
note2 #=> #<Note id: 2, image_id: 1, deleted_at: nil>
image.note #=> #<Note id: 2, image_id: 1, deleted_at: nil>
Note.with_deleted.all #=> [#<Note id: 1, image_id: 1, deleted_at: "2014-12-01 02:16:05">,
# #<Note id: 2, image_id: 1, deleted_at: nil>]
note2の保存後、dependent destroyとacts_as_paranoidにより、note1が論理削除される。
10. dependent destroy, acts_as_paranoid, uniqueness有り
class Image < ActiveRecord::Base
has_one :note, :dependent => :destroy
end
class Note < ActiveRecord::Base
acts_as_paranoid
belongs_to :image
validates :image_id, :uniqueness => true
end
class CreateImages < ActiveRecord::Migration
def change
create_table :images do |t|
end
end
end
class CreateNotes < ActiveRecord::Migration
def change
create_table :notes do |t|
t.integer :image_id
t.datetime :deleted_at
end
end
end
image = Image.create #=> #<Image id: 1>
note1 = image.create_note #=> #<Note id: 1, image_id: 1>
note2 = image.create_note #=> #<Note id: nil, image_id: 1, deleted_at: nil>
note1 #=> #<Note id: 1, image_id: 1, deleted_at: "2014-12-01 01:47:26">
note2 #=> #<Note id: nil, image_id: 1, deleted_at: nil>
image.note #=> #<Note id: nil, image_id: 1, deleted_at: nil>
Note.with_deleted.all #=> [#<Note id: 1, image_id: 1, deleted_at: "2014-12-01 02:17:20">]
uniqueness制約により、note2の保存に失敗。
dependent destroyとacts_as_paranoidにより、note1が論理削除される。
結果、DBには論理削除されたnote1だけが残る。
create_duplicate_note.rb実行時の、各インスタンスの値とDBの登録状況
表の見方
- 項目
- D : dependent destroy
- A : acts_as_paranoid
- P : presence
- N : NOT NULL
- U : uniqueness
- 値
-
- : なし
- o : あり
-
- : - or o
- true : deleted_atに値が埋まっている
- --- : 存在しない
各インスタンスの値
NUM | D | A | P | N | U | note1(id, image, deleted_at) | note2(id, image, deleted_at) | image.note.id |
---|---|---|---|---|---|---|---|---|
1 | - | * | - | - | - | 1, nil | 2, 1 | 2 |
2 | - | * | - | - | o | 1, nil | nil, 1 | 2 |
3 | - | * | - | o | - | 1, nil | nil | 1 |
4 | - | * | - | o | o | 1, nil | nil | 1 |
5 | - | * | o | * | - | 1, 1 | nil | 1 |
6 | - | * | o | * | o | 1, 1 | nil | 1 |
7 | o | * | * | * | - | 1, 1 | 2, 1 | 2 |
8 | o | * | * | * | o | 1, 1 | nil, 1 | 2 |
9 | o | o | * | * | - | 1, 1, true | 2, 1, nil | 2 |
10 | o | o | * | * | o | 1, 1, true | nil, 1, nil | 2 |
DBの登録状況
NUM | D | A | P | N | U | note1(id, image, deleted_at) | note2(id, image, deleted_at) | 補足 |
---|---|---|---|---|---|---|---|---|
1 | - | * | - | - | - | 1, nil | 2, 1 | |
2 | - | * | - | - | o | 1, nil | --- | |
3 | - | * | - | o | - | 1, 1 | 2, 1 | ActiveRecord::StatementInvalid |
4 | - | * | - | o | o | 1, 1 | --- | ActiveRecord::StatementInvalid |
5 | - | * | o | * | - | 1, 1 | 2, 1 | ActiveRecord::RecordNotFound |
6 | - | * | o | * | o | 1, 1 | --- | ActiveRecord::RecordNotFound |
7 | o | * | * | * | - | --- | 2, 1 | |
8 | o | * | * | * | o | --- | --- | |
9 | o | o | * | * | - | --- | 2, 1, nil | |
10 | o | o | * | * | o | 1, 1, true | --- | note1は論理削除 |