Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

週刊Railsウォッチ: AR::RelationにCTEを利用できるwithメソッドが追加、Propshaftアップグレードガイドほか(20220711前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

  • 各記事冒頭には🔗でパーマリンクを置いてあります: 社内やTwitterでの議論などにどうぞ
  • 「つっつきボイス」はRailsウォッチ公開前ドラフトを(鍋のように)社内有志でつっついたときの会話の再構成です👄
  • お気づきの点がありましたら@hachi8833までメンションをいただければ確認・対応いたします🙏

TechRachoではRubyやRailsなどの最新情報記事を平日に公開しています。TechRacho記事をいち早くお読みになりたい方はTwitterにて@techrachoのフォローをお願いします。また、タグやカテゴリごとにRSSフィードを購読することもできます(例:週刊Railsウォッチタグ)

🔗Rails: 先週の改修(Rails公式ニュースより)

Changelogをチェックした後で、公式更新情報も出ていたことに気づきました↓。既に扱ったものも結構あるようなので、次回見ようと思います。

参考: Ruby on Rails — Improved PostgreSQL support, performance improvements and more...

🔗 Active Recordのin_batchesuse_ranges: trueを指定可能になった

  • Active Recordにおけるテーブル全体イテレーションのバッチを最適化

従来のin_batchesは、すべてのidを受け取ってからバッチごとにINベースのクエリを構築していた。これはテーブル全体をイテレーションする場合に最適ではない(不要なidが読み込まれるうえに、項目数の多いINクエリは遅くなる)。
この改修によって、テーブル全体のイテレーションがデフォルトでrangeによるイテレーション(id >= x AND id <= y)を使うようになり、イテレーションが数倍高速になる。たとえば、PostgreSQLで1000万件のレコードでテストした場合、読み出しが253s->30s、更新が288s->124s、削除が268s->83sとなった。

この方法がデフォルトで使われるのは、テーブル全体のイテレーションのみ。この振る舞いは、use_ranges: falseを渡すと無効にできる。
たとえば、テーブル全体をイテレーションするときの条件がarchived_at: nilのみで、かつアーカイブ済みレコードの件数がわずかしかないような場合は、以下のようにこの方法を採用するのが理にかなっている。

Project.where(archived_at: nil).in_batches(use_ranges: true) do |relation|
  # 何かする
end

詳しくは#45414を参照。
fatkodima
同Changelogより


つっつきボイス:「in_batchで1つのテーブル全体を操作する場合にid >= x AND id <= yのようにrangeで取得すれば速くなる、たしかに」「use_ranges: trueを指定するとrangeで取れるようになるんですね」

「ところでこの方法は、idカラムがintegerでオートインクリメントされていることが前提だと思うので、idがたとえばUUIDだとうまくいかないでしょうね」「それ今同じこと思いました」「エンタープライズ寄りの案件だとidがUUIDになっていることが最近増えているので、そういうときは注意が必要かな」

参考: UUID - Wikipedia

🔗 ActiveRecord::RelationにCTEを利用できるwithメソッドが追加

このプルリクで追加されるActiveRecord::Relation::QueryMethods#withを使うと、CTE(Common Table Expressions)による複雑なクエリを非常に手軽にビルドできるようになる。
基本的には、渡された引数をArel::SelectManager.withで期待されている方法で「ラップ」しているだけ。これによる最も大きなメリットは以下のとおり。

  • Arel::Nodes::Asノードを手動でビルドせずに、標準的なActive Recordリレーションだけでやれるようになる
  • ActiveRecord::Relationが返されるので柔軟性が失われない

以下の例を参照のこと。

Post.with(
  posts_with_comments: Post.where("comments_count > ?", 0),
  posts_with_tags: Post.where("tags_count > ?", 0)
)
# 上は以下の従来コードを置き換えられる

posts_with_comments_table = Arel::Table.new(:posts_with_comments)
posts_with_comments_expression = Post.arel_table.where(posts_with_comments_table[:comments_count].gt(0))
posts_with_tags_table = Arel::Table.new(:posts_with_tags)
posts_with_tags_expression = Post.arel_table.where(posts_with_tags_table[:tags_count].gt(0))

Post.all.arel.with([
  Arel::Nodes::As.new(posts_with_comments_table, posts_with_comments_expression),
  Arel::Nodes::As.new(posts_with_tags_table, posts_with_tags_expression)
])
# WITH posts_with_comments AS (
#   SELECT * FROM posts WHERE (comments_count > 0)
# ), posts_with_tags AS (
#   SELECT * FROM posts WHERE (tags_count > 0)
# )
# SELECT * FROM posts

同PRより


つっつきボイス:「ActiveRecord::RelationでCTEのWITH句が使えるようになる🎉」「おほ、いい書き方❤️」

参考: 7.8. WITH問い合わせ(共通テーブル式)

withを使うと冒頭のサンプルのように4行で書けるのね」「その下にある同等のコードを見ると、処理ごとにArel::Table.newを生成していますね」「Arelテーブルとして生成する、なるほど」「WITH句を使うときに与える必要のある名前も指定できる」「しかもPost.where("comments_count > ?", 0)のような部分は普通のActive Recordで書けるので、生SQLで書く場合と違ってちゃんと型解決されるのはうまくできてますね」「そうそう、生SQLだと型解決忘れがち😅」

「CTEが公式に使えるだけでもありがたいし、ActiveRecord::Relation::QueryMethods#withでこういう書き方ができるなら使いやすそう👍」「Arel自体には前からwithが定義されているので、従来コードは今でも書けるはずですね」「withで書ける方が見やすい」「こっち使いたい」

「よく見るとこのプルリクは2019年にオープンされてますね」「3年越しか〜」

🔗 ActiveSupport::Deprecationの振る舞いをカスタマイズしたときのNoMethodErrorを修正

ActiveSupport::Deprecation.behavior=は、callに応答する任意のオブジェクトを受け取るとされていたが、実際の内部実装はオブジェクトがarityにも応答することを前提としていたため、Procオブジェクトに限定されていた。
この変更によって、振る舞いをカスタマイズしたときのarity制約がなくなった。
Ryo Nakamura
同PRより


つっつきボイス:「ActiveSupport::DeprecationでNoMethodErrorが起きていたのが修正された」「エラーで止まってしまうのは勘弁して欲しいヤツですね」

🔗 rails newの暗黙のオプションの扱いを改善

このコミットは、AppGeneratorPluginGeneratorの暗黙のオプションの扱いをさまざまな点で改良する。
暗黙のオプションは以下のように出力されるようになる。

$ rails new my_cool_app --skip-active-job
Based on the specified options, the following options will also be activated:

  --skip-action-mailer [due to --skip-active-job]
  --skip-active-storage [due to --skip-active-job]
  --skip-action-mailbox [due to --skip-active-storage]
  --skip-action-text [due to --skip-active-storage]

...

オプション同士がコンフリクトしている場合はエラーを出力する。

$ rails new my_cool_app --skip-active-job --no-skip-active-storage
Based on the specified options, the following options will also be activated:

  --skip-action-mailer [due to --skip-active-job]
  --skip-active-storage [due to --skip-active-job]
    ERROR: Conflicts with --no-skip-active-storage
  --skip-action-mailbox [due to --skip-active-storage]
  --skip-action-text [due to --skip-active-storage]

railties/lib/rails/generators/app_base.rb:206:in `report_implied_options': Cannot proceed due to conflicting options (RuntimeError)

--minimalなどのメタオプションによる暗黙のオプションも具体的に出力される。

$ rails new my_cool_app --minimal
Based on the specified options, the following options will also be activated:

  --skip-active-job [due to --minimal]
  --skip-action-mailer [due to --skip-active-job, --minimal]
  --skip-active-storage [due to --skip-active-job, --minimal]
  --skip-action-mailbox [due to --skip-active-storage, --minimal]
  --skip-action-text [due to --skip-active-storage, --minimal]
  --skip-javascript [due to --minimal]
  --skip-hotwire [due to --skip-javascript, --minimal]
  --skip-action-cable [due to --minimal]
  --skip-bootsnap [due to --minimal]
  --skip-dev-gems [due to --minimal]
  --skip-system-test [due to --minimal]

...

--no-*オプションをメタオプションと組み合わせることが可能になり、すべてのオプションのリストが正確に出力される。

$ rails new my_cool_app --minimal --no-skip-active-storage
Based on the specified options, the following options will also be activated:

  --skip-action-mailer [due to --minimal]
  --skip-action-mailbox [due to --minimal]
  --skip-action-text [due to --minimal]
  --skip-javascript [due to --minimal]
  --skip-hotwire [due to --skip-javascript, --minimal]
  --skip-action-cable [due to --minimal]
  --skip-bootsnap [due to --minimal]
  --skip-dev-gems [due to --minimal]
  --skip-system-test [due to --minimal]

...

つっつきボイス:「先週の--minimalオプションの改修(ウォッチ20220704)の続きっぽい」「スキップするオプション同士のコンフリクトが検出されるようになったり、--minimalのようなメタオプションで何がスキップされるのかを具体的に表示されるようになった」「言われてみれば何がスキップされるのか割と謎でしたね」

rails newのオプションなので通常の開発で使う頻度は低いと思いますが、オプション同士がコンフリクトしたときにソースコードを追わなくても原因がわかるのはいい👍」「自分はrails newをしょっちゅう使うのでありがたいです🙏」

🔗 cookieを設定したときにSameSite属性をオプトアウト可能になった

#37974以降、SameSite属性をcookieから除外できなくなった。SameSiteはたしかに推奨フィールドだが必須ではないので、オプトアウトも可能にすべき。
このプルリクは、以下のようにsame_site: nilを指定することでオプトアウトを可能にする。

cookies[:foo] = { value: "bar", same_site: nil }

従来は上を指定すると、cookies_same_site_protection設定の値でSameSite属性が誤った形で設定されていた。#44934で追加されたドキュメントにはnilを値として渡せると書かれているが、そうするとデフォルト値(:lax)にフォールバックしてしまう。
同PRより


つっつきボイス:「cookieのSameSite属性をオプトアウトできるようになったそうです」「フレームワークとしてはデフォルトでSameSite属性を有効にしたうえでオプトアウト可能にするのが望ましいでしょうね」「オプトアウトしたいのはどんなときだろう🤔」

参考: SameSite cookies - HTTP | MDN

🔗Rails

🔗 fixtureについて話しておきたい


つっつきボイス:「自分がfixtureが好きな理由のひとつとして、fixtureを即物的に書いていればコードによって変化しないというのがあります: でもテストが複雑になったときに以下の<%= User::B2C_TYPE %>みたいにfixtureの中にfactory_botみたいなものを書くぐらいだったら、factory_botでロジカルに生成する方がいいというのもわかる」

# 同記事より
# spec/fixtures/users.yml

matheus:
  first_name: Matheus
  last_name: Sales
  role: admin
  type: <%= User::B2C_TYPE %>

# spec/fixtures/roles.yml

admin:
  name: admin
user:
  name: user

「自分は割とfixtureが好きなんですけど、周りを見るとfactory_botで書くのが好きな人も多いですね」「factory_bot使いますっ」「使いますっ」「ロジカルに書くならfactory_botだと思うけど、ベタに列挙する程度ならfixtureで十分かなという気持ち」

thoughtbot/factory_bot - GitHub

Rails 7 API: ActiveRecord::FixtureSet(翻訳)


「この記事にあったconnascenceという言葉が気になりました↓」「コードの結合や癒着の度合いを表す専門用語みたい」「元記事には、名前のconnascence、位置のconnascence、意味のconnascenceがあると書かれてますね」

参考: 結合度の尺度「コナーセンス」とは何か - Qiita

🔗 マルチテナンシーとWebSocket


つっつきボイス:「AnyCable作者のVladimir Dementyevさん(Evil Martians)が運営しているanycable.ioのブログ記事です」「タイトルのvs.は、マルチテナンシーのアプリでAction CableやAnyCableを使うことを指しているようですね」

AnyCable 1.0: RubyとGoによるリアルタイムWebの4年間(翻訳)

「マルチテナンシーのアプリでは、今どのテナントのスコープにいるかがとても重要になるんですよ」「なるほど」「最近以下のようなコードをよく見かけますよね?」「最近一般的なマルチテナントなアプリケーションだとこういうの多いですね」「マルチテナンシーではまさにset_current_tenantのような書き方をします」

# 同記事より
class ApplicationController < ActionController::Base
  before_action do
    current_account = find_account_from_request_or_whatever
    set_current_tenant(current_account)
  end
end

「記事にもあるように、マルチテナンシーでAction Cableを使う場合もそういうものを扱う必要があります」「request.subdomainでテナントを切り替えるのは、SlackやAtlassianのプロダクトのようなマルチテナントアプリでよく見かけますね」

# 同記事より
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user, :tenant

    def connect
      self.tenant = request.subdomain
      Apartment::Tenant.switch!(tenant)
    end
  end
end

「Railsで普通使われる通常のHTTPリクエストの場合は、リクエストの中で使うコンテキストはそのリクエストの中で閉じることを前提にできるんですよ」「ふむふむ」「でもAction Cableの場合は1個のWebSocketを使い回すので、メッセージ間のコンテキストが分離されるとは限らない: つまりコンテキストを適切に切り替えないと事故につながります」「テナント同士の情報が混じったら非常にマズいですね」「漏洩事故ですね」

「おそらく一番ややこしいのは、1個のWebSocketの中でコンテキストを切り替えるときでしょうね: using_current_tenant { super }の部分とかがそう↓」「あ〜なるほど」「こういうところで事故ると怖いので、個人的にはクライアント側がアクセスしたいテナントごとにWebSocketごと切り替える方がいいのではという気持ちがあります」「それもわかる」「記事はAction CableとAnyCableそれぞれについて書かれていますが、このあたりはどちらを使おうと同じ注意が必要ですね」「なるほど」

# 同記事より
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    # ...

    # Make all channel commands tenant-aware
    def dispatch_websocket_message(*)
      using_current_tenant { super }
    end

    # The same override for AnyCable, which uses a different method
    def handle_channel_command(*)
      using_current_tenant { super }
    end
  end
end

参考: WebSocket API (WebSockets) - Web API | MDN

🔗 Sprockets->Propshaft公式アップグレードガイド


つっつきボイス:「このガイドがあることに最近気づいたので近々翻訳を出します」「そういえば少し前からできていた覚えがあるかも」「こういうドキュメントが整備されてくるとやりやすくなりますね👍」


前編は以上です。

バックナンバー(2022年度第3四半期)

週刊Railsウォッチ: 6月のRubyコア動向、Stack Overflowアンケート結果ほか(20220705後編)

今週の主なニュースソース

ソースの表記されていない項目は独自ルート(TwitterやはてブやRSSやruby-jp SlackやRedditなど)です。

Rails公式ニュース


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。