- Ruby / Rails関連
週刊Railsウォッチ: withで作成したリレーションをjoinsで指定可能に、キャッシュストアの例外処理を統一ほか(20230524前編)
こんにちは、hachi8833です。RubyKaigi 2023の余韻がまだ身体に残っています。発表者の皆さま、スタッフとスポンサーの皆さま、参加者の皆さまお疲れさまでした。素晴らしいイベントをありがとうございます!
🔗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
でも使えるようになったんですね」
「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書き慣れてなくて苦手です...😂」
🔗 domain: :all
の場合のドメインcookieをRFC6265準拠にした
- PR: Make Rails cookies RFC6265-compliant with domain: :all by gareth · Pull Request #48036 · rails/rails
動機/背景
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::Relation
にintersects?
が追加された
Ruby 3.1で
(a & b).any?
と等価なintersects?
が追加された。RuboCopは対応するcopであるStyle/ArrayIntersect
を追加し、古いスタイルをintersects?
に変換するようになった。残念ながら、intersects?
はCollectionProxy
に委譲されていないため、このメソッドが委譲されていないこと以外の正当な理由がなく、無効化しなければならない誤検出が引き起こされてしまう。このプルリクは、
intersects?
をRelation
に委譲することでこの問題を修正する。なお
intersection
とunion
はそれぞれ&
および|
と同一である(これらも委譲済み)なので、対称性のためにこれらも追加する価値があると思ったが、これらがない方がプルリクを受け入れやすいのであれば削除しても構わない。
同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
するのは避けたかった。しかし、PostgreSQLAdapter
やSQLite3Adapter
に比べて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
🔗 外部キー関連
🔗 マイグレーションでreferences
にdeferrable
オプション付きの外部キーを渡しても無視される問題を修正
- PR: deferrable foreign key can be passed to references by alpaca-tc · Pull Request #47671 · rails/rails
動機/背景
t.references
に先延ばし可能な外部キーを渡しても無視される問題を修正する。詳細
このプルリクは#41487の続き。
#41487ではadd_foreign_key
にdeferrable
オプションのサポートを追加しているが、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_key
のdeferrable: true
オプションを非推奨化する
動機/背景
deferrable: true
を非推奨化し、今後はdeferrable: :immediate
を使うようにする。deferrable: true
はRails 7.2で削除する予定。理由は、
deferrable: true
やdeferrable: :deferred
の意味がわかりにくいため。どちらもtrue
であり、:deferred
はtruthyな値である。
修正後の振る舞いは、#46192でadd_unique_key
メソッドに追加されたdeferrable
オプションと同じにしてある。詳細
- Add support for unique constraints (PostgreSQL-only). #46192で追加された
add_unique_key
メソッドと同様、deferrable
オプションががシンボルだけを受け取るべき。
deferrable: true
は非推奨化された。ActiveRecord::Migration[7.0]
を拡張することで、古いマイグレーションファイルで非推奨警告を表示せずにdeferrable: true
を使えるようにした。追加情報
生成されるSQLがわずかに異なるが、影響はない。
DEFERRABLE
とDEFERRABLE INITIALLY IMMEDIATE
が同等であることは#46192で確認してある。
同PRより
つっつきボイス:「ネーミングがよくなかったので非推奨になった、わかる」「true
とdeferred
が同じというのはたしかに紛らわしい」「たまにこういうメソッドに出くわすんですよね...」「7.0のマイグレーションを対象にしているからそれ以前のはそのままでいいそうです」「わずかに異なるが影響はないって言われると、わかっててもちょっとだけビビりますね😅」
🔗 assert_enqueued_with
とassert_performed_with
にシンボルも渡せるようになった
動機/背景
assert_enqueued_jobs
とassert_enqueued_with
の振る舞いが一貫していないことに気づいた。
assert_enqueued_jobs
とassert_performed_jobs
ではqueue
名にSymbol
もString
も利用できる。
しかしassert_enqueued_with
とassert_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
で直接文字列に変換してみたが、以下の理由でテストが落ちるようになった。
- キュー名を渡さなかった場合、
arguments[:queue]
がnil
になって文字列への変換結果が空文字列""
になる。そして"" != "default"
なのでテストが失敗する。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)
- 20230427後編 第1回Rails Worldが10月に開催、『研鑽Rubyプログラミング』でRuby本体も高速化ほか
- 20230425前編 Rails 7.1の複合主キー対応が引き続き進む、exceptメソッドにwithoutエイリアスが追加ほか
- 20230413後編 ShopifyのRubyパーサーyarp、RJITを書いた理由ほか
- 20230412前編 複合主キーの実装が進む、Rails公式のバグ再現用テンプレートほか
- 20230406後編 Rubyオブジェクトモデルクイズの最難問ほか
- 20230405前編 Arel::Nodes::NodeにAPIドキュメントが追加、rubocop-mdほか
ソースの表記されていない項目は独自ルート(TwitterやはてブやRSSやruby-jp SlackやRedditなど)です。
週刊Railsウォッチについて
TechRachoではRubyやRailsなどの最新情報記事を平日に公開しています。TechRacho記事をいち早くお読みになりたい方はTwitterにて@techrachoのフォローをお願いします。また、タグやカテゴリごとにRSSフィードを購読することもできます(例:週刊Railsウォッチタグ)