関連付けのあるモデルにおいて、親レコードをnew => 子レコードをbuild => 親レコードをsave! したときの挙動が複雑な気がしたのでメモ。
(理解力が不足しているだけかもしれない。)
検証環境: ActiveRecord 6.0.2.1
結論を言葉で
- has_oneでは親レコードと子レコードのvalidityは独立している。
- has_manyでは子レコードが1つでもinvalidなら親レコードもinvalidになる。
親レコードをsave!したとき、
- 親レコードがinvalidならraiseする
- 親レコードも子レコードもvalidな場合、insertがtransactionで囲まれて実行される
- 親レコードがvalidで子レコードがinvalidな場合(上記の性質によりhas_oneでのみありうる)、親レコードのinsert処理のみが実行される
追記
has_oneにもhas_manyにもvalidateというオプションが存在し、デフォルトがhas_oneだとfalse, has_manyだとtrueになっているため本記事の挙動となっていた。
validateオプションを指定することでこの辺りの挙動はコントロール可能である。
実験
サンプルは以下。
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)