ActiveRecordのupsert_allの挙動について
upsert_all はActiveRecordインスタンスの初期化を行わずに複数レコードの作成or更新を単一のSQLで出来るので、一括更新処理などを行いたいときは重宝するメソッドです。
ただ、使う時に INSERT ... ON DUPLICATE KEY UPDATE
への理解が甘いこともありちょっとハマったところがあったのでそれをまとめたいと思います。
なお、この記事では、DatabaseでMysql5.7、 Rails 6.1.7 を使用するものとします。
upsert_allを使う時は更新したいカラム以外の値も渡す必要がある
例えば次のような employees テーブルがあったとします。
employees テーブル
id | office_id | name | number | created_at | updated_at |
---|---|---|---|---|---|
1 | 1 | 野比のび太 | 1 | 2022-11-17 00:00:00 | 2022-11-17 00:00:00 |
2 | 1 | 剛田武 | 2 | 2022-11-18 00:00:00 | 2022-11-18 00:00:00 |
3 | 1 | 骨川スネ夫 | 3 | 2022-11-19 00:00:00 | 2022-11-19 00:00:00 |
この時、例えば既に保存されているレコードに関して姓と名の間に半角スペースが必要だったのでレコード1~3を更新したいすると次のように書きたくなります。
Employee.upsert_all(
[
{ id: 1, name: '野比 のび太' },
{ id: 2, name: '剛田 武' },
{ id: 3, name: '骨川 スネ夫' }
]
)
が、この場合だと ActiveRecord::NotNullViolation: Mysql2::Error が発生してしまいます
理由はレコードが新規追加だったケースの時に問題があるSQLになってしまうからです。
新規追加だったケースの時に問題がある理由
上記のupsert_allを実行したときのSQLは次のようになります
INSERT INTO `sample_database`.`employees`(`id`,`name`)
VALUES
(1, '野比 のび太'),
(2, '剛田 武'),
(3, '骨川 スネ夫')
ON DUPLICATE KEY UPDATE
updated_at = (
CASE
WHEN(
`name` <= >
VALUES(`name`)
) THEN `sample_database`.`employees`.updated_at
ELSE CURRENT_TIMESTAMP
END
),
`name` = VALUES(`name`)
INSERT ... ON DUPLICATE KEY UPDATE の挙動はMySQLのリファレンスマニュアルによると
- INSERTの時はVALUES に指定した内容 + 指定していないカラムに関してはテーブル定義のデフォルト値で保存される
- UPDATEの時はON DUPLICATE KEY UPDATE配下にて指定された内容で更新される
といった挙動になります。そして、処理結果が新規作成になるか更新になるかはSQLが実行される瞬間まで分からないため、SQL自体はCREATEでもUPDATEでも問題ないSQLである必要があります。
今回のEmployee.upsert_all
はUPDATEの時は問題ないですが、CREATEの時にoffice_id,numberなどおそらくデフォルト値が無いであろうカラムに関しての指定がないため Mysql2::Error
が発生してしまうのです。
どうしたら良いか?
Mysql2::Error
を回避するには例えupsert_allを使って更新処理のみを行いたい場合でもデフォルト値がないカラムに関しては値を渡してあげる必要があります。
Employee.upsert_all(
[
{ id: 1, office_id: 1, name: '野比 のび太', number: 1 },
{ id: 2, office_id: 1, name: '剛田 武', number: 2 },
{ id: 3, office_id: 1, name: '骨川 スネ夫', number: 3 }
]
)
Discussion