SmartHR Tech Blog

SmartHR 開発者ブログ

Railsクイズ、何問解けるかな?

こんにちは。SmartHRでRails顧問業をしています @willnetです。最近は主にリファクタリングをしています。

SmartHRでは毎週「Rubyist@SmartHR(仮)」という名の定例ミーティング*1が行われています。このミーティングはバックエンドエンジニアが集まり、チームをまたいだ情報共有や相談をすることを目的としています。その中では僕がTipsなどを共有する「willnetさんのありがたいお言葉」というコーナーが常設されています。

「willnetさんのありがたいお言葉」のコーナーではRailsの最新動向に関する話をすることが多いのですが、最近はRailsの各種機能がどのように動くのかをクイズ形式にして共有しています。これがなかなか好評なので今回テックブログにしてみた次第です。みんな全問正解できるかな?

ちなみにこんな感じでやってます

まず問題と回答の選択肢を見せてからslackのPollyを使って正解だと思うものに投票してもらい、そこから正解とウンチクを共有する、という流れです。

正解を公開したときのslackの反応。出題側としては嬉しい反応です😃

前提

最新安定版を使っている前提です。具体的には↓

  • Ruby 3.0.2
  • Rails 6.1.4.1
  • sidekiq 6.2.2

Railsクイズ1

class User < ApplicationRecord
  has_many :posts
  belongs_to :company
end
user = User.first
user.name = '変更したよ'
user.save # ここで走るクエリの数は何回でしょうか

回答の選択肢

  1. 1回
  2. 2回
  3. 3回

答え

「2. 2回」

usersテーブルのupdateとcompaniesテーブルのselectが実行されます。ログの例↓

Company Load (0.1ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
User Update (0.3ms)  UPDATE "users" SET "name" = ?, "updated_at" = ? WHERE "users"."id" = ?  [["name", "変更したよ"], ["updated_at", "2021-07-15 02:50:39.214209"], ["id", 1]]

belongs_toは暗黙のうちにvalidate_presence_of :companyを定義するので、user.companyが未ロードであればselectして、存在しているかどうかチェックします。 これを回避するにはbelongs_tooptional: true(もしくは required: false) を付ける必要があります。

参考: 関連コード

Railsクイズ2

class User < ApplicationRecord
  has_one :profile
end

class Profile < ApplicationRecord
  belongs_to :user
end
user = User.first
user.profile #=> Profileモデルオブジェクトが返ってくる
user.build_profile # このとき何が起こる?

回答の選択肢

  1. 新しいProfileモデルが生成され、既存のProfileモデルの外部キーがnullになる
  2. 新しいProfileモデルが生成され、既存のProfileモデルが削除される
  3. 例外が発生する
  4. 新しいProfileモデルが生成されるだけ

正解

「3. 例外が発生する」

/Users/willnet/.rbenv/versions/3.0.2/lib/ruby/gems/3.0.0/gems/activerecord-6.1.4/lib/active_record/associations/has_one_association.rb:105:in `remove_target!': Failed to remove the existing associated user_token. The record failed to save after its foreign key was set to nil. (ActiveRecord::RecordNotSaved)

has_one関連を宣言して生えてきたbuild_関連名メソッドを実行すると、既存の関連先との関連を暗黙のうちに削除し、新しく作ったオブジェクトと差し替えるという挙動になります。このときどのように削除するかはdependentオプションの設定次第で変わってきます。

  • dependent: :destroyだったら既存のProfileがdestroyで削除される
  • dependent: :deleteだったら既存のProfileがdeleteで削除される
  • それ以外はnullify(外部キーをnullにして更新)の挙動になります

デフォルトの挙動はnullifyです。しかし大抵のケースでhas_one関連先はbelongs_toを関連元に設定している(今回のケースもそうです)ので、Railsクイズ1で説明した通りvalidates_presence_ofが暗黙的に設定されています。つまりnullifyの「外部キーをnullにして更新」はバリデーションエラーになり、最終的にはActiveRecord::RecordNotSaved例外が発生します。

関連コード

Railsクイズ3

# config/application.rb

config.active_job.queue_adapter = :sidekiq
class Counter < ApplicationRecord
  # countカラム(integer)があるだけのモデル
end

class RetryJob < ApplicationJob
  queue_as :default

  retry_on StandardError, attempts: 5

  def perform
    counter = Counter.first || Counter.create!(count: 0)
    counter.increment!(:count)
    raise
  end
end
bundle exec sidekiq # sidekiqはすべてデフォルトの設定
bin/rails r 'RetryJob.perform_later'

リトライが全部終わったときのCounter.first.countは何を返す?

回答の選択肢

  1. 1
  2. 5
  3. 25
  4. 30
  5. 125

正解

「4. 30」

Active Jobとsidekiqそれぞれにリトライの仕組みがあります。Active Jobのリトライ回数はretry_on StandardError, attempts: 5で設定しているように5回です。

まずActive Job内のリトライ回数を管理しているコードを見てみます。リトライのエンキュー時にリトライ回数を含めてシリアライズし、実行時にデシリアライズされます。もちろんリトライ回数はリトライごとにインクリメントされます。コード的にはこのあたりを眺めるとなんとなくどう動くのかわかると思います。

まず最初にActive Jobのリトライが5回実行されます。Active Jobのリトライは、単に「sidekiqに新しいジョブを実行させる」というもので、Active Job内で例外を出したジョブはsidekiq側では正常終了した扱いになります。Active Jobのジョブが5回連続で失敗して初めてsidekiq側で例外をキャッチします。

そしてsidekiqのリトライが始まります。sidekiqのリトライ回数はデフォルト25回です( 参考: Error Handling · mperham/sidekiq Wiki )。

ここでまたActive Jobのリトライが開始する…と思いきや、Active Jobのリトライを5回実行されたという情報がキューに残っているため、Active Jobのリトライは実行されません。ここからはsidekiqのリトライだけが25回繰り返されることになります。

Active Jobのリトライ5回分とsidekiqのリトライ25回分が実行されるとジョブはようやくdeadになります。よって5+25=30が正解になります。

Active Jobを使うときには、adapter側のもっているリトライの仕組みとActive Jobの持っているリトライの仕組みのどちらを使うか検討しましょう。

Railsクイズ4

postgresqlを利用していて、companiesというテーブルがあります。カラムはnameだけ(created_at, updated_atは除く)。

# db/schema.rb
ActiveRecord::Schema.define(version: 2021_08_05_083543) do

  # These are extensions that must be enabled in order to support this database
  enable_extension "plpgsql"

  create_table "companies", force: :cascade do |t|
    t.string "name"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end

end

次のような新規マイグレーションファイルがあるとします。

class ChangeCompany < ActiveRecord::Migration[6.1]
  def up
    change_table :companies do |t|
      t.change :name, :string, limit: 20
      t.column :email, :string
      t.index :email, unique: true
    end
  end

  def down
    change_table :companies do |t|
      t.change :name, :string
      t.remove :email
    end
  end
end

↑をbin/rails db:migrateで適用したとき発行されるDDL文の数はいくつになるでしょう?

回答の選択肢

  1. 1回
  2. 2回
  3. 3回
  4. 4回

正解

「3. 3回」

デフォルトだと1メソッドごとにDDL文が実行されます。

   (5.1ms)  ALTER TABLE "companies" ALTER COLUMN "name" TYPE character varying(20)
   (3.4ms)  ALTER TABLE "companies" ADD "email" character varying
   (3.4ms)  CREATE UNIQUE INDEX "index_companies_on_email" ON "companies" ("email")

次のように、bulk: trueをつけるとALTER TABLEをひとまとめにすることができ、DDL発行回数を減らすことができます(MySQLとPostgreSQL限定)。

class ChangeCompany < ActiveRecord::Migration[6.1]
  def up
    change_table :companies, bulk: true do |t| # 差分はここだけ
      t.change :name, :string, limit: 20
      t.column :email, :string
      t.index :email, unique: true
    end
  end

  def down
    change_table :companies do |t|
      t.change :name, :string
      t.remove :email
    end
  end
end

発行されるDDL文の例

   (10.1ms)  ALTER TABLE "companies" ALTER COLUMN "name" TYPE character varying(20), ADD "email" character varying
   (4.2ms)  CREATE UNIQUE INDEX "index_companies_on_email" ON "companies" ("email")

レコード数が大きいテーブルを変更するときは必ずbulk: trueをつけましょう。

余談ですが @willnet は1億レコードあるテーブルに3カラム追加するときにbulk: trueをつけ損ねて、40分ですむALTER_TABLEにその3倍の時間をかけたことがあります…><

Railsクイズ5

リクエストがhttp://example.com/?q=1&q=2のような形できたらparams[:q]はどうなる?

回答の選択肢

  1. "1"
  2. "2"
  3. ["1", "2"]
  4. 例外

正解

「2. "2"」

単純に後勝ちです。クエリパラメータのパースはRack::QueryParserでやっているので興味あるひとはどうぞ。rack/query_parser.rb at a05f8d56f9ac4da14dddb8f312a3b43644f73397 · rack/rack

もし?q=1&q=2でリクエストが来て、両方のqを扱わないといけないのであればrequest.fullpathなどでパスを全部取得して独自にパースするしかないかもしれません(いい方法知ってたら教えて下さい)

ちなみにparams[:q]を配列にしたい場合は?q[]=1&q[]=2、ハッシュにしたい場合はq[a]=1&q[b]=2のようなリクエストを送信します。

Railsクイズ6

class Post < ApplicationRecord
  belongs_to :user
end

class User < ApplicationRecord
  has_many :posts
  has_many :desc_posts, -> { order(created_at: :desc) }, class_name: 'Post'
end
user = User.first
post1 = user.posts.first
post2 = user.desc_posts.first
post1.user # (A)
post2.user # (B)

(A), (B)はそれぞれクエリを発行するかどうか?

回答の選択肢

  1. A: しない, B: しない
  2. A: する, B: しない
  3. A: しない, B: する
  4. A: する, B: する

正解

「 3 A: しない, B: する」

今回のように関連先から関連元を取得するようなケースでは、関連元はすでにメモリ上にいるのでうまくやればクエリ発行は不要である、というのは想像がつくのではないでしょうか。Railsはこれを可能な限り自動でうまくやってくれます。

自動でうまくいかないケースに対応するために、各関連メソッド(has_many has_one belongs_to)にはinverse_ofというオプションが用意されています。inverse_ofの値として設定した関連名が関連元とみなされ、クエリ発行不要でアクセスできます。

自動で推測できる条件はこれです。 https://github.com/rails/rails/blob/174ee7bb602f63e7a5c44ec52e6592f3a5dd10b1/activerecord/lib/active_record/reflection.rb#L639-L653 *2

desc_posts関連はscopeがついていたので自動で関連元を推測できないのでした。has_many :desc_posts, -> { order(created_at: :desc) }, inverse_of: :user, class_name: 'Post' のようにすると(B)はクエリを発行せずにUserオブジェクトを返すことができます。

Railsクイズ7

class User < ApplicationRecord
  has_many :posts
end

class Post < ApplicationRecord
  belongs_to :user
  validates :title, length: { maximum: 10 }
end
user = User.first
user.posts.each_with_index { |post, i| post.title = "fuga#{i}"}
user.save # (A) postsは保存される?
Post.update_all(title: 'a' * 11) 
user = User.first
user.valid? # (B) なにが返る?

回答の選択肢

  1. A: 保存される, B: true
  2. A: 保存される, B: false
  3. A: 保存されない, B: true
  4. A: 保存されない, B: false

正解

「3. A: 保存されない, B: true」

has_manyな関連のデフォルトでは、関連先は新規レコードのときだけ保存されます(has_onebelongs_toも同様です)。更新する場合は直接post.saveのようにする必要があります。

もし関連元が保存されたときに一緒に関連先も保存したい、という場合は次のようにautosave: trueをつけます。

class User < ApplicationRecord
  has_many :posts, autosave: true
end

ちなみにaccepts_nested_attributes_forを定義していると、その関連は自動でautosave: trueになるので注意しましょう。

バリデーションも基本的には同様なのですが、仮にautosave: trueとしても(B)はtrueが返ってきてしまいます。autosave: trueとしても、関連先がdirtyでない限りは保存(やvalidate)をしない、という仕様になっているのでした。

でも (B)で常にvalidationしてほしい!という状況があるかもしれませんね。その場合は次のようにvalidates_associated :postsを宣言すると、引数に渡した関連が常にvalidateされます。

class User < ApplicationRecord
  has_many :posts
  validates_associated :posts
end

もう一つ細かい話をすると、次のコードはautosave: trueとしていてもpostは更新されません…。

user = User.first
post = user.posts.first
post.title = 'fuga'
user.save

あくまでpostsという関連オブジェクトに紐づくモデルがdirtyでないといけないようです(user.posts.firstuser.posts.to_aなどとして返すオブジェクトと別のオブジェクトを返します)。

というわけで紛らわしいのでautosaveは基本的には信用しないで直接post.saveとするのが良さそうです。

まとめ

何問正解できましたか?もっと問題解きたいな、と思った方は下記の採用サイトからお気軽にエントリーください!楽しくクイズしましょう(\( ⁰⊖⁰)/)

hello-world.smarthr.co.jp

*1:バックエンドミーティングやバックエンド定例とも呼ばれています

*2:6.1.4.1のコードよりもmainブランチの最新のほうが読みやすくなっていたので意図的にmainブランチのリンクをはっています