⛑️

ActiveRecord で autosave を有効にすると uniqueness バリデーションが効かなくなる場合がある

2023/11/29に公開

問題提起

autosave は親の保存のタイミングで子も保存してくれる便利な機能だが、親の生成時のトランザクションに子の生成も含めようとすると不可解な挙動が起きる。

検証1. 重複バリデーションが無視される例

require "active_record"
ActiveRecord::VERSION::STRING  # => "7.1.3.2"
ActiveSupport::LogSubscriber.colorize_logging = false
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Migration.verbose = false
ActiveRecord::Schema.define do
  create_table :users do |t|
  end
  create_table :articles do |t|
    t.belongs_to :user
    t.string :subject
  end
end

ActiveRecord::Base.logger = ActiveSupport::Logger.new(STDOUT)

class User < ActiveRecord::Base
  has_many :articles, autosave: true
end

class Article < ActiveRecord::Base
  belongs_to :user
  validates_uniqueness_of :subject
end

user = User.build
user.articles.build(subject: "foo")
user.articles.build(subject: "foo")
user.save!                     # => true
user.articles.count            # => 2
# >   TRANSACTION (0.0ms)  begin transaction
# >   Article Exists? (0.1ms)  SELECT 1 AS one FROM "articles" WHERE "articles"."subject" = ? LIMIT ?  [["subject", "foo"], ["LIMIT", 1]]
# >   Article Exists? (0.0ms)  SELECT 1 AS one FROM "articles" WHERE "articles"."subject" = ? LIMIT ?  [["subject", "foo"], ["LIMIT", 1]]
# >   User Create (0.0ms)  INSERT INTO "users" DEFAULT VALUES RETURNING "id"
# >   Article Create (0.0ms)  INSERT INTO "articles" ("user_id", "subject") VALUES (?, ?) RETURNING "id"  [["user_id", 1], ["subject", "foo"]]
# >   Article Create (0.0ms)  INSERT INTO "articles" ("user_id", "subject") VALUES (?, ?) RETURNING "id"  [["user_id", 1], ["subject", "foo"]]
# >   TRANSACTION (0.0ms)  commit transaction
# >   Article Count (0.0ms)  SELECT COUNT(*) FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 1]]

uniqueness バリデーションを入れているにもかかわらず同じ値の subject を持つ Article が 2 件、生成されているのがわかる。

検証2. autosave を指定しない場合は重複バリデーションが効く

require "active_record"
ActiveRecord::VERSION::STRING  # => "7.1.3.2"
ActiveSupport::LogSubscriber.colorize_logging = false
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Migration.verbose = false
ActiveRecord::Schema.define do
  create_table :users do |t|
  end
  create_table :articles do |t|
    t.belongs_to :user
    t.string :subject
  end
end

ActiveRecord::Base.logger = ActiveSupport::Logger.new(STDOUT)

class User < ActiveRecord::Base
  has_many :articles
end

class Article < ActiveRecord::Base
  belongs_to :user
  validates_uniqueness_of :subject
end

user = User.build
user.articles.build(subject: "foo")
user.articles.build(subject: "foo")
user.save! rescue $!           # => #<ActiveRecord::RecordInvalid: Validation failed: Articles is invalid>
user.articles.count            # => 0
# >   TRANSACTION (0.0ms)  begin transaction
# >   Article Exists? (0.2ms)  SELECT 1 AS one FROM "articles" WHERE "articles"."subject" = ? LIMIT ?  [["subject", "foo"], ["LIMIT", 1]]
# >   Article Exists? (0.0ms)  SELECT 1 AS one FROM "articles" WHERE "articles"."subject" = ? LIMIT ?  [["subject", "foo"], ["LIMIT", 1]]
# >   User Create (0.0ms)  INSERT INTO "users" DEFAULT VALUES RETURNING "id"
# >   Article Exists? (0.0ms)  SELECT 1 AS one FROM "articles" WHERE "articles"."subject" = ? LIMIT ?  [["subject", "foo"], ["LIMIT", 1]]
# >   Article Create (0.0ms)  INSERT INTO "articles" ("user_id", "subject") VALUES (?, ?) RETURNING "id"  [["user_id", 1], ["subject", "foo"]]
# >   Article Exists? (0.0ms)  SELECT 1 AS one FROM "articles" WHERE "articles"."subject" = ? LIMIT ?  [["subject", "foo"], ["LIMIT", 1]]
# >   TRANSACTION (0.0ms)  rollback transaction

subject の値が重複するため保存できないのがわかる。

まとめ

uniqueness バリデーションらしき SQL は発生しているものの、autosave 機能の都合上、まとめて INSERT しないといけないらしいので、結果として uniqueness バリデーションが無視される。

このように ActiveRecord はすべての機能の整合性がとれているわけではなく、開発陣だけが知っている暗黙の両立しない機能の組み合わせがあったりするようだ。

Discussion