11
3

More than 1 year has passed since last update.

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

Posted at

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

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

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

急な仕様変更に戸惑いながらも不承不承ながら了承したおじいさんは、とりあえず現在の実装を確認しました。
user.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? に変えた行を追加すればよいのでは?」と考え、以下のように変更しました。

  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 consoleemail を変更して動作確認をしてみました。

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 が呼ばれなくなってしまったようです。

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 ガイドを読んで、複数の条件を指定する方法を見つけました。

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

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

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

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

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!" が表示されました。

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 が実行されました。

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 の結果が偽ではないときに実行される。
11
3
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
3