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

週刊Railsウォッチ: RailsからOpenStructを削除、Playwrightベストプラクティスほか(20240425前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

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

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

  • お知らせ: 来週および再来週の週刊Railsウォッチはお休みをいただき、通常記事を公開します。

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

🔗 特定のUNIONを含むクエリでSQL構文エラーが生成されてしまう問題を修正

従来のArelでは、LIMITORDER BYを含むUNIONクエリもしくはUNION ALLクエリをビルドすると無効なSQLが生成されていた。このプルリクでは、構文エラーを避けるためにUnionノードとUnionAllノードのSELECTステートメントが丸かっこ()で囲まれるようにArel::Visitors::ToSqlを変更した。
同更新情報より

動機/背景
#40181で説明されているように、ArelでLIMITORDER BYを含むUNIONクエリもしくはUNION ALLクエリをビルドすると、無効なSQLが生成される。

現時点では、Arelを直接使わずにこのようなクエリをビルドできるとは思えないが、LIMITを含む再帰的なCTEをビルドすることについては興味があり(詳しい文脈についてはmastodon/mastodon#29889を参照)、そこでの障害の1つが、Arelが無効なSQLを生成するというものだった。

詳細

このプルリクでは、構文エラーを回避するためにUnionノードやUnionAllノード内のSELECTステートメントを丸かっこ()で囲むようArel::Visitors::ToSqlを変更する。

そのために、既存のArel::Visitors::PostgreSQL#grouping_parenthesesを一般化してArel::Visitors::ToSqlに移動し、これによってinfix_value_with_parenで使われるようになった。

追加情報

自分はActive Recordの内部やサポートされているあらゆるバックエンドについて詳しいわけではないが、このソリューションについては割と自信がある。

結果のSQLに対する仮定が厳密だったため、既存のテストの一部を更新する必要があった。
同PRより


つっつきボイス:「Arelを使わなければビルドできそうにないクエリの修正なんですね」「そもそもLIMITORDER BYを含むUNIONクエリとかをArelで書くことってほとんどなさそうですし」「以下のようなクエリが丸かっこ()で囲まれるようにするようになった↓、なるほど」

# activerecord/test/cases/arel/select_manager_test.rb#L354
        sql = manager.to_sql
        _(sql).must_be_like %{
          WITH RECURSIVE "replies" AS (
-             SELECT "comments"."id", "comments"."parent_id" FROM "comments" WHERE "comments"."id" = 42
+             (SELECT "comments"."id", "comments"."parent_id" FROM "comments" WHERE "comments"."id" = 42)
            UNION
-             SELECT "comments"."id", "comments"."parent_id" FROM "comments" INNER JOIN "replies" ON "comments"."parent_id" = "replies"."id"
+             (SELECT "comments"."id", "comments"."parent_id" FROM "comments" INNER JOIN "replies" ON "comments"."parent_id" = "replies"."id")
          )

「Arelの修正なのでChangelogは書かなかったとありますね」「Arelは一部を除いて基本的にprivate APIですよね(ウォッチ20230405)」

Arelのススメ -- Arelを使ってみよう

🔗 SQLiteのALTER TABLEで仮想カラムが誤って通常のカラムとして複製されていたのを修正

修正: #51522

RailsでSQLiteをALTER TABLEすると、新規テーブルを作成してから古いテーブルの構造とデータを新しいテーブルにコピーする。

ここで問題なのは、仮想カラムが誤って通常のカラムとしてコピーされること。このプルリクではこれを修正する。
同PRより


つっつきボイス:「SQLiteの仮想カラムが誤って通常のカラムとしてコピーされていたのはたしかに修正が必要」「原文のclassic columnsって通常のカラムのことか」「テーブルの新規作成をSQLiteのALTER TABLEしたものにコピーする形で行っているんですね」

「ここで言うSQLite3の仮想カラム(virtual column)っていわゆるgeneratedなカラムのことでしょうか?」「SQLite3のドキュメントを見た感じでは、generatedカラムのうち生成結果を実際にファイルに保存されるのがSTOREDで、いわゆるfunctionalカラム的にその都度生成するのがVIRTUALに該当するということのようですね↓: 前者は生成結果を実際にファイルに保存するのでインデックスを設定できるし、後者はその都度生成するのでインデックスを設定できないことになる」「なるほど」

参考: SQLiteドキュメント Generated Columns

2.1. VIRTUALカラムとSTOREDカラム
生成カラムは、VIRTUALまたはSTOREDのいずれかになります。VIRTUALカラムの値は読み取り時に計算されますが、STOREDカラムの値は行の書き込み時に計算されます。STOREDカラムはデータベースファイル内のスペースを占有しますが、VIRTUALカラムは読み取り時にCPUサイクルを多く消費します。

SQLの観点では、STOREDカラムとVIRTUALカラムはほぼ同じであり、生成カラムのどちらの分類に対するクエリでも、同じ結果が生成されます。唯一の機能の違いは、ALTER TABLE ADD COLUMNコマンドでは新しいSTOREDカラムを追加できないことです。ALTER TABLEで追加できるのはVIRTUALカラムのみです。
Generated Columnsより

🔗 テストで自動先延ばしジョブのエンキューをトランザクション終了後でもチェックできるようになった

修正: #51426(コメント)

perform_laterは、成功した場合はジョブのインスタンスを返し、エラーの場合はfalseを返すことになっている。

enqueueが自動遅延されると、実際のキューイングが成功するかどうかは当然予測不可能だが、後方互換性上の理由から、成功すると仮定するのがベスト。

必要に応じてジョブインスタンスを維持し、トランザクション完了後に#successfully_enqueued?をチェックできるようにした。
同PRより


つっつきボイス:「この間から改修されているジョブエンキューの自動先延ばし(ウォッチ20240416)と関連している感じですね」「自動先延ばしされたジョブがトランザクション終了後にもエンキュー成功かどうかをチェックできるようにした: 後からチェックしたいというニーズはテスト以外にも当然あるのでこれは欲しいヤツ👍」

stub_constexists: falseパラメータを渡すことで、まだ存在していない定数をスタブできるようになった。

Jean Boussier
同Changelogより

# activejob/test/cases/enqueue_after_transaction_commit_test.rb71
  test "#perform_later returns the Job instance even if it's delayed by `after_all_transactions_commit`" do
    fake_active_record = FakeActiveRecord.new(false)
    stub_const(Object, :ActiveRecord, fake_active_record, exists: false) do
      job = EnqueueAfterCommitJob.perform_later
      assert_instance_of EnqueueAfterCommitJob, job
      assert_predicate job, :successfully_enqueued?
    en

「ところで、ジョブのエンキューまで自動で非同期にしたいかどうかというのは少し微妙な点があるかも: 非同期の振る舞いが増えるとややこしくなる一方ですし、たいていの場合はジョブエンジンで十分高速に処理できると信じていいはずなので、少なくともエンキュー部分は基本的に同期的にやりたい気持ちがある」「あ、たしかに!」「このプルリクで述べられているような状況を考えなければならないとしたらGitHubやShopifyぐらいの規模でしょうし、そういう場合は既に非同期エンキュー周りがだいぶややこしくなっていそう」「そういえばJean BoussierさんはShopifyの方ですね」「そのぐらいの規模でもなければエンキューをデフォルトで自動先延ばしすることはほぼないんじゃないかな」「たぶんないと思います」

🔗 Arelでサブクエリに渡される配列のプリペアド可能/不可を修正

動機/背景

このプルリクを作成した理由は、SQLのすべてのINステートメントが自動的に「プリペアド不可」(プリペアドステートメントのこと)とマーキングされることに気づいたため。しかし、少なくともそのArelノードもプリペアド可能である限りはサブクエリもプリペアド可能であるべきに思える。この変更によって、プリペアド不可のArelノードのリストは以下のようになる。

  • SqlLiteral: ここには何が含まれてもよいため。

  • 配列が渡される場合のIn: 配列には任意の個数の項目やリテラル値を含められるため変動性が高く、プリペアドステートメントの候補としては不適切なため。

  • 配列が渡された場合のNotIn: 上のInと同じ理由。

  • HomogenousIn: 配列だけを渡せる(72fd0ba

詳細

このプルリクは、ArelのNodesInNotInを変更して、配列が渡された場合にのみpreparable = falseとマーキングするようにした。それ以外の場合はプリペアド可能なコレクタの状態を変更しない。

Arelはpublic APIではないのでChangelogは作成しなかったが、リクエストがあれば喜んでそうするつもり。
同PRより


つっつきボイス:「プリペアドステートメントは以前も取り上げましたね(ウォッチ20220222)」「INステートメントがプリペアド可能かどうかをArelが判定しているということか: こういうのがどのぐらい正しく動いているのかが少々気になりますね」

「従来は配列の要素型が異なる可能性があるので一律でプリペアド不可にしていたけど、subselectで要素型が揃っていればArelでHomogenousなInが使えるようにしたということらしい: 以下のQuoraを見る限りでは、homogeneousはデータセットの型が同じでheterogeneousはデータセットの型が異なっているとある↓」「72fd0baを見るとhomogeneousなら速くなるみたいですね」「要素型が違う可能性があったら実行時にその都度型チェックしないといけなくなりますけど、homogeneousなら省略できるので速くなりそう」

参考: What's the difference between homogeneous and heterogeneous data sets? - Quora

🔗 OpenStructの除去関連プルリク2件

#51491 (comment)の続き。

上のプルリクがマージされた後で、require "ostruct"がなくなったことによるエラーが1つあったので(CI)、単にOpenStructを使うことをやめればよさそう?
同PRより

ostructjson gemで暗黙にrequireされるが、アップデートされたときにテストでOpenStructを初期化できずに失敗した。

#51510でostructを削除しようとしていたが、今のCIが失敗しているので、つなぎとしてこれをプッシュしている。

同PRより


つっつきボイス:「RubyのOpenStructを削除するプルリクと、それによるエラーを一時的に解消するプルリクがありました」「そもそもOpenStructは遅いし、OpenStructを使うぐらいなら普通にRubyのHashを使う方がいいと思いますね」「なるほど、」「少なくともOpenStructを新規に使うのはやめた方がよいです」

参考: Ruby 3.0 から OpenStruct が非推奨になった

参考: class OpenStruct (Ruby 3.3 リファレンスマニュアル)

🔗 allow_browserコンフィグでブラウザをブロックする場合は406でレスポンスするよう修正

参照: #50505

RFC 9110では以下のように規定されている。

サーバーは、必要なプロトコルを示すためにレスポンス426(Upgrade Required)でUpgradeヘッダーフィールドを送信しなければならない(MUST)。
https://httpwg.org/specs/rfc9110.html#status.426より

ステータス406(Not Acceptable)は、リソースが以下であることを示せるので、より適切。

リクエストで受信したプロアクティブネゴシエーションヘッダーによると、user agentが受け取れる現在の表現がそのリソースに存在しない。
https://httpwg.org/specs/rfc9110.html#status.406より

プロアクティブネゴシエーションについては以下のように述べられている。

クライアントのネットワークアドレスやUser-Agentフィールドなどの暗黙的な特性。
https://httpwg.org/specs/rfc9110.html#proactive.negotiationより

同PRより


つっつきボイス:「以前allow_browserでブラウザの種類をRails側で制限できるようになっていましたね(ウォッチ20240123)」「RFCを丁寧に参照したうえで"426 Upgrade Required"レスポンスを受け取れないブラウザには"406 Not Acceptable"レスポンスを返すべきという修正、言われてみればたしかに」「406用のunsupported browserページも追加されていますね↓」

# railties/lib/rails/generators/rails/app/app_generator.rb#L495
      def delete_public_files_if_api_option
        if options[:api]
          remove_file "public/404.html"
+         remove_file "public/406-unsupported-browser.html"
          remove_file "public/422.html"
-         remove_file "public/426.html"
          remove_file "public/500.html"
          remove_file "public/icon.png"
          remove_file "public/icon.svg"
        end
      end

参考: 426 Upgrade Required - HTTP | MDN
参考: 406 Not Acceptable - HTTP | MDN

🔗Rails

🔗 Playwrightベストプラクティス


つっつきボイス:「Playwrightは以前取り上げたWebのE2Eテストフレームワークでしたね(ウォッチ20230906)」「"ユーザから見えるふるまいをテストする"、"テストはできるだけ分離する"など、その通りだと思います: システムテストはただでさえ遅いのでなるべく分離しておきたい」「"サードパーティの依存関係をテストしない"はものによっては難しいこともあるけどわかる」「お、記事で紹介されているPlaywright用のVS Code拡張↓はよさそう👍: こういうのは人間が手で書くよりツールを使いたいですね」

参考: Playwright Test for VSCode - Visual Studio Marketplace

🔗 RailsのI18nで訳文が見つからない場合の対応


つっつきボイス:「Railsの国際化機能を使ったときに訳文のキーがなくてもデフォルトではwarningが表示されない問題、あるある」「大きく育ったRailsアプリで訳文がない場合の洗い出しを、記事にもあるi18n-tasks gemとかで後から一気にやろうとすると大量の未定義項目が出たり、アルファベット順に並べ替えるdiffが大量に出たりして辛いことになるんですが、国際化を真面目にやろうとすると結局こういうものが必要になってくるんですよ」「そうそう」「このgemは、日本語版に訳文があるけど英語版にない場合にエラーにしてくれたり、訳文をキーでソートしたりできます👍」

参考: 6.2.1 I18n::MissingTranslationDataの処理方法をカスタマイズする -- Rails 国際化(I18n)API - Railsガイド

glebm/i18n-tasks - GitHub

🔗 RubyCademyより


つっつきボイス:「strftimeでdayの部分を%eではなくて%-eのようにマイナス記号-を付けるとdayが1桁のときにスペースを追加しなくなる、これ知らなかった」

#同ツイートより
require 'date'
date = Date.new(2042, 11, 04)

date.strftime('%e %B %Y')  #=> " 4 November 2042"

date.strftime('%-e %B %Y') #=> "4 November 2042"

参考: Time#strftime (Ruby 3.3 リファレンスマニュアル)

-: 左寄せにする(0埋めや空白埋めを行わない)
Time#strftime (Ruby 3.3 リファレンスマニュアル)より

Ruby: strftimeでよく使うテンプレート


前編は以上です。

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

週刊Railsウォッチ: Kamalはゲームチェンジャーになるか、Solid Queueで使われているfugitほか(20240423後編)

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

Rails公式ニュース

Ruby Weekly


CONTACT

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