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

週刊Railsウォッチ: Arel::Nodes::Cteが追加、html_escape_onceの修正ほか(20230613前編)

こんにちは、hachi8833です。ご存知かと思いますがリマインドです。

週刊Railsウォッチについて

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

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

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

🔗 機能追加・変更

🔗 Arel::Nodes::Cteを追加

WITH句で使うArel::Nodes::Cteを追加する。
また、Cteノードではクエリプランナーに対して明示的にMATERIALIZEDまたはNOT MATERIALIZEDヒントも提供する。

posts = Arel::Table.new(:posts)
comments = Arel::Table.new(:comments)

good_comments_query = comments.project(Arel.star).where(comments[:rating].gt(7))
cte = Arel::Nodes::Cte.new(:good_comments, good_comments_query, materialized: true)

query = posts.
  project(Arel.star).
  with(cte).
  where(posts[:id].in(cte.to_table.project(:post_id))).

puts query.to_sql

# WITH "good_comments" AS MATERIALIZED (SELECT * FROM "comments" WHERE "comments"."rating" > 7) SELECT * FROM "posts" WHERE "posts"."id" IN (SELECT post_id FROM "good_comments")

Jon Zeppieri
同Changelogより


動機/背景
PostgreSQLとSQLiteはどちらもCTE構文の非標準拡張をサポートしており、CTEサブクエリをマテリアライズすべきである(つまりメインクエリに織り込まずに個別に評価する)ことを指定できる。これは、クエリプランナーの判断が適切でない場合に便利。

どちらのデータベースでもWITH foo AS MATERIALIZED (...) ...という構文を使う。

同様に、どちらのデータベースもCTEをマテリアライズしないヒントをWITH foo AS NOT MATERIALIZED (...) ...で明示的に与えられる。

詳細
このプルリクはArel::Nodes::Cteを追加する。これはCTEクエリを表し、エイリアスやリレーションとマテリアライズフラグで構成されている。後者のフラグがtrueの場合はマテリアライズを指示し、falseの場合はマテリアライズしないことを指示する。nilの場合はマテリアライズのヒントを出さない。このノードは以下のように利用できる。

    posts = Arel::Table.new(:posts)
    comments = Arel::Table.new(:comments)

    good_comments_query = comments.project(Arel.star).where(comments[:rating].gt(7))
    cte = Arel::Nodes::Cte.new(:good_comments, good_comments_query, materialized: true)

    query = posts.
      project(Arel.star).
      with(cte).
      where(posts[:id].in(cte.to_table.project(:post_id))).

    puts query.to_sql

    # WITH "good_comments" AS MATERIALIZED (SELECT * FROM "comments" WHERE "comments"."rating" > 7) SELECT * FROM "posts" WHERE "posts"."id" IN (SELECT post_id FROM "good_comments")

AsTableAliasノードは、引き続きSelectManager#withの引数としてサポートされる。

追加情報

これは#47951の代替案であり、MATERIALIZEDの追加を表すだけのノードを追加するのではなく、@matthewdが提案した「エイリアスとリレーションを両方含むCteノードを追加する」案に従ったものである。これにより構文木が浅くなり、キーワード固有のノード種別をさらに追加したりノードのネストを深くするという最終手段に訴えなくてもNOT MATERIALIZEDヒントを簡単にサポートできるようになる。
同PRより


つっつきボイス:「お、Arelの機能追加じゃないですか😋」「joinsでのCTEサポートウォッチ20230524に続くCTEの機能追加ですね」「PostgreSQLやSQLite3にはAS MATERIALIZEDっていう構文があるのか」「通常のクエリを()で囲んでその後ろに書けるんですね」

参考: PostgreSQL 15ドキュメント 7.8. WITH問い合わせ(共通テーブル式)
参考: SQLite3ドキュメント The WITH Clause

「この記事↓によるとPostgreSQLが自動的にマテリアライズする場合の条件が"同じCTEが2回以上使われていないこと"と"CTE中に非immutable関数が使われていないこと"とある: 同じCTEを何度も使うならマテリアライズして使い回す方がいいし、CTEで改変が行われたら結果が変わってしまうので、おっしゃる通り」

参考: PostgreSQL 12の新機能:CTEの高速化

🔗 マルチプルDBで使えるActiveRecord.disconnect_all!が追加

これは基本的にActiveRecord::Base.connection.disconnect!のマルチDB版。データベースに既に接続していない場合の接続も回避する。
これは、establish_connectionを使った後でステートをリセットするのに便利。
同PRより


つっつきボイス:「お、マルチDBコンシャスのActiveRecord.disconnect_all!が追加された」「再接続も防止するんですね」「古いデータベースサーバーをフェイルオーバーするときにコネクションを一括で切断したいようなときはまれにあるかもしれませんね: 普段使う機能ではなさそう」

🔗 rescue可能な例外をtest環境のエラー画面に表示するようになった

action_dispatch.show_exceptions:all:rescuable:none のいずれかに変更する。
all:noneは、それぞれ従来のtruefalse と同じ動作になる。
新しい:rescuableオプションは、rescue可能な例外(例: ActiveRecord::RecordNotFound)だけを表示する。test環境では:rescuableがデフォルトになる。
Jon Dufresne
同Changelogより


背景
統合テストでは、アプリケーションが本番環境に近い応答をすることが望ましい。
これによってアプリケーションがその通りに動作することを確信できるようになる。

Railsのテストでは、test環境とproduction環境にひとつ大きなミスマッチがある。HTTPリクエスト中に発生した例外(例:ActiveRecord::RecordNotFound)がrescueされずにテスト内で再発生し、404レスポンスに変換されてしまう。

config.action_dispatch.show_exceptionstrueに設定すれば、test環境がproductionと同じように動作するようにはなる。しかし、予期しない内部サーバーエラーが発生した場合、テストが表示するのは有用なスタックトレースではなく、詳細のわからない500レスポンスになり、デバッグが難しくなる。

このため開発者は、統合テストをもっと高品質にするか、失敗時のデバッグ体験の向上を優先するかという二択を迫られる。

自分は両方のいいとこ取りが可能であることを提案したい。

ソリューション
config.action_dispatch.show_exceptions設定オプションを従来のブーリアンから:all:rescuable:noneの3つの値のいずれかに変更する。値:all:noneは、それぞれ従来のtruefalseと同じように動作する。従来のtrue(現在は:all)については、test以外の環境では引き続きデフォルト値となる。

新しい値:rescuableは、test環境での新しいデフォルトとなる。ActionDispatch::ExceptionWrapper.rescue_responsesで定義されたrescue可能な例外に対してのみ、レスポンスで例外を表示するようになる。
予期しない内部サーバーエラーが発生した場合に有用なスタックトレースと良いデバッグ体験を提供するために、test環境ではエラーの原因となる例外を引き続きraiseする。
同PRより


つっつきボイス:「test環境でrescue可能なエラー画面をもっと親切なものにする改修だそうです」「HTTPリクエスト中に発生した例外がrescueされないで再発生して404 Not Foundに変換される現象があるとは知らなかった」「これで困った覚えはなかったけど、エラー表示が改善されるのはいいですね👍」「今までconfig.action_dispatch.show_exceptionsにはtrue/falseしか設定できなかったのが、:all/:rescuable/:noneの3つになるのね」

参考: §3.5.8 ActionDispatch::ShowExceptions -- Rails アプリケーションを設定する - Railsガイド

🔗 バグ修正

🔗 change_in_place?の挙動を修正

#40383のフォローアップ
修正: #48255
修正: #48262

バイナリカラムでシリアライズド属性がサポートされている場合は、raw_old_valueraw_new_valueが両方ともBinary::Dataにキャストされるようにしなければならない。

さらに、Binary::DataEncoding::BINARYで背後の文字列をキャストしなければならない。そうしないとASCII範囲外のバイトを含む文字列の比較に失敗する。

自分へのメモ: これはバックポートすべき。
同PRより


つっつきボイス:「Active Recordの型キャスト周りに不整合があったのを修正したらしい: 必要な場合にEncoding::BINARYの値を使うようになったんですね↓」

# activemodel/lib/active_model/type/binary.rb#L38
      class Data # :nodoc:
        def initialize(value)
-         @value = value.to_s
+         value = value.to_s
+         value = value.b unless value.encoding == Encoding::BINARY
+         @value = value
        end

参考: PostgreSQL 15ドキュメント 8.4. バイナリ列データ型

🔗 ERB::Util.html_escape_onceのエスケープ済み文字がhtml_safeでマーキングされていなかったのを修正

ERB::Util.html_escape_onceが常にhtml_safe文字列を返すよう修正。

従来このメソッドは、戻り値の文字列のhtml_safe?プロパティを保持していた。しかしこの文字列はエスケープ済みであるため、html_safeとマーキングしないとエンティティが2回エスケープされることになる。

以下はビュースニペット例

<p><%= html_escape_once("this & that & the other") %></p>

変更前は上が2回エスケープされて以下のようにレンダリングされていた。

変更後は以下のように正しくレンダリングされる。

修正: #48256
Mike Dalessio
同Changelogより


つっつきボイス:「html_escape_onceでエスケープしてもhtml_safeでマーキングされていなかったので二重エスケープされていたのか」「フラグが合ってなかったんですね」「踏んだら悲しいバグ」

参考: Rails API html_escape_once -- ERB::Util

🔗 Authorizationヘッダーに空の値を渡すとエラーになる問題を修正

動機/背景
このプルリクを作成した理由は、特殊な形で区切られたAuthorizationヘッダー値がArgumentErrorの発生に利用される可能性があるため(多くの場合500エラーレスポンスが返される)。

詳細
このプルリクは、token_and_optionsメソッドを変更して引数エラーの原因となる空白値を除去する。
通常、raw_paramsはタプルの配列を返すが、AuthorizationヘッダーがBearer foo,,barのようにブランク値を含む場合(2つの,区切り文字の間の値が空欄である点に注意)、raw_paramsメソッドにタプル以外の値が含まれることになる。
これらの値は次にHash[]に渡されるが、このメソッドは引数を1個または2個しか受け取らず、0個の引数は受け取らない。残念ながらこれはArgumentError: invalid number of elements (0 for 1..2)を発生する。
同PRより


つっつきボイス:「あ〜なるほど、Bearer foo,,barのように値のない項目を含むものをHTTPのAuthorizationヘッダーに渡すとエラーになってたのか: これは必要な修正」

参考: Authorization - HTTP | MDN

🔗 モデルのジェネレータにcreate_table_migrationテンプレートのオーバーライドが正しく反映されない問題を修正

create_table_migrationテンプレートをオーバーライドしてもActiveRecord::Generators::ModelGeneratorに正しく反映されないバグを修正。

rails g model create_books title:string content:text

この修正によって、以下の場所でcreate_table_migration.rb.ttテンプレートから順序正しく読み込まれるようになる。

lib/templates/active_record/model/create_table_migration.rb
lib/templates/active_record/migration/create_table_migration.rb

Spencer Neste
同PRより


つっつきボイス:「これはジェネレータのバグ修正ですね」「ところでマイグレーションのジェネレータは今もよく使うけど、モデルやscaffoldのジェネレータはめったに使わないかな」「マイグレーションファイルの連番は人間が入力するものじゃないですよね」

参考: Active Record マイグレーション - Railsガイド

🔗 rails newでJavaScriptとCSSのオプションの組み合わせによって起きるバグを修正

動機/背景
このプルリクを作成した理由は、現在のRailsのアプリジェネレータに2つ問題があるため。

問題1
JavaScriptオプションにimportmapを指定し、CSSオプションにbootstrapbulma、またはpostcssを指定すると、Railsはimportmapの指定を無視してJavaScriptオプションで強制的にesbuildを設定してしまう。

JavaScriptにオプションにimportmapを指定して、Node.jsでbootstrapbulmaまたはpostcssを指定しても問題はないはず。

問題2

JavaScriptオプションにimportmapを指定するとRailsが強制的にesbuildにする場合(上と同じ問題)や、Node.jsが必要なCSSオプションを使う場合でも、.node-versionファイルが作成されず、DockerfileにNode.jsインストール手順が含まれない。

詳細
このプルリクでは、.node-versionファイルが作成され、DockerfileにNode.jsのインストールが含まれるように要件を変更する。
また、従来の動作も変更され、CSSオプションの選択内容にかかわらずimportmapを選択できるようになる。


1: jsbundling-railsで必要なnodeがインストールされないままimportmapからesbuildに切り替わる
なお、まだ利用できない組み合わせがある。CSSをコンパイルするためにimportmapcssbundling-railsを使いたい場合は、tailwindcss-railsdartsass-railsではなく、tailwindsassを使うことになる。
同PRより


つっつきボイス:「Rails 7が出た頃に以下の記事↓を書いたんですが、rails newのオプションの組み合わせがとてもややこしいと思っていたらバグがあったんですね」「複雑になるとバグも入りやすくなりがち」「組み合わせを網羅したマトリックスがメンテで必要になるだろうけど、相当大きくなりそう」「矛盾する組み合わせでオプションを指定したらどうなるか知りたいですね」

Rails 7 : rails newのフロントエンド関連オプションの組み合わせを調べてみた

🔗 非推奨化

🔗 SafeBuffer#clone_emptyが非推奨化

動機/背景
SafeBuffer#clone_emptyはもうRailsのコードベースで使われていない。最後の呼び出し元も2014年(Rails 4.2.0)に479c7caで削除された。

追加情報
グローバルにGitHub検索してみると、このメソッドがここ11年の間に書かれたり更新されたりしている頻度は高くなさそう。
今後もこのメソッドをサポートしたいだろうか?
同PRより


つっつきボイス:「SafeBuffer#clone_emptyは使ったことないけどSafeBuffer自体はよく使いますね」「中身これだけだった↓」「SafeBufferはどこで使われているんでしたっけ?」「さっきのhtml_safeフラグなどもSafeBufferが持っていますね: これはStringを継承しています」「なるほど」

# https://github.com/rails/rails/blob/e88857bbb9d4e1dd64555c34541301870de4a45b/activesupport/lib/active_support/core_ext/string/output_safety.rb#L210
    def clone_empty
      self[0, 0]
    end

参考: Rails API ActiveSupport::SafeBuffer

🔗 assert_enqueued_email_with:args引数へのHash渡しが非推奨化

#45752では、assert_enqueued_email_with:paramsが追加されて、メーラーのparamsやargsを同時に指定可能になった。しかし後方互換性のために、:argsのHashがparamとして温存された。その結果、:params:argsを両方指定し、かつ:argsがHashになっていると、:paramsが無視されてしまう。

このコミットは、:argsでのparams指定(つまり:argsにHashを渡す)を非推奨化する。
この非推奨の振る舞いが削除された後は、:argsにHashを渡せるようになり、Hashが名前付き引数として扱われるようになる。
同PRより


assert_enqueued_email_with:argsキーワード引数経由でparamsを渡すことを非推奨化する。
これによって、assert_enqueued_email_with:paramsキーワード引数をサポートするようになるので、paramsを渡すときはこれを使うこと。

# 変更前
assert_enqueued_email_with MyMailer, :my_method, args: { my_param: "value" }
# 変更後
assert_enqueued_email_with MyMailer, :my_method, params: { my_param: "value" }

メーラーの名前付き引数をHashとして指定するには、以下のようにHashを配列でラップすること。

assert_enqueued_email_with MyMailer, :my_method, args: [{ my_arg: "value" }]
# または
assert_enqueued_email_with MyMailer, :my_method, args: [my_arg: "value"]

Jonathan Hefner
同Changelogより


つっつきボイス:「assert_enqueued_email_withはこの間も改修されていましたね(ウォッチ20230607)」

🔗 細かな改修

🔗 fixture_pathsにエンジンのtest/fixturesを自動追加するようになった

パスがRailsアプリケーションのrootディレクトリに存在する場合は、on_loadフックでエンジンのtest/fixturesパスをfixture_pathsに追加するようになった。
Chris Salzberg
同Changelogより


動機/背景
イニシャライザで、test/fixturesディレクトリをデフォルトで自動的にTestFixtures#fixture_pathsに追加する。目的はエンジンのセットアップで大量の定型文を回避するため。

詳細
#47675で、主にエンジンで使うためにfixtureのパスを複数割り当てるTestFixtures#fixture_pathsが導入された。
#47690ではそうしたパスを追加するフックも提供された。

その結果、以下のようにエンジンで独自のfixtureパスを追加可能になった。

ActiveSupport.on_load(:active_record_fixtures) do
  self.fixture_paths << Engine.root.join("test", "fixtures").to_s
end

しかしこのコードは書き忘れやすく、どのエンジンでも全く同じになる。そう考えれば、エンジンが自動的に test/fixtures を探し、もしディレクトリが存在すれば、このような形でfixture_pathsに追加する方が理にかなっていそう。このプルリクはそれを実現する。
同PRより


つっつきボイス:「fixtureの改修: たしかにエンジンのfixtureもfixtureのパスに自動で入ってくれると嬉しいですね👍」

🔗 エラーのハイライトを修正

このコミットより前は、一部のレンダリング呼び出しでerror highlightが「利用できない」形でハードコードされていた。このため、正しいバージョンのerror highlightがインストールされているにもかかわらず、エラーページによって「error highlightをインストールする必要があります」というメッセージが表示されることがあった。

このコミットでは、DebugViewクラスにデリゲートメソッドを追加し、デバッグ関連のテンプレートでerror highlightが利用可能かどうかをメソッド呼び出しで確認できるようにした。これにより、あらゆる場所でローカル変数の受け渡しに依存する必要がなくなる。
なおこの変更により、すべての「rescue」テンプレートがDebugViewクラスのコンテキスト内でレンダリングされなければならなくなるというマイナス面もある(しかし自分はそれでいいと思う)。
同PRより


つっつきボイス:「tenderloveさんによる改修です」「エラーのハイライトが効かない場合があったとは」「リファクタリングとあるけど実際は修正でしょうね」

🔗 RDocの固定フォント表示をシンプルな記法に変更

<tt>(\w+::\w+)</tt>をすべて+$1+に置き換える
例:

<tt>ActiveRecord::Base</tt> -> +ActiveRecord::Base+

同PRより


つっつきボイス:「APIドキュメント表示のシンプルなリファクタリングですね」「変更範囲はさすがに多いですけど」

# actioncable/lib/action_cable/gem_version.rb#L3
module ActionCable
- # Returns the currently loaded version of Action Cable as a <tt>Gem::Version</tt>.
+ # Returns the currently loaded version of Action Cable as a +Gem::Version+.

🔗 after_bundleブロックがapp:templateコマンドでも実行されるようになった

アプリのテンプレートにはafter_bundle blocksが含まれている場合がある。これはテンプレートを実行した後、bundle installを実行した後に実行する必要がある。

gem "devise"

after_bundle do
  generate "devise:install"
end

このコミットより前は、bundle installafter_bundleブロックはrails newでテンプレートを適用するときしか実行されなかった。このコミットにより、bin/rails app:templateでもこれらを実行するようになった。
同PRより


つっつきボイス:「テンプレートにafter_bundleというものを書けるのか↓」

# Railsガイドより
# template.rb
generate(:scaffold, "person name:string")
route "root to: 'people#index'"
rails_command("db:migrate")

after_bundle do
  git :init
  git add: "."
  git commit: %Q{ -m 'Initial commit' }
end

参考: Rails アプリケーションのテンプレート - Railsガイド

🔗 yamlテンプレートに含めるドキュメントの改善

このプルリクを作成した理由は、訳文キーの値としてYes/Noを使い、訳語True/Falseを取得していたせいで、値をエスケープする必要があることに気づくまで30分ほど頭がおかしくなりそうになったため。このプルリクは、truthy/falsyの判断が難しいキーだけをエスケープする必要があるというコメントを含める形ですべてのen.ymlファイルを更新し、値についてもカバーするようにした。

またこのプルリクでは、コメントの指示に合わせて二重引用符を一重引用符に変更した(式展開ではない文字列では一重引用符が良いので、Rubocopを怒らせないため)。

修正: #46475
同PRより


つっつきボイス:「yamlテンプレートに含まれている注意事項を更新したんですね↓」「そうそう、この間も話題にしましたけど(ウォッチ20230222)、ここに書かれているものは大文字小文字を問わずブーリアンとして解釈される点はyamlを扱うときに気をつけたいですね」

# actiontext/test/dummy/config/locales/en.yml#L22
+# Be aware that YAML interprets the following case-insensitive strings as
+# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings
+# must be quoted to be interpreted as strings. For example:

「yamlで思い出したんですが、たとえばRailsのコンフィグを設定用のyamlに保存するときは、それぞれの設定の意味や対象がわかるようにyamlファイルのコメントにも情報を残して欲しいですね」「そうしておかないと他の人が見た時に設定をどう変えたらいいのかがわからなくなってしまいますよね」「特にパスの設定には、末尾に/を書いていいのかいけないのかという情報もぜひ欲しい」「たしかに」

🔗 MemoryStore::DupCoderのマーシャル呼び出しを削減して高速化

このコミットより前のMemoryStore::DupCoderは、dumploadの両方でEntry#dup_value!を呼び出し、キャッシュされた値の外部改変を防止していた。Entry#dup_value!は複雑なオブジェクトを複製するためにMarshal.dumpを呼び出して次にMarshal.loadを呼び出している。しかし外部からの改変を防ぐのであれば、DupCoder.dumpに対して一度だけMarshal.dumpを呼び、DupCoder.loadに対して一度だけMarshal.loadを呼べば済む。

このコミットでは、DupCoderEntry#dup_value!に依存するのではなく、Marshal.dumpMarshal.loadを直接呼び出すように変更し、複雑なオブジェクトの読み書きでMarshalの処理を半減させる。
同PRより


つっつきボイス:「マーシャルの二重呼び出しを解消するという比較的シンプルな最適化ですね」

参考: Rails API ActiveSupport::Cache::MemoryStore
参考: module Marshal (Ruby 3.2 リファレンスマニュアル)


前編は以上です。

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

週刊Railsウォッチ: Jets v4リリース、頑張らない型導入、Rust言語からCrabがforkほか(20230608後編)

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

Rails公式ニュース


CONTACT

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