Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails 7: upsert_allのon_duplicateオプションに生SQLを渡せるようになった(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

Rails 7: upsert_allのon_duplicateオプションに生SQLを渡せるようになった(翻訳)

Railsアプリケーションでレコードを一括挿入する必要が生じることがよくあります。Rails 6では一括インポートを解決するためにinsert_allメソッドとupsert_allメソッドが導入されました(『Rails 6 bulk insert records』)。

upsert_allメソッドは単にupdate」と「insertを合わせたものです。レコードが存在する場合は新しい属性でupdateし、存在しない場合は新規レコードをinsertします。

変更前

Rails 6ではupsert_allメソッドにreturning:オプションやunique_by:オプションを渡せます。

returningオプションは、insertupdateが成功したすべてのレコードから返されるべきモデル属性の配列を指定します。

たとえば、idnamepriceという属性を持つCommodityというモデルがあるとします。Commodityモデルで3行をインポートし、更新されたname属性とprice属性を返すよう指定するreturningオプションを渡すとします。これを実行するコードは以下のようになります。

result = Commodity.upsert_all(
          [
            { id: 1, name: "原油", price: 51.27 },
            { id: 2, name: "銅", price: 2.84 },
            { id: 4, name: "金", price: 1480.35 }
          ],
        )

# Bulk Update (2.3ms) INSERT INTO "commodities" ("id", "name","price")
# VALUES(1, "原油", 51.27...)
# RETURNING "name", "price"

puts result.inspect

#<ActiveRecord::Result:0x00007fb6612a1ad8 @columns=["id", "name", "price"], @rows=[[1, "原油", 51.27]....],

@hash_rows=nil, @column_types=...>

puts result.rows

[[1, "原油", 51.27], [2, "銅", 2.84], [4, "金", 1480.35]]

Rails 6では、upsert_allに重複レコードを渡すと、デフォルトでは新しい属性でレコードをupdateします。しかし状況によっては、重複レコードが渡されたらエラーを発生させたい場合や、カスタムSQLを実行したい場合もあります。

試しにupsert_allメソッドにon_duplicate:オプションを渡すと以下のエラーが発生します。

result = Commodity.upsert_all(
          [
            { id: 1, name: "原油", price: 51.27 },
            { id: 2, name: "銅", price: 2.84 },
            { id: 4, name: "金", price: 1480.35 }
          ],
          on_duplicate: :raise
        )

ArgumentError (unknown keyword: :on_duplicate)

変更後

上の問題を解決するため、Rails 7でupsert_allon_duplicate:オプションが追加されました(#41933)。この変更で、on_duplicate:オプションやreturning:オプションに生SQLクエリも渡せるようになりました。

先ほどのCommodityモデルの例を続けます。重複レコードをupsertしようとするときに、そのコモディティの中で最も高い価格に設定したいとします。以下のようにon_duplicate:オプションにカスタムSQLクエリを渡すことで、コモディティで最も高い価格を設定できるようになります。

Commodity.upsert_all(
  [
    { id: 2, name: "銅", price: 4.84 },
    { id: 4, name: "金", price: 1380.87 },
    { id: 6, name: "アルミニウム", price: 0.35 }
  ],
  on_duplicate: Arel.sql("price = GREATEST(commodities.price, EXCLUDED.price)")
)

Commodity.find_by_name("銅").price
#=> 4.84

Commodity.find_by_name("金").price
#=> 1480.35

上のように、銅の価格は元の2.84より高値の4.84に更新されますが、金の価格は変わっていません。

同様に、returning:オプションにもカスタムクエリを渡せます。

Commodity.upsert_all(
  [
    { id: 2, name: "銅", price: 4.84, created_at: Time.now, updated_at: Time.now  },
    { id: 4, name: "金", price: 1380.87, created_at: Time.now, updated_at: Time.now  },
    { id: 8, name: "鉄", price: 1.35, created_at: Time.now, updated_at: Time.now  }
  ],
  returning: Arel.sql("id, (xmax = '0') as inserted, name as new_name")
)

#=> <ActiveRecord::Result:0x00007f9a3061ee38 @columns=["id", "inserted", "new_name"], @rows=[[2, false, "銅"], [4, false, "金"], [8, true, "鉄"]], @hash_rows=nil, @column_types={}>

ご覧のとおり、xmax = '0'はレコードが挿入される場合trueになり、既存のレコードではfalseになります。

原注

上述の変更を適用できないデータベースもあります。 returning:オプションはPostgreSQLではサポートされていますが1、SQLite3ではサポートされていません。

関連記事

Rails 7でunpermitted_parametersのログ出力にcontextも含められる(翻訳)


  1. 訳注: upsert_allreturning:オプションはapi.rubyonrails.orgedgeapi.rubyonrails.orgともに「PostgreSQLのみ」と記載されています。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。