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

週刊Railsウォッチ: 複合主キー関連の実装進む、Action TextでHTML5サニタイザほか(20230719前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

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

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

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

🔗 暗号化関連

🔗 EncryptedConfigurationが一部のHashメソッドで誤った値を返していたのを修正

動機/背景

修正: #48554

EncryptedConfigurationは以前更新された。これにより、キーをメソッドのように呼び出し可能になった。その後さらに更新されHashOrderedOptionsの両方の振る舞いをするようになった。しかし2度目の変更で誤って InheritableOptionsインスタンスが別のInheritableOptionsインスタンス内部にネストしてしまうようになった。

これは、外部のInheritableOptionsにキーが存在しない場合、内部のInheritableOptionsにフォールバックするため、ほとんどの場合期待通りに機能していた。しかし、外部のInheritableOptionsをすべてのキーについて知っているかのように扱おうとするメソッド(例: #keys#to_h#to_json)は失敗する。

詳細
このコミットは、余分な外側のInheritableOptionsインスタンスを削除することで問題を修正する。
同PRより


つっつきボイス:「このInheritableOptions.newが余計だったということですね↓: シンプルな修正」「7-0-stableは影響受けてなかった」

# activesupport/lib/active_support/encrypted_configuration.rb#L94
      def options
-       @options ||= ActiveSupport::InheritableOptions.new(deep_transform(config))
+       @options ||= deep_transform(config)
      end

参考: Rails API ActiveSupport::EncryptedConfiguration

🔗 Rails.application.config#inspectsecret_key_baseを出力しないよう修正

Rails.application.config#inspectを呼び出すと、@secret_key_baseを含むすべての属性が表示されてしまう(21c3455以降)。

inspectメソッドをオーバーライドしてクラス名のみを表示することで、機密情報が誤って出力されることを防止できる。

変更前:

Rails.application.config.inspect
"#<Rails::Application::Configuration:0x00000001132b02a0 @root=... @secret_key_base=\"b3c631c314c0bbca50c1b2843150fe33\" ... >"

変更後

Rails.application.config.inspect
"#<Rails::Application::Configuration:0x00000001132b02a0>"

同PRより


つっつきボイス:「前回の改修のバグ修正だそうです(ウォッチ20230704)」「あ〜、@secret_key_baseinspectで見えてたのか: これは修正必要ですね」「これまでinspectが定義されてなくて全部表示されていたのを、inspectをオーバーライドしてclass.nameobject_id以外を表示しないようにしたのか↓」「まあ任意の変数を参照・inspectできる状態という前提の場合は、環境変数を参照すれば見えてしまうと思うので、ログなどで意図しない出力をしてしまう場合の対策なのかなと思います」

# railties/lib/rails/application/configuration.rb#L530
+     def inspect # :nodoc:
+       "#<#{self.class.name}:#{'%#016x' % (object_id << 1)}>"
+     end

🔗 複合主キー関連

🔗 has_one関連付けで複合主キーのdependent: :nullifyをサポート

動機/背景
このプルリクを作成した理由は、現在のhas_one複合主キー関連付けでdependent: :nullifyが動作しないため。

詳細
このプルリクは、ActiveRecord::Associations::HasOneAssociationのnullificationコードを変更し、外部キーカラムをイテレーションして、それらがモデルの主キーに属さない場合にnullifyする。

追加情報
複合主キーの一部がnullになってはいけないシナリオを作成するため、テストモデルを追加しなければならなかったが問題ないだろうか?
同PRより


つっつきボイス:「今回も複合主キー関連の実装が続いてますね」「has_oneの複合主キーでdependent: :nullifyを使えるようにした、なるほど」「フック周りとかにも改修が必要なところとかあったりしそうですね」

🔗 has_oneの関連付けで複合主キーの一部を変更したときに関連付けが保存されるよう修正

動機/背景

has_oneの関連付けで複合主キーを使う場合は、has_oneのオーナーの複合主キーの一部が変更されたら、変更を所属オブジェクトの外部キーに反映する必要がある。

これまでうまく動作していなかった理由は、#_record_changed?が複合主キー関連付けを処理できていなかったためで、オーナーの主キーが変更されたときに所属オブジェクトの外部キーを更新する必要性を自分たちが認識できていなかった。

詳細

#_record_changed?のチェックは変えずに、複合主キーまたは外部キーに対応するように調整する。つまり、オーナーの主キーを構成するすべての属性がレコードに存在することを確認し、次に複合外部キーの値がオーナーの主キーと一致しないことを確認する。これはレコードが変更されたことを表し、それに応じて外部キーの値も更新する必要があることを意味する。
同PRより


つっつきボイス:「これもhas_oneの修正ですね」「_record_changed?で呼び出されるassociation_foreign_key_changedが修正された↓」「複合主キーをサポートし始めると、やることがいろいろ見つかりますね」

# activerecord/lib/active_record/autosave_association.rb#L482
      def association_foreign_key_changed?(reflection, record, key)
        return false if reflection.through_reflection?

-       record._has_attribute?(reflection.foreign_key) && record._read_attribute(reflection.foreign_key) != key
+       foreign_key = Array(reflection.foreign_key)
+       return false unless foreign_key.all? { |key| record._has_attribute?(key) }
+
+       foreign_key.map { |key| record._read_attribute(key) } != Array(key)
      end

🔗 複合主キーのモデルでeager loadingできるようになった

動機/背景
このプルリクを作成した理由は、複合主キーを持つモデルと関連付けのeager loadingが現在壊れているため。

詳細

このプルリクエストでは、joinの依存関係の代入を変更することで、複合主キーを持つノードを適切に処理するようにする。

追加情報

has_many/has_oneリレーションのためにdistinct_relation_for_primary_keyも変更する必要が生じた。以下のような変換の必要があるため、基本的にArray#transposeを使うことにした。

  • [[id], [other_id]][[id, other_id]]に変換
  • [[cpk_id_1, cpk_id_2], [other_cpk_id_1, other_cpk_id_2]][[cpk_id_1, other_cpk_id_1], [cpk_id_2, other_cpk_id_2]]に変換

同PRより


つっつきボイス:「複合主キーのモデルでeager loadingするのはなかなか難しそうだけど、複合主キーを扱う可能性のあるところはひととおり対応しないといけなくなるのは仕方がないですね」「たしかに」「それでもRailsの複合主キー関連がかなりいいテンポで修正されているのがすごい: 想像だけどこれまで複合主キーのgemをやってた人たちもRailsの複合主キー改修に参加してたりするのかな?」「ノウハウありそうなので参加してたら心強いですね」

composite-primary-keys/composite_primary_keys - GitHub

🔗 Active Recordでidをenum値の名前として使えないようにした

#48527の再試行。

上は、primary_keyの呼び出しによってrailtiesテストが壊れてしまったため、#48534で取り消された。

元の問題が引き起こされたのは、ここ#idが呼び出されたため。そのため、このチェックは値が主キーではなくidの場合にのみ実行する必要があると思う。
同PRより


つっつきボイス:「#idというenum名があるとprimary keyのidなのかenum値なのかが混じっちゃうのはわかる」「idみたいにぶつかりそうな名前をどこまで扱うかって悩ましいですね」

# activerecord/lib/active_record/enum.rb#L320
      def detect_enum_conflict!(enum_name, method_name, klass_method = false)
        if klass_method && dangerous_class_method?(method_name)
          raise_conflict_error(enum_name, method_name, type: "class")
        elsif klass_method && method_defined_within?(method_name, Relation)
          raise_conflict_error(enum_name, method_name, type: "class", source: Relation.name)
+       elsif klass_method && method_name.to_sym == :id
+         raise_conflict_error(enum_name, method_name)
        elsif !klass_method && dangerous_attribute_method?(method_name)
          raise_conflict_error(enum_name, method_name)
        elsif !klass_method && method_defined_within?(method_name, _enum_methods_module, Module)
          raise_conflict_error(enum_name, method_name, source: "another enum")
        end
      end

🔗 Action Textで可能な場合はHTML5サニタイザを使うようになった

動機/背景
Action Textを更新して、利用可能な場合にはHTML5サニタイザを使用し、マークアップの解析にはNokogiri::HTML5を使うようにした。従来は、これらの操作でNokogiriのHTML4パーサーが使われていた。

詳細
このプルリクは、ActionText::ContentHelper.sanitizerのデフォルト値をRails::Html::Sanitizer.safe_list_sanitizer.newからRails::Html::Sanitizer.best_supported_vendor.safe_list_sanitizer.newに更新する。.best_supported_vendor
メソッドを利用できるのはrails-html-sanitizer v1.6.0以降であり、これは500ccaaでAction Viewの最小要件となっている。

このプルリクエストでは、"最もサポートされている" HTMLドキュメントとドキュメントフラグメントのクラスを返す2つの新しいメソッド(ActionText.html_document_classおよび ActionText.html_document_fragment_class)も追加される。これらのメソッドは、以前コードが直接Nokogiri::HTMLを参照していた箇所で内部的に使われる。

Nokogiriでの問題を回避するため、#cloneから#dupへの変更が必要。HTML5フラグメントでは#cloneが正しく定義されておらず、フラグメントに親ドキュメントが存在しない可能性がある。#dupなら期待通りに動作するので問題はないはず。

追加情報

これはRailsでHTML5を使うように更新する取組の一環。
同PRより


つっつきボイス:「この間のHTML5(正式にはHTML Living Standard)対応の続きですね(ウォッチ20230621)」「NokogiriのHTML5パーサーを使うようになった、なるほど」

sparklemotion/nokogiri - GitHub

参考: HTML5 - Wikipedia

🔗 to_fsでbeginless/endless rangeをサポート

> (0..1).to_fs(:db)
=> "BETWEEN '0' AND '1'"
> (..1).to_fs(:db)
RangeError: cannot get the first element of beginless range
from .../activesupport/lib/active_support/core_ext/range/conversions.rb:33:in `first'
> (0..).to_fs(:db)
RangeError: cannot get the last element of endless range
from .../activesupport/lib/active_support/core_ext/range/conversions.rb:33:in `last'

期待される動作

> (0..1).to_fs(:db)
=> "BETWEEN '0' AND '1'"
> (..1).to_fs(:db)
=> "LESS THAN '1'"
> (0..).to_fs(:db)
=> "GREATER THAN '0'"

48485より


つっつきボイス:「to_fsは少し前にto_s(:format)が非推奨になって代わりに推奨されたメソッドでしたね(ウォッチ20221025)」「それに..10..みたいなrangeを渡せるようになったんですね」

参考: §10.4.2 to_fs -- Active Support コア拡張機能 - Railsガイド

「へ〜、to_fs:dbを渡すと、idに応答する、つまりid属性を持っていればActive Recordを意識した形で扱えるようになるんですね↓: [user].to_fs(:db)はこのuseridを持っているので"8456"という文字列を返し、invoice.lines.to_fs(:db)のようにlinesコレクションがそれぞれidを持っているので"23,567,556,12"というidリストを文字列で返す」「なるほど」

to_fsメソッドは、デフォルトではto_sと同様に振る舞います。

ただし、配列の中にidに応答する項目がある場合は、:dbというシンボルを引数として渡すことで対応できる点が異なります。この手法は、Active Recordオブジェクトのコレクションに対してよく使われます。返される文字列は以下のとおりです。

[].to_fs(:db)            # => "null"
[user].to_fs(:db)        # => "8456"
invoice.lines.to_fs(:db) # => "23,567,556,12"

上の例の整数は、idへの呼び出しによって取り出されたものとみなされます。
§10.4.2 to_fs -- Active Support コア拡張機能 - Railsガイドより

🔗 has_one関連付けがautosaveされるときは_read_attributeを使うよう修正

動機/背景
このプルリクを作成した理由は、複合主キーモデルが関連する主キーを:idに設定した場合に正しく自動保存されないため。複合主キーを持つモデルでは、#idアクセサは配列を返すが、実際にはidカラムの値が必要。

詳細
このプルリクは、has_oneの自動保存をpublic_sendではなく_read_attributeで行うよう変更し、#idアクセサが使われないようにする。

追加情報
既存のアプリの振る舞いが壊れないかどうかについて懸念はあるだろうか?
同PRより


つっつきボイス:「これも複合主キーとhas_oneidがらみの修正: 配列を返す#idメソッドが複合主キーのモデルで呼ばれないように条件を変更したんですね」「これも地道な作業」

# activerecord/lib/active_record/autosave_association.rb#L446
      def save_has_one_association(reflection)
        association = association_instance_get(reflection.name)
        record      = association && association.load_target
        if record && !record.destroyed?
          autosave = reflection.options[:autosave]
          if autosave && record.marked_for_destruction?
            record.destroy
          elsif autosave != false
-           key = reflection.options[:primary_key] ? public_send(reflection.options[:primary_key]) : id
+           key = reflection.options[:primary_key] ? _read_attribute(reflection.options[:primary_key].to_s) : id

            if (autosave && record.changed_for_autosave?) || _record_changed?(reflection, record, key)
              unless reflection.through_reflection
                Array(key).zip(Array(reflection.foreign_key)).each do |primary_key, foreign_key_column|
                  record[foreign_key_column] = primary_key
                end
                association.set_inverse_instance(record)
              end
              saved = record.save(validate: !autosave)
              raise ActiveRecord::Rollback if !saved && autosave
              saved
            end
          end
        end
      end

🔗 関連付け(belongs_to/has_one/has_many)のサブクエリにスコープを適用するようになった(その後取り消し)

例: has_many :welcome_posts, -> { where(title: "welcome") }
変更前:

Author.where(welcome_posts: Post.all)
#=> SELECT (...) WHERE "authors"."id" IN (SELECT "posts"."author_id" FROM "posts")

変更後:

Author.where(welcome_posts: Post.all)
#=> SELECT (...) WHERE "authors"."id" IN (SELECT "posts"."author_id" FROM "posts" WHERE "posts"."title" = 'welcome')

Lázaro Nixon
同Changelogより


つっつきボイス:「has_manyにlambda(->)を書くことってあんまりしないかも: 自分なら別メソッドを書くかな」「ちょっとわかりにくいけど、変更後を右にスクロールするとWHERE "posts"."title" = 'welcomeが追加されてますね」「変更前は追加されてないということは振る舞いが変わっているので、これを使っている場合は一応気をつけておく必要はありそう」

rafaelfrancaのコメントにもbreaking changeとありますね↓: 自分もそう思う」「rafaelfrancaはオプトインをすすめているけど今のところ入ってないみたい」

これは大きなbreaking changeだ。従来バージョンのRailsでは適用されていなかった条件がこの変更で適用されると結果が異なることになる。

@lazaronixon 関連付けに何らかのオプションを渡したらこの振る舞いをオプトインするプルリクを開いてもらってもよいだろうか?例: has_many :welcome_posts, -> { where(title: "welcome") } , class_name: "Post", apply_on_subqueries: true
#48487のrafaelfrancaのコメントより

なお、#48487は「もっと議論が必要」という理由で取り消されていました。

参考: Revert "Merge pull request #48487 from lazaronixon/scope-subqueries" · rails/rails@e366af5


前編は以上です。

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

週刊Railsウォッチ: AWS LambdaでRailsをRackで動かすLambyほか(20230705後編)

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

Rails公式ニュース

Ruby Weekly


CONTACT

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