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

週刊Railsウォッチ(20201214前編)Rails 6.1の直近コミットを見る、RuboCop Rails 2.9リリース、ar_lazy_preload gemほか

こんにちは、hachi8833です。Rails 6.1がリリースされましたね。

速報: Ruby on Rails 6.1がリリースされました

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

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

今回はいつもと趣向を変えて、6-1-stableブランチの直近のプルリクを中心に見繕いました。したがって、以下のいずれのプルリクも6.1にマージされています。

6.1: バリデーション時のstrict loadingを修正

strict loadingはバリデーション時にエラーを出して欲しくない(バリデーションのためにレコードを読み込む必要があるため)。
今回の変更では、owner.validation_contextをチェックするようにした。これがnilの場合は、createやupdateでオブジェクトを現在バリデーションしていないことがわかる。それ以外の値が設定されている場合はバリデーション中なので、strict loadingでraiseをスキップしたい。
同PRより大意


つっつきボイス:「validation_contextとは...?」「なるほど、strict loadingを有効にすると、バリデーションの実行中にエラーになったときにraiseされてしまっていたので、バリデーション中はエラーを投げないようにしたということのようですね」

# activerecord/lib/active_record/associations/association.rb#L212
      private
        def find_target
-         if owner.strict_loading?
+         if owner.strict_loading? && owner.validation_context.nil?
            Base.strict_loading_violation!(owner: owner.class, association: klass)
          end

-         if reflection.strict_loading?
+         if reflection.strict_loading? && owner.validation_context.nil?
            Base.strict_loading_violation!(owner: owner.class, association: reflection.name)
          end

「テストコードを見ると、AuditLogRequiredモデルのrequired: trueがバリデーションに失敗したときにralseしないことをチェックしている↓」

# activerecord/test/cases/strict_loading_test.rb#L89
  def test_strict_loading_is_ignored_in_validation_context
    with_strict_loading_by_default(Developer) do
      developer = Developer.first
      assert_predicate developer, :strict_loading?

      assert_nothing_raised do
        AuditLogRequired.create! developer_id: developer.id, message: "i am a message"
      end
    end
  end

「このプルリクの日時を見ると、わずか15時間前(つっつき時点)にマージされてたんですね」「Rails 6.1がリリースされる直前じゃないですか」「間に合ってよかった🎉」

6.1: I18n.translateでキーがStringの場合に対応

# actionview/lib/action_view/helpers/translation_helper.rb#L70
      def translate(key, **options)
        return key.map { |k| translate(k, **options) } if key.is_a?(Array)
+       key = key.to_s unless key.is_a?(Symbol)

        alternatives = if options.key?(:default)
          options[:default].is_a?(Array) ? options.delete(:default).compact : [options.delete(:default)]
        end

つっつきボイス:「translateのキーがシンボルでなくStringの場合も対応するようになったのね」「前はStringでもできてたような気がしたけど、#39989のコメントを見ると、そこでパフォーマンスを改善したときにto_sを外したことでキーがたとえばIntegerの場合にうまくいかなくなると指摘されているので、それを受けて上の#40773でキーがシンボルでない場合にも対応できるよう修正したという流れのようですね」「なるほど!」「translateのキーに数値を入れることは実際にはあまりなさそうですけどね」

「要するにシンボルでなかったら数値でも何でも文字列にしとけば大丈夫と」「Rubyのto_sはObjectクラスにあるので、Objectクラスを継承するオブジェクトは必ずto_sで文字列に変えられますよね」

ドキュメント: Object#to_s (Ruby 2.7.0 リファレンスマニュアル)

6.1: Ruby 3.0のStringの挙動修正に対応

Ruby 3では、Stringクラスのメソッドがサブクラスのインスタンス上で呼び出されたときにStringクラスのメソッドが常にStringのインスタンスを返すという非互換の変更が導入された。
https://bugs.ruby-lang.org/issues/10845
ruby/ruby#3701

これはStringのサブクラスであるActiveSupport::SafeBufferにわずかに影響するので、SafeBuffer#[]SafeBuffer#*でRuby 2の振る舞い(別のSafeBufferインスタンスを返す)をRuby 3でも維持するパッチを用意した。

なお、Ruby 3.0で変更されたメソッドのほぼすべては、既にSafeBufferでStringを返すためのオーバーライドが完了しているので、このテストをパスするために必要なパッチはこの2つだけ。
同PRより大意


つっつきボイス:「@amatsudaさんによるプルリクです」「Ruby 3.0の足音が聞こえてきそう」「修正の経緯について#10845に書かれているみたい↓」

「まず、従来のRubyではStringを継承したクラスが返すオブジェクトが以下の*+%のように不揃いだった↓のが、Ruby 3.0でStringを返すように統一された」

# 10845より
class MyString < String
end

MyString.new("foo").*(2).class                        #=> MyString
MyString.new("foo").+("bar").class                #=> String
MyString.new("%{foo}").%(foo: "bar").class #=> String

「RailsのSafeBufferは以下のようにStringを継承しているので、Ruby 2.xまではSafeBufferを返していたメソッドがRuby 3.0ではStringを返すように変わってしまったということか」

# activesupport/lib/active_support/core_ext/string/output_safety.rb#133
module ActiveSupport #:nodoc:
  class SafeBuffer < String

...

    def [](*args)
      if html_safe?
-       new_safe_buffer = super
+       new_string = super

-       if new_safe_buffer
-         new_safe_buffer.instance_variable_set :@html_safe, true
-       end
+       return unless new_string

+       new_safe_buffer = new_string.is_a?(SafeBuffer) ? new_string : SafeBuffer.new(new_string)
+       new_safe_buffer.instance_variable_set :@html_safe, true
        new_safe_buffer
      else
        to_str[*args]
      end
    end

...

    def *(*)
-     new_safe_buffer = super
+     new_string = super
+     new_safe_buffer = new_string.is_a?(SafeBuffer) ? new_string : SafeBuffer.new(new_string)
      new_safe_buffer.instance_variable_set(:@html_safe, @html_safe)
      new_safe_buffer
    end  

「それを上のように、返すものがSafeBufferでない場合はSafeBuffer.newすることでRuby 2.xと3.0で挙動が変わらないよう三項演算子で修正したんですね」「あ、なるほど!」「Ruby 2.xではSafeBufferを返すのでこれまでと同じ挙動になる」「Rubyのバージョンをチェックするif文を書かずに挙動を揃えているのがうまいですね😋」


後で以下の#3701を見ると、Ruby 3.0のStringクラスの#+#-以外のメソッドはすべて、StringのサブクラスのインスタンスではなくStringインスタンスを返すよう統一されるんですね。#3701のコメントの中でもSafeBufferについて言及されていました。

6.1: Relation#mergeの利用法をガイドに追加

現在のActive Record クエリインターフェイスガイドの「結合されたテーブルで条件を指定する」にはRelation#mergeの利用法を示すサンプルがない。
既存のサンプルは比較的基本的なjoinedテーブルの条件を生成するにはよいが、Relation#mergeは高度なSQLクエリ生成や既存の名前付きスコープの利用に欠かせない。
同PRより大意


つっつきボイス:「Relation#mergeのドキュメントはたしかに欲しいですね」「こういう情報がガイドに追加されるのはありがたい🙏」

# 同PRのガイドより
class Order < ApplicationRecord
  belongs_to :customer
  scope :created_in_time_range, ->(time_range) {
    where(created_at: time_range)
  }
end

time_range = (Time.now.midnight - 1.day)..Time.now.midnight
Customer.joins(:orders).merge(Order.created_in_time_range(time_range)).distinct

6.1: require_dependencyhelperから削除

これだけ今年5月にマージ済みのプルリクです。

プルリクの動機は以下の2本立て:

  • require_dependencyは現在フレームワークから段階的に削除を進めている
  • config.add_autoload_paths_to_load_pathが無効の場合にhelperが動くようにする
    同commitより大意

コントローラのhelperクラスメソッドは、stringやsymbolで指定されているヘルパーモジュールをrequire_dependencyではなくString#constantizeで読み込むようになった
Action Pack Changelogより


つっつきボイス:「使わないことになったrequire_dependencyがRailsフレームワークに残っていたのを削除したんですね」「Rails 6.1のアップグレードガイド(edge版)を見て知りました」

参考: 2.6.3 require_dependencyについて -- Rails アップグレードガイド - Railsガイド

require_dependencyの既知のユースケースはすべて排除されました。自分のプロジェクトをgrepしてrequire_dependencyを削除してください。
railsguides.jpより

Rails

RuboCop Rails 2.9がリリース


つっつきボイス:「@koicさんのツイートでこれを含むさまざまな更新情報を知ることができて助かります🙏」「え、今日ちょうどRuboCopのバージョン上げたところなんですけど😳」「こちらはRuboCop Railsなので本体のRuboCopとは別物ですね」「よかった〜」「RuboCop本体もこの後Rubyのコーナーで取り上げます(明日のウォッチに掲載します)」

rubocop-hq/rubocop-rails - GitHub

RuboCop Rails 2.9.0リリースノートの変更点より:

  • RuboCop 0.90以上が必須になる
  • Rails/SquishedSQLHeredocsをunsafeに変更

acts_as_tenant: マルチテナンシーgem(Ruby Weeklyより)

ErwinM/acts_as_tenant - GitHub


つっつきボイス:「READMEのこのサンプルコード↓ではサブドメインでテナントを分けていますね: サブドメインでマルチテナント化する設計はよく使われていて、Slackなどでも行われています」

# 同リポジトリより
class ApplicationController < ActionController::Base
  set_current_tenant_by_subdomain(:account, :subdomain)
end

「ただ、マルチテナントの要件はプロジェクトごとに詳細がいろいろ異なるんですよ: acts_as_tenant gemはまだ使ったことはありませんが、詳細を把握しきっていないgemでマルチテナンシーすることを考えると、おそらく自分ならマルチテナンシーの機能を自分で実装する方を選ぶことが多いかもしれませんね」「あぁ、たしかに!」「gemが案件にフィットするかどうかは案件の成長なども含めて検討しておく必要があるでしょうね: acts_as_tenantはたぶんそういう種類のgemだと思います」

「もちろん、それまで十分使い慣れていて、要件に合致することが確かめられているなら使ってもよいと思います👍」「acts_as_tenant gemは歴史もあるしサポートも継続しているようなので、その点は大丈夫そうですね」

「Railsの認証機能でよく使われるDevise gem↓などもそうなんですが、gemで機能を取り入れるということは、そのgemの機能の範囲で構築せざるを得なくなることでもありますよね」「そうなんですよね...」「gemの詳細や案件との調和をよく調べないうちにDeviseのようなgemを安易に導入すると、後がつらくなるから気をつけようという話もよく目にします」「はい、身に沁みてます😅」

heartcombo/devise - GitHub


「そういえば、昔のRails向けgemにはacts_as_なんちゃらという名前がよくあったというお話しを以前されてましたね」「昔はそういうネーミングのライブラリが多かったんですが、そういう名前でもライブラリが古いとは限らないでしょうね」「あ、そうか」「このacts_as_tenantは新しいのかな?」「リリースをさかのぼってみると2012年からありますね」「ホントだ」「★はあと少しで1000になるくらいかな」「おそらく今話したみたいに、Railsのマルチテナンシー機能については案件に応じて自前で実装することが多いのかもしれませんね」


追いかけボイス:「後でacts_as_tenantの実装を少し追いかけてみた限りでは、Deviseと比べてだいぶ薄めのgemのようです」「なお、マルチテナンシーだとapartmentというgem↓もあって、もしかするとこちらの方がメジャーかもしれません」

influitive/apartment - GitHub

ar_lazy_preload: GraphQLでも役立つlazy load gem(Ruby Weeklyより)

DmitryTsepelev/ar_lazy_preload - GitHub

TechRacho翻訳記事でお世話になっているEvil Martiansがスポンサーになっています。


つっつきボイス:「このgemの#lazy_preloadメソッドを使って読み込んでおくと、たとえば後でmapしてもクエリを1回しか実行しなくなるということか↓」

# 同リポジトリより
users = User.lazy_preload(:posts).limit(10)  # => SELECT * FROM users LIMIT 10
users.map(&:first_name)

「コンフィグでlazyなオートプリロードをオンにすることもできる↓」

# 同リポジトリより
ArLazyPreload.config.auto_preload = true

#preload_associations_lazilyも使える↓」

# 同リポジトリより
posts = User.preload_associations_lazily.flat_map(&:posts)
# => SELECT * FROM users LIMIT 10
# => SELECT * FROM posts WHERE user_id in (...)

「READMEを眺めた限りでは比較的シンプルな機能のgemみたい」「lib/を覗いてみても、コンフィグも少ないし、比較的シンプルそうですね」「同じコードを自力で書くよりはこういうgemでやる方がよさそう👍」


ArLazyPreloadは、関連付けのlazy loading機能をRailsアプリケーションに導入するgemです。N+1クエリ問題を解決するRails組み込みメソッドはたくさんありますが、プリロードする関連付けのリストが明確でない場合があります。そんなときはこのgemで大半をカバーできます。

シンプル
利用に必要なのは、#includes#eager_load#preload#lazy_preloadに置き換えることだけです。
高速
ベンチマークをご覧ください(TASK=benchTASK=memory)。
GraphQLとの親和性
読み込む関連付けのリストをトップレベルのリゾルバで定義すれば後はこのgemにおまかせ
オートプリロードのサポート
関連付けのリストを指定したくない場合はArLazyPreload.config.auto_preloadtrueに設定します。

同リポジトリより

「READMEにはGraphQLでも便利と書かれていますね」「以下のような感じで、GraphQLのリゾルバでカラムを取得してからmapするような操作はGraphQLでよく使うので、たぶんそれを指しているんじゃないかな」

# 同リポジトリより
users = User.lazy_preload(:posts).limit(10)  # => SELECT * FROM users LIMIT 10
users.map(&:first_name)

「GraphQLでは、最終的に欲しいカラムをGraphQLのリクエスト側で指定できるんですが、おそらくこのgemの機能を使うと、GraphQLリゾルバの直前まではActive Recordのリレーションのまま加工して、最後の最後でGraphQLからのカラムを渡すと、そのカラムでSELECTするクエリを発行して結果を返す、という感じでクエリ発行が1回で済むようにするのがやりやすくなるんでしょうね」「なるほど!」「なかなかよさそうなgemですね👍」

参考: GraphQL - Resolvers


前編は以上です。

バックナンバー(2020年度第4四半期)

週刊Railsウォッチ(20201209後編)Ractorベンチマーク記事、Railsで複合主キーを使う、AWS re:Invent 2020ほか

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

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

Rails公式ニュース

Ruby Weekly


CONTACT

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