ネットの海の片隅で

技術ネタの放流、あるいは不法投棄。

RailsにおけるDB制約のワナ

DBに制約を張る

このようなテーブルがあったとします。

CREATE TABLE products(
  id serial NOT NULL,
  code varchar NOT NULL,
  price integer NOT NULL CHECK (price > 0),
  CONSTRAINT pk_products PRIMARY KEY (id)
);
  • 各カラムにNOT NULL制約
  • priceにCHECK制約

という2点を除けば、rails g migrationで作られるものと同じだと思います。

このテーブルにおいて、

ActiveRecord::Base.transaction do
  CSV.foreach(csv_path) do |row|
    code, price = row[0], row[1]
    Product.create!(code: code, price: price)
  end
end

というような処理を行おうとすると、制約に違反するデータが来た時にPG::ErrorPG::CheckViolationという例外が発生してトランザクションロールバックされます。

ここまでは想定通り。

制約に違反するデータを無視する

制約に違反するデータが1件でもあれば全体をロールバックしたいのであれば上記のコードで良いです。 しかし、DB接続の前後で例外が発生した場合はロールバックし、制約に違反するデータは無視して処理を続けたい場合、つまり、例外を投げる処理A,Bを含む次のようなコードの場合、

ActiveRecord::Base.transaction do
  # 処理A(例外を投げる可能性アリ)

  CSV.foreach(csv_path) do |row|
    code, price = row[0], row[1]
    Product.create!(code: code, price: price)
  end

  # 処理B(例外を投げる可能性アリ)
end

素直に

ActiveRecord::Base.transaction do
  # 処理A(例外を投げる可能性アリ)

  CSV.foreach(csv_path) do |row|
    code, price = row[0], row[1]
    Product.create(code: code, price: price) # create!メソッドをcreateに変更
  end

  # 処理B(例外を投げる可能性アリ)
end

というようなコードを書くと、制約に違反するデータが渡された後に呼ばれるcreateメソッドActiveRecord::StatementInvalid: PG InFailedSqlTransaction*1という例外が吐かれます。

これはPG::ErrorPG::CheckViolationRailsの中で発生した例外ではなく、PostgreSQL側で生じた例外なのでRailsに例外が渡されるより前にPostgreSQL内部でロールバックが走っていることに起因します。

ぽすぐれ「ロールバックされたトランザクションにはINSERTできないよ☆」

ってことです。

対策

PostgreSQLに不正なデータが渡さないということが必要なので、Rails側で値のチェックをする必要があります。

そうです、validationです。

まとめ

忘れずにvalidationしろというお話。

*1:PostgreSQLの場合。