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

週刊Railsウォッチ: 7.1アップグレードガイドにActive Record暗号化設定の注意事項が追加ほか(20231024前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

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

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

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

今回も以下のコミット差分からChangelogのあるものを中心に見繕いました。多くは細かなドキュメント修正で、Rails 7.1.1に既に含まれているものもありました。

🔗 Active Modelの内部用にdecorate_attributesを追加

このプルリクは、属性の型デコレーション(attribute type decoration)をサポートするためにActive Modelにdecorate_attributesメソッドを追加する。decorate_attributesはprivateの低レベルAPIであり、ActiveRecord::Base::normalizesActiveRecord::Base::enumなどの高レベルAPIによってラップされることを意図している。

同PRより


つっつきボイス:「お、このdecorate_attributesというメソッドがattribute type decorationを行うということですか」「"属性の型デコレーション"としてみましたが、これはいわゆるDecoratorパターンのことでしょうか?」「たぶんそれでよさそうですけど、プルリクメッセージには詳しい説明やChangelogがないので、コードかテストも見てみましょうか」

参考: Decorator パターン - Wikipedia

# activemodel/test/cases/attribute_registration_test.rb#151
    test "::decorate_attributes decorates specified attributes" do
      attributes = default_attributes_for do
        attribute :foo, TYPE_1
        attribute :bar, TYPE_2
        attribute :qux, TYPE_2
        decorate_attributes([:foo, :bar]) { |name, type| MyDecorator.new(name, type) }
      end

      assert_instance_of MyDecorator, attributes["foo"].type
      assert_equal "foo", attributes["foo"].type.name
      assert_same TYPE_1, attributes["foo"].type.cast_type

      assert_instance_of MyDecorator, attributes["bar"].type
      assert_equal "bar", attributes["bar"].type.name
      assert_same TYPE_2, attributes["bar"].type.cast_type

      assert_same TYPE_2, attributes["qux"].type
    end

「属性foobarquxにTYPE_1から3まで型を書いてから、新しいdecorate_attributesMyDecorator.newしている」「MyDecoratorというのはこれですね↓」「上のアサーションを見ると、たとえばattributes["foo"].typeMyDecoratorのインスタンスになっている、なるほど」

# activemodel/test/cases/attribute_registration_test.rb
module ActiveModel
  class AttributeRegistrationTest < ActiveModel::TestCase
    MyType = Class.new(Type::Value)
    Type.register(MyType.name.to_sym, MyType)

    TYPE_1 = MyType.new(precision: 1)
    TYPE_2 = MyType.new(precision: 2)

    MyDecorator = DelegateClass(Type::Value) do
      attr_reader :name
      alias :cast_type :__getobj__

      def initialize(name, cast_type)
        super(cast_type)
        @name = name
      end
    end
...

MyDecoratorは単に型情報を返すだけじゃなくて型変換も行っているっぽい」「Decoratorなので差し替えたりもできるんでしょうね: ActiveRecord::Base::normalizesActiveRecord::Base::enumでラップすると書かれていますし」

「ところで、decorate_attributesを公開したら喜ぶ人が割りといそうな気もしますね」「言われてみれば自分も使いたくなりそうな機能、かな?」

🔗 foreign_key:オプションに誤って配列を渡すと例外を発生するよう変更

関連: #49622

関連付けでは、foreign_keyオプションを配列として渡すことが許可されたこともサポートされたこともなく、これはRails 7.1でも変わらない。ただし、Rails 7.1では複合主キーがサポートされているため、アプリケーションで誤ってforeign_key:に配列が渡されることが増えてくる可能性がある。このコミットによって、foreign_key:が配列として渡されたら例外を発生するようになる。

レビュアーへの質問

自分は最終的にinitializeでチェックを実装した。これにより、関連付けの定義が誤っている場合はアプリケーションを起動できなくなるが、開発者が問題を早期に把握できるようになる。自分は一般的に、起動時のバリデーション実行で例外が発生するかどうかを注意深く見るようにしている。通常なら、アプリケーションで利用できないものや未設定のもの(redis、kafka、elasticsearch、dbなど)があっても起動可能であるべきだと考えている(特に開発中)。しかしこれについては、問題が早急に判明する方が開発者体験の向上につながる良い結果を得られるのではないかと思う。

このチェックをforeign_keyが派生する場所に移動してlazyにチェックすべきと思うのであれば知らせて欲しい。

# https://github.com/rails/rails/blob/dcb1d1f4c4987c3352f8c5f94bb44393b0f18252/activerecord/lib/active_record/reflection.rb#L504-L505
 elsif options[:foreign_key] 
   options[:foreign_key].to_s 

同PRより


つっつきボイス:「関連付けに配列を渡したらinitializeの時点で容赦なくエラーを出すようになったそうです」「foreign_keyにうっかり配列を渡してしまうというのはありそう」

# activerecord/lib/active_record/reflection.rb#376
      def initialize(name, scope, options, active_record)
        super()
        @name          = name
        @scope         = scope
        @options       = options
        @active_record = active_record
        @klass         = options[:anonymous_class]
        @plural_name   = active_record.pluralize_table_names ?
                            name.to_s.pluralize : name.to_s
+       validate_reflection!
      end
...

+       def validate_reflection!
+         return unless options[:foreign_key].is_a?(Array)
+
+         message = <<~MSG.squish
+           Passing #{options[:foreign_key]} array to :foreign_key option
+           on the #{active_record}##{name} association is not supported.
+           Use the query_constraints: #{options[:foreign_key]} option instead to represent a composite foreign key.
+         MSG
+         raise ArgumentError, message
+       end

「実際、関連付けが間違っていてもアプリの起動はできちゃうんですよ」「え、そうなんですか」「そうそう、実際にその関連付けにアクセスしたときに初めて死にます」「まさに実行時エラーなんですね」

「このあたりをどう扱うかは考えどころかも: たとえば既に使われなくなって消す予定のコードにそういう間違いが残っているだけでもアプリが起動しなくなりますけど、果たしてそれでいいのかという問題もありますね」「あ〜なるほど」

「たしかにアプリは起動できなくなるけど、foreign_keyに配列を渡すというあからさまな間違いなら本番より前に見つかる方がいいでしょうね👍」「プルリクを見た感じでは環境によって振る舞いは変わらないみたいですけど、development環境でチェックすれば十分かもしれませんね」「そうですね、開発中にアプリを動かしたりしていると、使えないはずのhas_many関連付けがしれっと見つかったりすることもあるので、その種の間違いはむしろdevelopment環境やtest環境でこそエラーになって欲しいかも」

「ちなみに続きのプルリクで、Changelogがmainブランチから消されていました↓」「7-1-stableのChangelogを見ると7.1.1より後に入っているので、以下のメッセージは7.1.x系を指しているということなんでしょうね: 今後7.1.xにアップデートするときは、今まで見落としていた配列渡しでエラーになるかもしれないので、覚えておくといいかも」

🔗 rails new--databaseオプションなどに誤った値を渡すと通知するようになった

動機/背景

このプルリクを作成した理由は、rails new--database--asset-pipeline--css--javascriptフラグにさまざまなオプションを渡せるにもかかわらず、正しいオプションが指定されているかどうかを確認しているのが--databaseオプションしかないため。

--javascript=esbuiildなどのように誤った値が渡されると、意図に反してimportmapが使われたり未定義の振る舞いを示したりする可能性がある。

詳細

このプルリクは、他の4つのオプションにも--databaseフラグと同様のチェックを追加し、ユーザーが正しい値を入力できるようにする。

追加情報

新しい3つの定数の命名や配置場所に関する意見・提案があれば歓迎。

現状では、この変更によりヘルプテキストのオプションから(default)の記述が失われることになるが、現在のデータベースオプションのリストにも(default)は表示されていない。方法の1つとして、配列の最初の要素をデフォルトとして扱い、それに応じたヘルプテキストを追加して出力する方法が考えられる。

同PRより

なお、以下でリファクタリングとChangelogの追加も行われました↓。


つっつきボイス:「rails newのオプションの話なので継続中の開発には影響しませんが、rails newをしょっちゅう使う自分には嬉しいです」「渡してよい値のリストをここに切り出したのね↓」

# railties/lib/rails/generators/app_base.rb#21
      JAVASCRIPT_OPTIONS = %w( importmap bun webpack esbuild rollup )
      CSS_OPTIONS = %w( tailwind bootstrap bulma postcss sass )
      ASSET_PIPELINE_OPTIONS = %w( sprockets propshaft )

「その代わり、rails new --helpとかで表示する説明文に(default)が改修の都合で出なくなったそうです↓」「Railsガイドあたりにデフォルト値を書いておけば十分な気もしますけどね」

# railties/lib/rails/generators/app_base.rb#L74
- desc: "Choose your asset pipeline [options: sprockets (default), propshaft]"
+ desc: "Choose your asset pipeline [options: #{ASSET_PIPELINE_OPTIONS.join(", ")}]"

「ところで、Rubyのメソッドが={}とかでハッシュのキーワードを受けるようになっていると、存在しないキーワードを渡してもエラーにならないのをちょっと思い出した」「そうそう、HTMLタグを指定できるメソッドなんかだと、どんなタグを渡してもエラーにならないけど、ドキュメントがなくて何を渡すとどう振る舞うのか結局わからないものがあったりしますよね」「Ruby 2.0から入ったキーワード引数なら、存在しないものを渡すとちゃんと怒ってくれますけどね」「違うものを渡すとすぐエラーで知らせてくれると、ちゃんと調べるきっかけになるんですよね」「そういえばJavaScriptのObjectも、存在しないプロパティを呼ぶとエラーにならずに即プロパティが作られるところはRubyのHashにちょっと似ているかも」「まあそういう作法ですから」

参考: Object - JavaScript | MDN

Ruby 2.7: ハッシュからキーワード引数への自動変換が非推奨に(翻訳)

🔗 captureビューヘルパーがHAMLやSlimで空文字列""をキャプチャしたときの振る舞いを修正

関連: #47194 (comment)

HAMLもSlimも、キャプチャブロックの戻り値としてバッファを返していたので、captureヘルパーが混乱する。

こういうチェックをせずに済めば理想的だが、後方互換性を考えれば妥当なトレードオフ。
同PRより


つっつきボイス:「Changelogを見るとHamlやSlimで空文字列""がキャプチャされるとバッファ全体を返していたのを修正したということのようですね」

# actionview/lib/action_view/helpers/capture_helper.rb#L47
      def capture(*args, &block)
        value = nil
        @output_buffer ||= ActionView::OutputBuffer.new
        buffer = @output_buffer.capture { value = yield(*args) }

-       case string = buffer.presence || value
+       string = if @output_buffer.equal?(value)
+         buffer
+       else
+         buffer.presence || value
+       end
+
+       case string
        when OutputBuffer
          string.to_s
        when ActiveSupport::SafeBuffer
          string
        when String
          ERB::Util.html_escape(string)
        end
      end

参考: HAML - Wikibooks
参考: slim/README.jp.md at main · slim-template/slim

「ところでcaptureって何するメソッドだったかな?」「テンプレートの一部を切り出してテンプレート内で再利用するときに便利そうですね↓」「ビューで使い回したいパーシャルをcaptureで定義できるということですか、なるほど」

参考: § 1.5.1 capture -- Action View ヘルパー - Railsガイド

<!-- Railsガイドより -->
<% @greeting = capture do %>
  <p>ようこそ!現在の日時: <%= Time.now %></p>
<% end %>
<!-- Railsガイドより -->
<html>
  <head>
    <title>ようこそ!</title>
  </head>
  <body>
    <%= @greeting %>
  </body>
</html>

「ちなみに、APIドキュメントだと@greetingを2箇所で参照していますね↓: Time.nowをERBに直接書くと2つのメッセージで日時の細かい値が微妙にズレてしまうので、日時を含む文字列を@greetingにキャプチャしてから使えばズレなくなるよというのを見せているんでしょうね」「なるほど」

<!-- APIドキュメントより -->
<% @greeting = capture do %>
  Welcome to my shiny new web page!  The date and time is
  <%= Time.now %>
<% end %>
<!-- APIドキュメントより -->
<html>
<head><title><%= @greeting %></title></head>
<body>
<b><%= @greeting %></b>
</body>
</html>
@greeting # => "Welcome to my shiny new web page! The date and time is 2018-09-06 11:09:16 -0500"

参考: Rails API: capture -- ActionView::Helpers::CaptureHelper

🔗 response_bodyにEnumeratorを渡すとエラーになるのを修正

現状の欠点は、この種のレスポンスではETagsを生成できなくなることだが、Enumeratorを使う場合はバッファされたレスポンスをキャッシュ可能ということが想定されていない。つまり、Enumeratorではストリーミングレスポンスを生成できないことになる。

修正: #49588

以下も参照: #47092
同PRより


つっつきボイス:「引用されているissue #49588を見ると、まず#47092ActionDispatch::ResponseがストリーミングのbodyをRails 7.1でサポートするようになっていて、その後以下のようにresponse_bodyEnumerator.newを渡すとエラーになっていたのが修正されたという流れのようです」

# #49588再現コードより
  def index
    self.response_body = Enumerator.new do |enumerator|
      10.times do |n|
        enumerator << n.to_s
      end
    end
  end

「プルリクメッセージが短かくて状況を把握するのに少々手間取りました😅」「まあエンジニアにしてみればコードとドキュメントを両方書くのは単純に作業が倍になるのと、コードを更新したときにドキュメントの更新を忘れがちになったりすることもあって、プルリクメッセージやドキュメントの量をあまり増やしたくない気持ちになる人がいるのはわかる」「今だったらそれこそAIにドキュメントを書いてもらったらよさそうですよね」「それいいかも」「コードを更新したらAIがAPIドキュメントも自動更新してくれるようになったら最高」「CIに仕込みたい」

参考: APIドキュメンテーションは自動生成の夢を見るか?


後でChatGPT(GPT-4)にこのプルリクのURLを渡してプルリクメッセージを補ってもらいました。

With the introduction of handling enumerators for non-buffered responses, a limitation arises where ETags can no longer be generated for these responses. This change is based on the assumption that when using an enumerator, the intention is not to have a cacheable buffered response. As a consequence of this approach, it's important to note that enumerators cannot be employed to generate streaming responses.

バッファリングされていないレスポンスでEnumeratorのサポートが導入されたことで、そうしたレスポンスでETagを生成できないという制約が生じていた。その変更では、「Enumeratorを渡すことで、レスポンスがキャッシュ可能かつバッファリングされたものではないという意図が示される」という前提をベースとしていた。この方法の結果、Enumeratorではストリーミングレスポンスを生成できなくなっていた点に注意。

🔗 7.1アップグレードガイドにActive Record暗号化設定の注意事項が追加

これは新しいnew_framework_defaultsには記載されているが、古いバージョンからアップグレードするユーザーにとって有用になるよう、アップグレードガイドで明示的に強調するのがふさわしい。

cc @rafaelfranca

動機/背景

このプルリクを作成した理由は、Rails 7.1へのアップグレードでハッシュアルゴリズムを明示的に設定済みにしておかない限り復号エラーが発生する可能性があるため。

追加情報

#48204でも詳しく議論されているが、結論はこのプルリクで追加したアドバイスに盛り込まれていると理解している。
同PRより

上の#49587の続き。おかしな点があったら指摘して欲しい。
まだ#42922#48204での議論を組み立てている最中なので、@boomer196@jorgemanrubiaにもレビューしてもらえるとありがたい。情報が不正確だとproductionでアプリが止まる可能性があるので、可能な限り正確さを担保しておきたい。

同PRより


つっつきボイス:「Rails 7.1では44873でActive Record暗号化のハッシュダイジェストアルゴリズムがデフォルトでSHA-1からSHA-256(SHA-2の一種)に変更されてconfig.active_record.encryption.hash_digest_classでコンフィグ可能になったんですが(ウォッチ20230322)、それに伴ってこの2つのプルリクでいくつか重要な注意事項が追加されました」「ハッシュダイジェストアルゴリズムが変わるということは、暗号を復号できなくなったりするヤツか」

参考: Active Record と暗号化 - Railsガイド
参考: SHA-1 - Wikipedia
参考: SHA-2 - Wikipedia

「Active Record暗号化を使っているRailsアプリを7.1にアップグレードするときは要注意なヤツだ」「忘れた頃にこれを踏んでデータが読めなくなったりする人がいそう」「テストしてちゃんと検証すれば本番前に気づけるとは思いますけどね」


つっつき後にRailsガイドにも反映しました↓。

参考: § 2.11 Active Record 暗号化アルゴリズムの変更について -- Rails アップグレードガイド - Railsガイド

ここで言及されている以下の設定は互いに関連していますが、設定名の後半が同じでも別物なのでご注意ください。


前編は以上です。

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

週刊Railsウォッチ: Kaigi on Rails 2023関連イベント情報公開、複合主キーのlocality解説記事ほか(20231018後編)

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

Rails公式ニュース


CONTACT

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