昔ばなし「Active Record の条件付きコールバックで苦戦太郎」


昔々あるところに、おじいさんとおばあさんが Web アプリケーション開発をしていました。
おじいさんはバックエンドを、おばあさんはフロントエンドを担当していました。

ある日突然、おばあさんはおじいさんに次のような仕様を言い渡しました。

「おじいさんや、`User` の `email` が更新された際に `some_function` が実行されるようにしておくれ。」

急な仕様変更に戸惑いながらも不承不承ながら了承したおじいさんは、とりあえず現在の実装を確認しました。
`user.rb` を開くと、以下のように実装されていました。

```rb:user.rb
  after_update :some_function, if: :saved_change_to_name?

  def some_function
    p "some_function called!"
  end
```

上記では `name` が更新された後に `some_function` が実行されるようになっています。

おじいさんは「`:saved_change_to_name?` を `:saved_change_to_email?` に変えた行を追加すればよいのでは？」と考え、以下のように変更しました。

```diff_ruby
  after_update :some_function, if: :saved_change_to_name?
+ after_update :some_function, if: :saved_change_to_email?

  def some_function
    p "some_function called!"
  end
```

念のため `rails console` で `email` を変更して動作確認をしてみました。

```rb
irb(main):005:0> u.update(email: "test2@example.com")
  TRANSACTION (0.1ms)  begin transaction
  User Exists? (0.2ms)  SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER(?) AND "users"."id" != ? LIMIT ?  [["email", "test2@example.com"], ["id", 1], ["LIMIT", 1]]
  User Update (0.7ms)  UPDATE "users" SET "email" = ?, "updated_at" = ? WHERE "users"."id" = ?  [["email", "test2@example.com"], ["updated_at", "2022-06-20 13:10:03.984894"], ["id", 1]]
"some_function called!"
  TRANSACTION (5.5ms)  commit transaction
=> true
```

コンソールに `"some_function called!"` が表示されていることを確認したおじいさんは「ヨシ！」と言いながらテストを追加して Pull Request を出そうとしたところ、CIで回していたテストが落ちていることに気づきました。

原因を調べたところ、`name` を更新したときに `some_function` が呼ばれなくなってしまったようです。

```rb
irb(main):002:0> u.update(name: "test")
  TRANSACTION (0.1ms)  begin transaction
  User Exists? (0.3ms)  SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER(?) AND "users"."id" != ? LIMIT ?  [["email", "test2@example.com"], ["id", 1], ["LIMIT", 1]]
  User Update (1.4ms)  UPDATE "users" SET "name" = ?, "updated_at" = ? WHERE "users"."id" = ?  [["name", "test"], ["updated_at", "2022-06-20 12:55:50.461921"], ["id", 1]]
  TRANSACTION (5.7ms)  commit transaction
=> true
```

さすがに雑に書きすぎたな、と反省したおじいさんは Rails ガイドを読んで、複数の条件を指定する方法を見つけました。

https://railsguides.jp/active_record_callbacks.html#%E3%82%B3%E3%83%BC%E3%83%AB%E3%83%90%E3%83%83%E3%82%AF%E3%81%A7%E8%A4%87%E6%95%B0%E3%81%AE%E6%9D%A1%E4%BB%B6%E3%82%92%E6%8C%87%E5%AE%9A%E3%81%99%E3%82%8B

> `:if` と `:unless` オプションは、procやメソッド名のシンボルの配列を受け取ることも可能です。

シンボルの配列を渡せることが分かったので、以下のように変更しました。

```rb
  after_update :some_function, if: [:saved_change_to_name?, :saved_change_to_email?]
```

すると、`name` と `email` のどちらを変更しても、`some_function` が呼ばれなくなってしまいました。

```rb
irb(main):004:0> u.update(name: "test1")
  User Exists? (0.3ms)  SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER(?) AND "users"."id" != ? LIMIT ?  [["email", "test2@example.com"], ["id", 1], ["LIMIT", 1]]
  User Update (0.5ms)  UPDATE "users" SET "name" = ?, "updated_at" = ? WHERE "users"."id" = ?  [["name", "test1"], ["updated_at", "2022-06-20 13:12:59.072391"], ["id", 1]]
  TRANSACTION (6.2ms)  commit transaction
=> true
irb(main):005:0> u.update(email: "test@example.com")
  User Exists? (0.3ms)  SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER(?) AND "users"."id" != ? LIMIT ?  [["email", "test@example.com"], ["id", 1], ["LIMIT", 1]]
  User Update (0.4ms)  UPDATE "users" SET "email" = ?, "updated_at" = ? WHERE "users"."id" = ?  [["email", "test@example.com"], ["updated_at", "2022-06-20 13:13:44.376203"], ["id", 1]]
  TRANSACTION (15.5ms)  commit transaction
=> true
```

もしや、と思い試しに一度に両方変更したところ、`"some_function called!"` が表示されました。

```rb
irb(main):008:0> u.update(name: "test3", email: "test3@example.com")
  TRANSACTION (0.1ms)  begin transaction
  User Exists? (0.3ms)  SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER(?) AND "users"."id" != ? LIMIT ?  [["email", "test3@example.com"], ["id", 1], ["LIMIT", 1]]
  User Update (0.4ms)  UPDATE "users" SET "name" = ?, "email" = ?, "updated_at" = ? WHERE "users"."id" = ?  [["name", "test3"], ["email", "test3@example.com"], ["updated_at", "2022-06-20 13:14:12.554928"], ["id", 1]]
"some_function called!"
  TRANSACTION (6.7ms)  commit transaction
=> true
```

`if: [:saved_change_to_name?, :saved_change_to_email?]` のように書くと、両方の条件が満たされる場合にのみ実行されるようになることがわかりました。

もう一度 Rails ガイドを読み直したおじいさんは、 コールバックの条件に `Proc` が指定できることに気づき、最終的に以下のようにすることでどちらかの条件が満たされる場合に `some_function` が実行されるコードを実装することができました。

```
  after_update :some_function, if: -> { saved_change_to_name? || saved_change_to_email? }
```

動作確認も問題なく、それぞれを変更した際に、`some_function` が実行されました。

```rb
irb(main):002:0> u.update(name: "test")
  TRANSACTION (0.1ms)  begin transaction
  User Exists? (0.3ms)  SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER(?) AND "users"."id" != ? LIMIT ?  [["email", "test3@example.com"], ["id", 1], ["LIMIT", 1]]
  User Update (0.6ms)  UPDATE "users" SET "name" = ?, "updated_at" = ? WHERE "users"."id" = ?  [["name", "test"], ["updated_at", "2022-06-20 13:17:40.394976"], ["id", 1]]
"some_function called!"
  TRANSACTION (8.9ms)  commit transaction
=> true
irb(main):003:0> u.update(email: "test@example.com")
  TRANSACTION (0.1ms)  begin transaction
  User Exists? (0.4ms)  SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER(?) AND "users"."id" != ? LIMIT ?  [["email", "test@example.com"], ["id", 1], ["LIMIT", 1]]
  User Update (1.4ms)  UPDATE "users" SET "email" = ?, "updated_at" = ? WHERE "users"."id" = ?  [["email", "test@example.com"], ["updated_at", "2022-06-20 13:17:45.752444"], ["id", 1]]
"some_function called!"
  TRANSACTION (7.2ms)  commit transaction
=> true
```

これでおじいさんは改めて Pull Request を提出し、おばあさんの機嫌を損ねることなく急な仕様変更に対応できました。

めでたしめでたし。

# まとめ

- あるコールバック関数に対し、複数の条件がある場合、コールバックを複数に分けるのではなく一つにまとめる必要がある。
    - 上書きされてしまうため、最後に指定したコールバックしか実行されない。
- Symbol を指定した場合
    - 例: `after_update :some_function, if: :saved_change_to_name?`
        - `saved_change_to_name?` が `true` のときに実行される
- Array を指定した場合
    - 例: `after_update :some_function, if: [:saved_change_to_name?, :saved_change_to_email?]`
        - `saved_change_to_name?` と `saved_change_to_email?` の両方が `true` のときに実行される
- Proc を指定した場合
    - 例: `after_update :some_function, if: -> { saved_change_to_name? || saved_change_to_email? }`
        - Proc の結果が偽ではないときに実行される。