LoginSignup
16
15

More than 5 years have passed since last update.

【Rails】【ActiveRecord】create_xxxに要注意

Last updated at Posted at 2014-12-01

概要

Imageに1:1で紐付くNoteレコードがあるような状態で、意図せず同一のImageに対してcreate_noteを二回叩いてしまった。
すると、例外発生して、Imageに紐付くNoteがなくなってしまった・・・。

これはほんの一例。
ActiveModelの制約によって、create_xxxを2回叩いた時の挙動が変わります。
みなさんも知らないうちにレコード消されたり、二重登録されてたりしませんか?
そうならないためにも、制約と挙動のセットをこの機会に覚えましょう!

まとめ

create_xxxを2回叩いたときの挙動

最初にまとめておくと

  1. uniqueness制約があると、2回目で作成するレコードの保存に失敗(false)する
  2. dependent destroyがあると、1回目で作成したレコードが削除される
  3. dependent destroyがない場合、外部キーにnot null制約があると、例外が発生する
    1. presence制約 : ActiveRecord::RecordNotFound
    2. DBのNOT NULL制約 : ActiveRecord::StatementInvalid
    3. 保存に成功するかどうかは前述のunqueness制約の有無による

以上の組合わせで、create_xxx1回目、2回目で作成されたレコードがどうなるかが決まる。


詳細

前提

基本は以下のModelとMigration。

image.rb
class Image < ActiveRecord::Base
  has_one :note
end
note.rb
class Note < ActiveRecord::Base
  attr_accessible :image_id
  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 

実験

条件

以下の5つの条件を組み合わせる (計2^5=32通り)

  1. has_one :note, :dependent => :destroy # image.rb
  2. t.integer :image_id, :null => false # create_notes.rb (Migrationファイル)
  3. validates :image_id, :presence => true # note.rb
  4. validates :image_id, :uniqueness => true # note.rb
  5. acts_as_paranoid # note.rb (deleted_atカラムも追加)

内容

上記条件で以下のcreate_duplicate_note.rbを実行する。
この時の2回目のcreate_noteの挙動や、各インスタンス、DBの状況がどうなるかを調べる。

create_duplicate_note.rb
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
create_duplicate_note.rb
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
create_duplicate_note.rb
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
create_duplicate_note.rb
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
create_duplicate_note.rb
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
create_duplicate_note.rb
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
create_duplicate_note.rb
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
create_duplicate_note.rb
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
create_duplicate_note.rb
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
create_duplicate_note.rb
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
create_duplicate_note.rb
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は論理削除
16
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
15