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

週刊Railsウォッチ: 複合主キーをスキーマから導出可能に、 DBアダプタの例外をconnection_poolに保存ほか(20230628前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

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

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

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


つっつきボイス:「Rails 7.1.0のマイルストーンを見てみると現在13件で前より少し増えてました↓」「経験上Railsのマイルストーンは厳密に運用されているわけではないので、次にどんな機能が入るかという目安ぐらいに考えるのがいいと思います」「issueに締切日も設定されていませんし、そうですね」

「なお、7.1.0マイルストーンの常連だったRackのバージョン3以上必須化対応は一通り機能が揃ったようなのでissueがクローズされていました↓」

参考: Allow rack >= 3 in Rails. by ioquatix · Pull Request #46594 · rails/rails

Rack 2-> Rack 3アップグレードガイド(翻訳)

🔗 with_routingヘルパーをクラスレベルで呼び出せるようになった

with_routingヘルパーをクラスレベルで呼び出し可能になった。クラスレベルで呼び出された場合、以下のようにルーティングがテストごとに設定され、テスト後にリセットされる。

class RoutingTest < ActionController::TestCase
  with_routing do |routes|
    routes.draw do
      resources :articles
      resources :authors
    end
  end

  def test_articles_route
    assert_routing("/articles", controller: "articles", action: "index")
  end

  def test_authors_route
    assert_routing("/authors", controller: "authors", action: "index")
  end
end

Andrew Novoselac
同Changelogより


つっつきボイス:「with_routingというルーティング用テストヘルパーがあったとは」「ルーティングの設定を一時的に追加してテストしたいときに使えるようですね」「複数のテストで使えるようにクラスレベルでも設定できるようになったらしい」「ルーティングのテストって普段あまり書かないせいか使い道はすぐには思いつかないかな」「Railsエンジン的なものやルーティング周りの定義自体を追加するようなgem(Deviseのdevise_scopeなど)で、ルーティングの拡張機能部分をテストするときに便利かも👍」

参考: with_routing -- ActionDispatch::Assertions::RoutingAssertions

動機/背景
このプルリクを作成した理由は、with_routesでセットアップしたルーティングを複数のテストケースで使っているファイルがあるため。テストファイル全体でルーティングをもっと楽にセットアップできるヘルパーが欲しい。

詳細
with_routesの既存の実装を2つのprivateメソッドに切り出した(ルーティング作成用とリセット用)。そしてsetupブロックでルーティングを作成し、teardownブロックでリセットするクラスメソッドを定義した。テストケースでこのクラスメソッドを呼び出せるように、ActionDispatch::AssertionsActionDispatch::Assertions::RoutingAssertionsをconcernsに変換した。
同PRより

🔗 複合主キー関連

🔗 スキーマから複合主キーを導出可能になった

複合主キーを含むスキーマを持つアプリケーションを起動したときにwarningが発生しないようになり、ActiveRecord::Base#primary_keyの値がnilになることもなくなる。
以下のようなtravel_routesテーブル定義とTravelRouteモデルがあるとする。

create_table :travel_routes, primary_key: [:origin, :destination], force: true do |t|
  t.string :origin
  t.string :destination
end

class TravelRoute < ActiveRecord::Base; end

このTravelRoute.primary_keyの値は自動的に["origin", "destination"]に導出される。
Nikita Vasilevsky
同Changelogより


つっつきボイス:「ActiveRecord::Base#primary_keynilになったらたしかに問題」「この改修でprimary_key属性が配列になるということは、これが 文字列を返す前提のコードが動かなくなるんじゃないかな」「あっそうか」

「ところでprimary_key属性は単数形のままなんですね」「スキーマキャッシュのprimary_keysメソッドは前から複数形ですけどね↓」

# activerecord/lib/active_record/attribute_methods/primary_key.rb#129
          def get_primary_key(base_name) # :nodoc:
            if base_name && primary_key_prefix_type == :table_name
              base_name.foreign_key(false)
            elsif base_name && primary_key_prefix_type == :table_name_with_underscore
              base_name.foreign_key
            else
              if ActiveRecord::Base != self && table_exists?
-               pk = connection.schema_cache.primary_keys(table_name)
-               suppress_composite_primary_key(pk)
+               connection.schema_cache.primary_keys(table_name)
              else
                "id"
              end
            end
          end

参考: Rails API primary_key -- ActiveRecord::AttributeMethods::PrimaryKey::ClassMethods
参考: Rails API primary_keys -- ActiveRecord::ConnectionAdapters::SchemaCache

このプルリクは、"Active Record does not support composite primary key" warningを止めてActiveRecord::Base#primary_keyArrayとして導出可能にする。
warningがなくなったので、これを期待する/期待しないテストを削除した。
同PRより

🔗 複合主キーを持つテーブルのfind_eachfind_in_batchesin_batches:asc:descを指定可能になった

find_eachfind_in_batchesin_batchesで複数カラムによる順序付けをサポート
複合主キーがあるテーブルでfind_eachfind_in_batchesin_batchesを実行するときに、キーごとに:asc:descを指定可能になる。

Person.find_each(order: [:desc, :asc]) do |person|
  person.party_all_night!
end

Takuya Kurimoto
同Changelogより


つっつきボイス:「バッチ系メソッドでも複合主キーでorder: [:desc, :asc]のようにキーごとに:ascdescを指定できるようになった: 複合主キー対応したものが増えてきましたね👍」

🔗 データベース関連の例外をconnection_poolに保存するようになった

動機/背景
GitHubでデータベース関連のエラーを調査するたびに、クラスタやロールといったコネクション関連の情報をさらに深掘りすることになる。
このプルリクの目的は、アダプタから発生した例外をそれに関連するconnection_poolに保存することで、アプリケーションがそこに手軽にアクセスして例外トラッキングシステムに送信可能にすること。
Railsがマルチプルデータベースをサポートしていることを考えれば、適切な機能だと思う。

アダプタから例外が発生した場合は常に[1]コネクションプールを提供して、アプリケーションの問題点を掘り下げながらデバッグ可能にする。これは、マルチプルデータベースのRailsアプリケーションを実行する際に重要な機能。
私たちはコネクションやロール、シャードなどの関連コンテキストをconnection_poolで提供する方法を選択した。コネクションを直接を提供する方法は、プールに制御が戻った後で別のスレッドに渡されて誤用される可能性があるため、避けたかった。
ConnectionAdapters::PoolConfigを使ってもよかったのだが、:nodoc:につき採用しなかった。
脚注[1]: 1つ例外的な場合を見つけた。SQlite3のアダプタは、データベースファイルを指定しないとArgumentErrorを発生する。この例外をコネクションプールに含める形で変更するのは不適切だと考える。rails/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb at 02df5fe1e77c38f8086ec45e349ba3d1b25605a0 · rails/rails
同PRより


つっつきボイス:「これはGitHubからのプルリクです」「アダプタレベルの例外をconnection_poolに保存することで例外にアクセスしやすくしたんですね」「改修ではTrilogy以外にPostgreSQLやMySQLやSQLite3のアダプタも対応しているのね」

「データベース関連の例外を解明するのはたいてい難しいし、GitHubは例のTrilogy(MySQL互換のDBクライアント)を使っていたりするので(ウォッチ20230502)こういう例外を追いやすくする機能が切実なのかもしれませんね」「そうそう、TrilogyはGitHubが作ったんでしたね」

trilogy-libraries/trilogy - GitHub
trilogy-libraries/activerecord-trilogy-adapter - GitHub

🔗 エンジンのdraw_pathsをアプリのルーティングに追加するようになった

エンジンのdraw_pathsをアプリケーションのルーティングに追加し、エンジンのパスで定義されているルーティングファイルをアプリケーションで処理できるようになった。
Gannon McGibbon
同Changelogより

参考: Rails API draw -- ActionDispatch::Routing::Mapper::Resources


つっつきボイス:「drawメソッドって使ったことなかった気がするけど、別のルーティングファイルを読み込むメソッドなのか、なるほど」「今までは自動ではできなかったんですね」「改修は1行追加だけ↓」「draw_pathsはエンジン側で読み込ませるんですね」

# railties/lib/rails/engine.rb#L585
    initializer :add_routing_paths do |app|
      routing_paths = paths["config/routes.rb"].existent
      external_paths = self.paths["config/routes"].paths
      routes.draw_paths.concat(external_paths)
+     app.routes.draw_paths.concat(external_paths)

      if routes? || routing_paths.any?
        app.routes_reloader.paths.unshift(*routing_paths)
        app.routes_reloader.route_sets << routes
        app.routes_reloader.external_routes.unshift(*external_paths)
      end
    end

参考: Railsのルーティングをdrawを使ってまとめる - Qiita

🔗 引用符を含むパラメータでのMIMEタイプの扱いを改善した

Mime::TypeがパラメータのMIMEタイプをサポートし、引用符を正しく扱えるようになった。
Acceptヘッダーの解析時にq値パラメータより前のパラメータが保持され、一致するMIMEタイプが存在する場合に利用される。
現行の機能を維持するために、パラメータなしでメディアタイプを検索するフォールバックが作成されている。

この変更によって、たとえばJSON APIapplication/vnd.api+json; profile="https://jsonapi.org/profiles/ethanresnick/cursor-pagination/" ext="https://jsonapi.org/ext/atomic"のような複雑なカスタムMIMEタイプを利用できるようになる。

Nicolas Erni
同Changelogより


動機/背景
このプルリクを作成した理由は、引用符で囲まれたパラメータをMime::Typeが正しく扱えないため。ここには、カンマなどほとんどの文字が含まれる可能性がある。さらに現在の実装では、MIMEタイプを読み込むときにはこのパラメータを無視するにもかかわらず、登録時には無視しない。

修正: #48052

詳細

このプルリクは、Mime::Typeを変更して、引用符で囲まれたパラメータ値内に(RFC 822 3.3で定義されている\r\"以外の)あらゆる文字を含められるようにする(エスケープされた文字を除く: 現在の制約を参照)。

さらに、Acceptヘッダーの他の部分は単純に,で区切られるわけではないが、引用符内で配慮するようになった。

現在の実装では、Mime::Type.registerはパラメータを保持するが、一方でMime::Type.parseはパラメータを削除している。このプルリクでは、すべてのパラメータの前にあるq値パラメーターを(RFC 7231 5.3.2の定義に沿って)保持する。
検索中にパラメータにMIMEタイプが見つからなかった場合は、フォールバックとしてパラメータなしでMIMEタイプを検索する。これはmultipart/form-data; boundary="simple boundary"のような場合に有用であり、かつ現在の振る舞いを変えない。

現在の制約
現時点では以下の制約が存在するが、どこまで対応する価値があるか自分にはわからない。

  • 引用符で囲まれた文字列に\"のようなエスケープ済み文字が含まれる可能性がある。自分の実装では、解析をシンプルにして効率を落とさないためにバックスラッシュを許していない。
  • パラメータの前後にホワイトスペースの違いがある場合、マッチしているとみなされない可能性がある。例: my/type;test="value"my/type; test="value"とマッチしない。
  • RFC 7231 5.3.2によると、パラメータにあるMIMEタイプはパラメータにないMIMEタイプより優先される。しかしこの情報に沿って扱おうとすると、MIMEタイプをパラメータから切り離す必要があり、計算量が増えてしまう。
    同PRより

参考: Rails API Mime::Type


つっつきボイス:「たとえばboundary="simple, boundary"みたいにカンマ区切りを引用符で囲んだものも渡せるようになったんですね」「boundary="simple, boundary", text/xmlみたいに引用符の外にもカンマがあったりすると面倒になりそうだけど、そういうのも扱えるのか」「multipart/form-databoundaryを指定したりするのはよくありますね」

#actionpack/test/dispatch/mime_type_test.rb#83
  test "parse arbitrary media type parameters with comma" do
    accept = 'multipart/form-data; boundary="simple, boundary"'
    expect = [Mime[:multipart_form]]
    assert_equal expect, Mime::Type.parse(accept)
  end

  test "parse arbitrary media type parameters with comma and additional media type" do
    accept = 'multipart/form-data; boundary="simple, boundary", text/xml'
    expect = [Mime[:multipart_form], Mime[:xml]]
    assert_equal expect, Mime::Type.parse(accept)
  end

参考: Accept - HTTP | MDN
参考: MIME type (MIMEタイプ) - MDN Web Docs 用語集: ウェブ関連用語の定義 | MDN


前編は以上です。

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

週刊Railsウォッチ: 書籍『The Rails and Hotwire Codex』、JavaScript Primer改訂2版ほか(20230622後編)

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

Rails公式ニュース


CONTACT

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