こんにちは。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回
- 2回
- 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_to
にoptional: 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 # このとき何が起こる?
回答の選択肢
- 新しいProfileモデルが生成され、既存のProfileモデルの外部キーがnullになる
- 新しいProfileモデルが生成され、既存のProfileモデルが削除される
- 例外が発生する
- 新しいProfileモデルが生成されるだけ
正解
/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/singular_association.rb at 90357af08048ef5076730505f6e7b14a81f33d0c · rails/rails
- rails/has_one_association.rb at 90357af08048ef5076730505f6e7b14a81f33d0c · rails/rails
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
- 5
- 25
- 30
- 125
正解
Active Jobとsidekiqそれぞれにリトライの仕組みがあります。Active Jobのリトライ回数はretry_on StandardError, attempts: 5
で設定しているように5回です。
まずActive Job内のリトライ回数を管理しているコードを見てみます。リトライのエンキュー時にリトライ回数を含めてシリアライズし、実行時にデシリアライズされます。もちろんリトライ回数はリトライごとにインクリメントされます。コード的にはこのあたりを眺めるとなんとなくどう動くのかわかると思います。
- https://github.com/rails/rails/blob/90357af08048ef5076730505f6e7b14a81f33d0c/activejob/lib/active_job/core.rb#L28-L35
- https://github.com/rails/rails/blob/90357af08048ef5076730505f6e7b14a81f33d0c/activejob/lib/active_job/exceptions.rb#L58-L61
- https://github.com/rails/rails/blob/90357af08048ef5076730505f6e7b14a81f33d0c/activejob/lib/active_job/exceptions.rb#L157-L164
- https://github.com/rails/rails/blob/90357af08048ef5076730505f6e7b14a81f33d0c/activejob/lib/active_job/core.rb#L102-L103
まず最初に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回
- 2回
- 3回
- 4回
正解
デフォルトだと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"
- "2"
["1", "2"]
- 例外
正解
「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)はそれぞれクエリを発行するかどうか?
回答の選択肢
- A: しない, B: しない
- A: する, B: しない
- A: しない, B: する
- 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) なにが返る?
回答の選択肢
- A: 保存される, B: true
- A: 保存される, B: false
- A: 保存されない, B: true
- A: 保存されない, B: false
正解
「3. A: 保存されない, B: true」
has_many
な関連のデフォルトでは、関連先は新規レコードのときだけ保存されます(has_one
やbelongs_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.first
はuser.posts.to_a
などとして返すオブジェクトと別のオブジェクトを返します)。
というわけで紛らわしいのでautosaveは基本的には信用しないで直接post.save
とするのが良さそうです。
まとめ
何問正解できましたか?もっと問題解きたいな、と思った方は下記の採用サイトからお気軽にエントリーください!楽しくクイズしましょう(\( ⁰⊖⁰)/)