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

週刊Railsウォッチ: withで作成したリレーションをjoinsで指定可能に、キャッシュストアの例外処理を統一ほか(20230524前編)

こんにちは、hachi8833です。RubyKaigi 2023の余韻がまだ身体に残っています。発表者の皆さま、スタッフとスポンサーの皆さま、参加者の皆さまお疲れさまでした。素晴らしいイベントをありがとうございます!

週刊Railsウォッチについて

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

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

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

4月初頭の公式更新情報が大盛りなので今週は前後編に分けて取り上げます。

🔗 joinsでCTEをサポート

動機/背景
#37944の続き。

joinsがCTEでスムーズに動作するようにする。

relation = Post
  .with(commented_posts: Comment.select(:post_id).distinct)
  #=> INNER JOIN commented_posts on posts.id = commented_posts.post_id
  .joins(:commented_posts)

更新情報: .left_outer_joinsもサポートした。

追加情報

たぶん@vladoとの作業で実証できたと思う。
同PRより

参考: Rails API joins -- ActiveRecord::QueryMethods
参考: PostgreSQL 14ドキュメント 7.8. WITH問い合わせ(共通テーブル式)


つっつきボイス:「CTE(Common Table Expression: 共通テーブル式)のwithは昨年Rails 7.1にマージされましたが(ウォッチ20220711)、そのwithで作成したリレーションをjoinsでも指定可能になったそうです」「お〜、joinsでも使えるのは嬉しいヤツ🎉」「left_outer_joinsでも使えるようになったんですね」

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

with句ってどんなのでしたっけ?」「昨年のウォッチ20220711のサンプルコードが見やすいかも↓」「withがないときはArelでこういうふうに書いていたんですか」

# withを使った場合
Post.with(
  posts_with_comments: Post.where("comments_count > ?", 0),
  posts_with_tags: Post.where("tags_count > ?", 0)
)
# withを使わない場合
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

「だんだんwith句のよさがわかってきたかも: たとえば1回定義したクエリ条件を2回以上使いまわしたいときに便利なんですね」「そうそう、サブクエリだと定義したものを使い回せなかったと思います」「with句はそのクエリの中だけで使い回せるデータベースVIEWっぽくもあるかも」「この機能が嬉しい人は多いと思います👍」

「私はArel記事を書いちゃうくらいArel好きなので、Arelで書くのはそれはそれで嫌いではありませんけどね😋」「自分はArel書き慣れてなくて苦手です...😂」

Arelのススメ — JOINをArelで書こう

🔗 domain: :allの場合のドメインcookieをRFC6265準拠にした

動機/背景

domain: :allオプションが存在する場合に、RailsのSet-Cookieヘッダーでcookieのドメイン値に誤って冒頭のドットが追加されていた。

この冒頭のドットは、RFC2965(2000年10月)に基づくcookieでは必要だったが、RFC6265(2011年4月)で振る舞いが変更されて、冒頭のドットは厳密には正しくないものになった。現在のブラウザは、cookieに関してRFC6265に準拠することを目標としている。Railsのdomain: :allの機能はRFC6265より前のものである。

新しい振る舞いでは、明示的に渡されたドメインを持つすべてのcookieが、一致するすべてのサブドメインに送信される(参考)。正確なオリジンサーバだけがcookieを受け取ることを示すためには、サーバは従来のようにdomain属性を渡すべきではない。

この振る舞いが変更されたにもかかわらず、ブラウザのDevToolsは、サブドメインで有効であることを示すために、冒頭にドットが付いたcookieドメインを表示することがよくある。この冒頭のドメインは、必ずしもSet-Cookieヘッダーで渡された生の値とは限らない。このため、一般の開発者の間では冒頭のドットが必要だと未だに思われている。

RFC6265標準では、UA(User Agent)で古いスタイルのcookieドメインパラメータを扱うアルゴリズムを与えている(冒頭のドットが存在する場合は削除してよい)ので、このエラーがウェブブラウザに影響を与えることはないと思われる。

ただし、Ruby独自のCGI::Cookieクラス(<=0.3.5)では、この方法で生成されたcookieを処理できない。

> CGI::Cookie.new "domain" => ".example.com", "name" => "foo"
ArgumentError: invalid domain: ".example.com"

Ruby CGIライブラリの新バージョンでは、同じUAフォールバック動作(余分なドットを削除)に対応しているが、これがcookieを設定する正しい方法として正当化されるわけではない。

詳細
このプルリクでは、cookieでdomain: :allが設定された場合にcookieのdomainプロパティを削除する。

追加情報
この問題は#46578で議論されたことがあるが、ruby/cgiが変更されたためクローズされた。しかし前述のように、ruby/cgiの変更は、Railsが正しいことを行っているという意味にはならず、間違った場合を処理するフォールバック動作があるという意味にしかならない。

このプルリクでは現在、domain: :allの機能に関連する機能とテストのみを変更しているが、冒頭にドットがあるドメインを明示的に渡す他のテストがあり、これらも変更する必要がありそう。このプルリクに入れるべきかどうかわからない。

さらに言うと、Railsでは冒頭のドットがあるサブドメインとないサブドメインに関連するテストが別々に行われているらしい。ブラウザはこれらのケースを同じように扱うので、おそらくもう別々のテストは不要と思われる。必要ならついでにここで整理してもよかったのだが、一度にひとつの変更に集中したかった。
同PRより

参考: RFC 6265 - HTTP State Management Mechanism 日本語訳
参考: RFC 2965 - HTTP State Management Mechanism 日本語訳 -- 廃止


つっつきボイス:「プルリクメッセージがかなり詳細に書かれている」「現在のRailsのcookieの挙動がRFC 6265に沿っていない部分があったということみたいですね」「スクショを拡大してみてわかったけど、ドメイン名の冒頭のドット.ってこれか↓」「この冒頭ドット.があると、正規表現の*っぽくすべてのサブドメインも対象になるということなのかな」

「そして修正はたったこれだけ↓」「ドットを削除しただけですか」「わかりやすい」「ピンポイントの修正に丁寧なプルリクメッセージがあるのはいいですね👍」

# actionpack/lib/action_dispatch/middleware/cookies.rb#L472
            options[:domain] = if cookie_domain.present?
-             ".#{cookie_domain}"
+             cookie_domain
            end

🔗 Active Record関連

🔗 ActiveRecord::Relationintersects?が追加された

Ruby 3.1で(a & b).any?と等価なintersects?が追加された。RuboCopは対応するcopであるStyle/ArrayIntersectを追加し、古いスタイルをintersects?に変換するようになった。残念ながら、intersects?CollectionProxyに委譲されていないため、このメソッドが委譲されていないこと以外の正当な理由がなく、無効化しなければならない誤検出が引き起こされてしまう。

このプルリクは、intersects?Relationに委譲することでこの問題を修正する。

なおintersectionunionはそれぞれ&および|と同一である(これらも委譲済み)なので、対称性のためにこれらも追加する価値があると思ったが、これらがない方がプルリクを受け入れやすいのであれば削除しても構わない。
同PRより


つっつきボイス:「これはkazzさんが好きそうな機能ですね」「はい大好きです❤️」「RuboCopのStyle/ArrayIntersect copと連携して(a & b).any?a.intersetcs?(b)に自動置き換えできるようになるんですね」「個人的には元の&方がわかりやすいような気もするんだけどな〜」

参考: Style/ArrayIntersect :: RuboCop Docs

「intersectは学校で習った集合論で言う共通集合ですけど、データベース関連とかだと交差と訳されることが多いですね」「SQLのINTERSECTは普通に使いますね」「unionはこの場合では和集合を指しますね」

参考: 7.4. 問い合わせの結合(UNION, INTERSECT, EXCEPT)

intersects?を追加したとあるけど、実際はdelegateに加えたのか↓」「こういうメソッドって定義場所がちょっと見つけにくかったりしますよね」

# activerecord/lib/active_record/relation/delegation.rb#L100
-   delegate :to_xml, :encode_with, :length, :each, :join,
+   delegate :to_xml, :encode_with, :length, :each, :join, :intersects?,

🔗 Mysql2Adapterにloadフックを追加

PostgresとMysqlのアダプタを拡張したかったが、プロジェクトでしか使わないので、拡張のために手動でrequireするのは避けたかった。しかし、PostgreSQLAdapterSQLite3Adapter に比べて Mysql2Adapter にはloadフックがないことに気がついた。このプルリクではその点を修正した。
同PRより

参考: Rails API ActiveRecord::ConnectionAdapters::Mysql2Adapter


つっつきボイス:「loadフックがPostgreSQLやSQLiteにあるならMySQLにも欲しいというのはわかる」「どんなときにloadフックを使うんでしょうか?」「ユースケースよくわからないけど、読み込みの瞬間に何かしたくなることがあるのかもしれませんね🤔」

# activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb#L181
    end
+   ActiveSupport.run_load_hooks(:active_record_mysql2adapter, Mysql2Adapter)
  end
end

🔗 外部キー関連

🔗 マイグレーションでreferencesdeferrableオプション付きの外部キーを渡しても無視される問題を修正

動機/背景
t.referencesに先延ばし可能な外部キーを渡しても無視される問題を修正する。

詳細
このプルリクは#41487の続き。
#41487ではadd_foreign_keydeferrableオプションのサポートを追加しているが、t.referencesではまだdeferrableオプションがサポートされていなかった。
おそらく以下の実装に抜けがあっただけだろう。

create_table(:testings) do |t|
  t.references(:parent, foreign_key: { deferrable: :immediate }) #=> deferrable must not be ignored!
end

追加情報
このプルリクは、#47659↓がマージされた後にマージされる必要がある(ForeignKeyDefinition#deferrableの戻り値が変更されるため)。


つっつきボイス:「マイグレーションで外部キーにdeferrableオプションが効いてなかったのが修正されたんですね」「修正もシンプル」「これは次のプルリクと関連しています↓」

# activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb#L26
+         def visit_ForeignKeyDefinition(o)
+           super.dup.tap do |sql|
+             sql << " DEFERRABLE INITIALLY #{o.deferrable.to_s.upcase}" if o.deferrable
+           end
+         end

🔗 add_foreign_keydeferrable: trueオプションを非推奨化する

動機/背景
deferrable: trueを非推奨化し、今後はdeferrable: :immediateを使うようにする。deferrable: trueはRails 7.2で削除する予定。

理由は、deferrable: truedeferrable: :deferredの意味がわかりにくいため。どちらもtrueであり、:deferredはtruthyな値である。
修正後の振る舞いは、#46192add_unique_keyメソッドに追加されたdeferrableオプションと同じにしてある。

詳細

  • Add support for unique constraints (PostgreSQL-only). #46192で追加されたadd_unique_keyメソッドと同様、deferrableオプションががシンボルだけを受け取るべき。
    • deferrable: trueは非推奨化された。
  • ActiveRecord::Migration[7.0]を拡張することで、古いマイグレーションファイルで非推奨警告を表示せずにdeferrable: trueを使えるようにした。

追加情報
生成されるSQLがわずかに異なるが、影響はない。
DEFERRABLEDEFERRABLE INITIALLY IMMEDIATEが同等であることは#46192で確認してある。
同PRより


つっつきボイス:「ネーミングがよくなかったので非推奨になった、わかる」「truedeferredが同じというのはたしかに紛らわしい」「たまにこういうメソッドに出くわすんですよね...」「7.0のマイグレーションを対象にしているからそれ以前のはそのままでいいそうです」「わずかに異なるが影響はないって言われると、わかっててもちょっとだけビビりますね😅」

🔗 assert_enqueued_withassert_performed_withにシンボルも渡せるようになった

動機/背景
assert_enqueued_jobsassert_enqueued_withの振る舞いが一貫していないことに気づいた。

assert_enqueued_jobsassert_performed_jobsではqueue名にSymbolStringも利用できる。
しかしassert_enqueued_withassert_performed_withではqueue名にStringを利用できるがSymbolの場合は失敗する。

詳細
例:

assert_enqueued_with(job: LoggingJob, queue: :default) do
  LoggingJob.set(wait_until: Date.tomorrow.noon).perform_later
end

結果:

Failure:
No enqueued job found with {:job=>LoggingJob, :queue=>:default}

Potential matches: {"job_class"=>"LoggingJob", "job_id"=>"LOGGING-JOB-ID", "provider_job_id"=>nil, "queue_name"=>"default", "priority"=>nil, "arguments"=>[], "executions"=>0, "exception_executions"=>{}, "locale"=>"en", "timezone"=>nil, "enqueued_at"=>"2023-04-23T13:53:20.183425000Z", :job=>LoggingJob, :args=>[], :queue=>"default", :priority=>nil, :at=>2023-04-24 12:00:00 +0530}

同PRより


つっつきボイス:「これもシンプルな修正」「シンボルと文字列をどちらも渡せるメソッドと一方しか渡せないメソッドが混じっていると使いにくいので、統一するのはいいですね👍」「修正↓はシンプルだけどレビューのやりとりが結構ありますね」「to_sだけでよさそうに見えるけど、is_a?(Symbol)で型チェックしておく必要もあった、なるほど」

# activejob/lib/active_job/test_helper.rb#L695
      def prepare_args_for_assertion(args)
        args.dup.tap do |arguments|
+         if arguments[:queue].is_a?(Symbol)
+           arguments[:queue] = arguments[:queue].to_s
+         end
+

@bensheldon、私のプルリクをみっちり見てくれてありがとう!
提案されたようにキュー名をarguments[:queue] = arguments[:queue].to_sで直接文字列に変換してみたが、以下の理由でテストが落ちるようになった。

  1. キュー名を渡さなかった場合、arguments[:queue]nilになって文字列への変換結果が空文字列""になる。そして"" != "default"なのでテストが失敗する。
  2. arguments[:queue]Procになる可能性もありそうに思える。これによって以下のテストや別の同様のテスト(1894行目)が失敗する。
# 744b671, rails/activejob/test/cases/test_helper_test.rb#576-596
def test_assert_enqueued_with_supports_matcher_procs 
   facets = { 
     job: HelloJob, 
     args: ["Rails"], 
     at: Date.tomorrow.noon, 
     queue: "important", 
   } 
  
   facets[:job].set(queue: facets[:queue], wait_until: facets[:at]).perform_later(*facets[:args]) 
  
   facets.each do |facet, value| 
     matcher = ->(job_value) { job_value == value } 
     refuser = ->(job_value) { false } 
  
     assert_enqueued_with(**{ facet => matcher }) 
  
     assert_raises ActiveSupport::TestCase::Assertion do 
       assert_enqueued_with(**{ facet => refuser }) 
     end 
   end 
 end 

1番目の問題については以下で修正できた。

arguments[:queue] = arguments[:queue].to_s if arguments.key?(:queue)

しかし2番目の問題は型チェックに頼るしかないので今回のように変更した。
もっといい方法があったら知らせて欲しい。
#48034コメントより

🔗 キャッシュストアでキーがnil""の場合の振る舞いを統一した

動機/背景
現在は、展開したキャッシュキーがnilまたは空文字""の場合は以下のようになる。

  • mem_cacheストアの実装でArgumentErrorエラーがraiseされる。これはこのクライアントで発生する
  • Redisストアではnilの場合にのみTypeErrorがraiseされる。これはこのクライアントで発生する
  • file_storeの実装では以下のようなエラーが発生する。
  • memoryストアとnullストアではraiseせずに成功してしまう。

このプルリクでは、どのストア実装でも空のキーを渡されたら常にraiseするようにする。

詳細
このプルリクは、ActiveSupport::Cache::Store#normalize_keyにバリデーションを追加して、展開済みキャッシュキーがnil""の場合は、#read_entry#write_entry#delete_entry#read#fetchなど)に依存するすべてのメソッド内で常にArgumentErrorをraiseするようにする。
同PRより


つっつきボイス:「キャッシュストアでキーがnilだったり空文字列""だったりした場合の挙動がキャッシュストアごとに違ってたのが修正された、なるほど」「RailsのキャッシュストアってRedis以外にもMemoryStoreとかNullStoreとかいろいろあるんですね」

参考: §2 キャッシュストア -- Rails のキャッシュ機構 - Railsガイド


前編は以上です。

バックナンバー(2023年度第2四半期)

週刊Railsウォッチ: スライド『Rails 7.1をn倍速くした話』、Rails 7.1でMessagePackをサポートほか(20230502)

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

Rails公式ニュース


CONTACT

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