ActiveRecordでnew => build => save! するとどうなる

関連付けのあるモデルにおいて、親レコードをnew => 子レコードをbuild => 親レコードをsave! したときの挙動が複雑な気がしたのでメモ。

(理解力が不足しているだけかもしれない。)

検証環境: ActiveRecord 6.0.2.1

追記

さんざん書いてから気づいたのだけど、has_oneにもhas_manyにもvalidateというオプションが存在する。 これのデフォルトがhas_oneだとfalse, has_manyだとtrueになっているため本記事の挙動となっていただけで、ここを指定すればいいだけの話だった...。

結論を言葉で

  • has_oneでは親レコードと子レコードのvalidityは独立している。
  • has_manyでは子レコードが1つでもinvalidなら親レコードもinvalidになる。

親レコードをsave!したとき、

  • 親レコードがinvalidならraiseする
  • 親レコードも子レコードもvalidな場合、insertがtransactionで囲まれて実行される
  • 親レコードがvalidで子レコードがinvalidな場合(上記の性質によりhas_oneでのみありうる)、親レコードのinsert処理のみが実行される

実験

サンプルは以下。

migrations:

class CreateTables < ActiveRecord::Migration[6.0]
  def change
    create_table :customers do |t|
    end

    create_table :banks do |t|
    end

    create_table :merchants do |t|
    end

    create_table :bank_accounts do |t|
      t.references :customer, foreign_key: true, null: false
      t.references :bank, foreign_key: true, null: false
      t.string :account_number, null: false
    end

    create_table :orders do |t|
      t.references :customer, foreign_key: true, null: false
      t.references :merchant, foreign_key: true, null: false
      t.integer :price, null: false
    end
  end
end

models:

class Customer < ApplicationRecord
  has_one :bank_account
  has_many :orders
end

class Bank < ApplicationRecord
end

class Merchant < ApplicationRecord
end

class BankAccount < ApplicationRecord
  belongs_to :customer
  validates :account_number, presence: true
end

class Order < ApplicationRecord
  belongs_to :customer
  validates :price, presence: true
end

実験するケースは以下。

  • 1 has_one
    • 1-1 invalid
    • 1-2 valid & insertable
    • 1-3 valid & uninsertable
  • 2 has_many
    • 2-1 all invalid
    • 2-2 all valid & insertable
    • 2-3 all valid & uninsertable
    • 2-4 valid&savable + valid & uninsertable
    • 2-5 invalid + valid & uninsertable
    • 2-6 invalid + valid & insertable

1-1 invalid

irb(main):118:0> customer = Customer.new
irb(main):119:0> customer.build_bank_account
=> #<BankAccount id: nil, customer_id: nil, bank_id: nil, account_number: nil, created_at: nil, updated_at: nil>
irb(main):120:0> customer.valid?
=> true
irb(main):121:0> customer.bank_account.valid?
=> false
irb(main):122:0> customer.bank_account.errors.full_messages
=> ["Account number can't be blank"]
irb(main):123:0> customer.save!
=> true
irb(main):125:0> customer.persisted?
=> true
irb(main):126:0> customer.bank_account.persisted?
=> false

1-2 valid & insertable

irb(main):156:0> customer = Customer.new
irb(main):157:0> customer.build_bank_account(account_number: "1234", bank_id: 1)
=> #<BankAccount id: nil, customer_id: nil, bank_id: 1, account_number: "1234", created_at: nil, updated_at: nil>
irb(main):158:0> customer.valid?
=> true
irb(main):159:0> customer.bank_account.valid?
=> true
irb(main):160:0> customer.save!
=> true
irb(main):161:0> customer.persisted?
=> true
irb(main):162:0> customer.bank_account.persisted?
=> true

1-3 valid & uninsertable

irb(main):138:0> customer = Customer.new
irb(main):139:0> customer.build_bank_account(account_number: "1234")
=> #<BankAccount id: nil, customer_id: nil, bank_id: nil, account_number: "1234", created_at: nil, updated_at: nil>
irb(main):140:0> customer.valid?
=> true
irb(main):141:0> customer.bank_account.valid?
=> true
irb(main):142:0> customer.save!
ActiveRecord::NotNullViolation (Mysql2::Error: Field 'bank_id' doesn't have a default value)
irb(main):143:0> customer.persisted?
=> false
irb(main):144:0> customer.bank_account.persisted?
=> false

2-1 all invalid

irb(main):176:0> customer = Customer.new
irb(main):177:0> customer.orders.build
=> #<Order id: nil, customer_id: nil, bank_id: nil, price: nil, created_at: nil, updated_at: nil>
irb(main):178:0> customer.orders.build
=> #<Order id: nil, customer_id: nil, bank_id: nil, price: nil, created_at: nil, updated_at: nil>
irb(main):187:0> customer.valid?
=> false
irb(main):188:0> customer.errors.full_messages
=> ["Orders is invalid"]
irb(main):191:0> customer.orders.map {|order| order.valid? }
=> [false, false]
irb(main):193:0> customer.orders.map {|order| order.errors.full_messages }
=> [["Price can't be blank"], ["Price can't be blank"]]
irb(main):196:0> customer.save!
ActiveRecord::RecordInvalid (Validation failed: Orders is invalid)

2-2 all valid & insertable

irb(main):001:0> customer = Customer.new
irb(main):002:0> customer.orders.build(price: 100, merchant_id: 1)
=> #<Order id: nil, customer_id: nil, merchant_id: 1, price: 100, created_at: nil, updated_at: nil>
irb(main):004:0> customer.orders.build(price: 200, merchant_id: 2)
=> #<Order id: nil, customer_id: nil, merchant_id: 2, price: 200, created_at: nil, updated_at: nil>
irb(main):005:0> customer.valid?
=> true
irb(main):006:0> customer.orders.map {|order| order.valid? }
=> [true, true]
irb(main):007:0> customer.save!
=> true
irb(main):008:0> customer.persisted?
=> true
irb(main):009:0> customer.orders.map {|order| order.persisted? }
=> [true, true]

2-3 all valid & uninsertable

irb(main):016:0> customer = Customer.new
irb(main):017:0> customer.orders.build(price: 100)
=> #<Order id: nil, customer_id: nil, merchant_id: nil, price: 100, created_at: nil, updated_at: nil>
irb(main):018:0> customer.orders.build(price: 200)
=> #<Order id: nil, customer_id: nil, merchant_id: nil, price: 200, created_at: nil, updated_at: nil>
irb(main):019:0> customer.valid?
=> true
irb(main):020:0> customer.orders.map {|order| order.valid? }
=> [true, true]
irb(main):021:0> customer.save!
ActiveRecord::NotNullViolation (Mysql2::Error: Field 'merchant_id' doesn't have a default value)
irb(main):024:0> customer.persisted?
=> false
irb(main):025:0> customer.orders.map {|order| order.persisted? }
=> [false, false]

2-4 valid&savable + valid & uninsertable

irb(main):035:0> customer = Customer.new
irb(main):036:0> customer.orders.build(price: 100, merchant_id: 1)
=> #<Order id: nil, customer_id: nil, merchant_id: 1, price: 100, created_at: nil, updated_at: nil>
irb(main):037:0> customer.orders.build(price: 200)
=> #<Order id: nil, customer_id: nil, merchant_id: nil, price: 200, created_at: nil, updated_at: nil>
irb(main):038:0> customer.valid?
=> true
irb(main):039:0> customer.orders.map {|order| order.valid? }
=> [true, true]
irb(main):040:0> customer.save!
ActiveRecord::NotNullViolation (Mysql2::Error: Field 'merchant_id' doesn't have a default value)
irb(main):041:0> customer.persisted?
=> false
irb(main):042:0> customer.orders.map {|order| order.persisted? }
=> [false, false]

2-5 invalid + valid & uninsertable

irb(main):044:0> customer = Customer.new
irb(main):045:0> customer.orders.build
=> #<Order id: nil, customer_id: nil, merchant_id: nil, price: nil, created_at: nil, updated_at: nil>
irb(main):046:0> customer.orders.build(price: 100)
=> #<Order id: nil, customer_id: nil, merchant_id: nil, price: 100, created_at: nil, updated_at: nil>
irb(main):047:0> customer.valid?
=> false
irb(main):048:0> customer.errors.full_messages
=> ["Orders is invalid"]
irb(main):049:0> customer.orders.map {|order| order.valid? }
=> [false, true]
irb(main):050:0> customer.orders.map {|order| order.errors.full_messages }
=> [["Price can't be blank"], []]
irb(main):051:0> customer.save!
ActiveRecord::RecordInvalid (Validation failed: Orders is invalid)

2-6 invalid + valid & insertable

irb(main):059:0> customer = Customer.new
irb(main):060:0> customer.orders.build
=> #<Order id: nil, customer_id: nil, merchant_id: nil, price: nil, created_at: nil, updated_at: nil>
irb(main):061:0> customer.orders.build(price: 100, merchant_id: 1)
=> #<Order id: nil, customer_id: nil, merchant_id: 1, price: 100, created_at: nil, updated_at: nil>
irb(main):062:0> customer.valid?
=> false
irb(main):063:0> customer.errors.full_messages
=> ["Orders is invalid"]
irb(main):064:0> customer.orders.map {|order| order.valid? }
=> [false, true]
irb(main):065:0> customer.orders.map {|order| order.errors.full_messages }
=> [["Price can't be blank"], []]
irb(main):066:0> customer.save!
ActiveRecord::RecordInvalid (Validation failed: Orders is invalid)