⛑️
ActiveRecord で autosave を有効にすると uniqueness バリデーションが効かなくなる場合がある
問題提起
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