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

週刊Railsウォッチ: Active Recordのenumにエラーをraiseしないvalidateオプションが追加ほか(20230913前編)

こんにちは、hachi8833です。昨日以下のツイートに気づいて7.1.0のマイルストーンを見に行ってみると、issueが一気にcloseされていました。

参考: 7.1.0 Milestone

その後v7.1.0.beta1タグが追加され、本日Rails 7.1 Beta 1がリリースされました🎉。

参考: Ruby on Rails — Rails 7.1 Beta 1: Dockerfiles, BYO Authentication, More Async Queries, and more!

Rails 7.1 Beta 1がリリースされました

週刊Railsウォッチについて

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

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

お知らせ: 来週の週刊Railsウォッチはお休みとし、通常記事を公開します。

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

先週の改修が公式更新情報に追いついてしまったので、久しぶりにコミットリストから見繕いました。

ドキュメントの更新が少し増えています。7.1リリースノートもだいぶ更新されてきたようです。

🔗 mergerewhereオプションを渡すことが非推奨化された

6.1リリースで、より一貫したmergeの振る舞いを得るために、#39250mergerewhereオプションが追加された。

7.0では、mergeの呼び出しで両方の条件をデフォルトで維持する振る舞いが削除された(515aa1e

リリースノートオリジナルの非推奨警告の両方で、6.1アプリを7.0アプリに移行する方法としてrewhere: trueが表示されている。

7.0は既にリリースされているので、mergeのオプションとしてのrewhereを非推奨化して、rewhereしないバージョンのmergeを削除可能になっている。

同PRより


つっつきボイス:「mergeメソッドは7.0で既にrewhere: trueオプションを指定したのと同じ振る舞いに変わっているので、7.1からはrewhereオプションを付けることが非推奨になって7.2でrewhereオプションが削除されるということみたいですね」「rewhere: truerewhere: falseも今後mergeに渡せなくなるのか」

# activerecord/lib/active_record/relation/spawn_methods.rb#L
    def merge!(other, *rest) # :nodoc:
      options = rest.extract_options!
+
+     if options.key?(:rewhere)
+       if options[:rewhere]
+         ActiveSupport::Deprecation.warn(<<-MSG.squish)
+           Specifying `Relation#merge(rewhere: true)` is deprecated, as that has now been
+           the default since Rails 7.0. Setting the rewhere option will error in Rails 7.2
+         MSG
+       else
+         ActiveSupport::Deprecation.warn(<<-MSG.squish)
+           `Relation#merge(rewhere: false)` is deprecated without replacement,
+           and will be removed in Rails 7.2
+         MSG
+       end
+     end
+ 
      if other.is_a?(Hash)
        Relation::HashMerger.new(self, other, options[:rewhere]).merge
      elsif other.is_a?(Relation)
        Relation::Merger.new(self, other, options[:rewhere]).merge
      elsif other.respond_to?(:to_proc)
        instance_exec(&other)
      else
        raise ArgumentError, "#{other.inspect} is not an ActiveRecord::Relation"
      end
    end

参考: Rails API merge -- ActiveRecord::SpawnMethods

「そういえばRails 6.1の頃に同じカラムをmergeしたときの振る舞いを変更しようという話がありましたね(ウォッチ20200615)」

参考: § 8.3 主な変更点 -- Ruby on Rails 7.0 リリースノート - Railsガイド

  • 同じカラム上で条件をマージした場合に両方の条件が維持されなくなり、常に後者の条件によって置き換わるようになった。
# Rails 6.1 (IN句はマージする側の等値条件によって置き換えられる)
Author.where(id: [david.id, mary.id]).merge(Author.where(id: bob)) # => [bob]
# Rails 6.1 (競合する条件がどちらも存在する: 非推奨)
Author.where(id: david.id..mary.id).merge(Author.where(id: bob)) # => []
# Rails 6.1 でrewhereを用いてRails 7.0の挙動に移行する)
Author.where(id: david.id..mary.id).merge(Author.where(id: bob), rewhere: true) # => [bob]

# Rails 7.0 (IN句の振る舞いは同じで、マージされる側の条件が常に置き換えられる)
Author.where(id: [david.id, mary.id]).merge(Author.where(id: bob)) # => [bob]
Author.where(id: david.id..mary.id).merge(Author.where(id: bob)) # => [bob]

§ 8.3 主な変更点 -- Ruby on Rails 7.0 リリースノート - Railsガイドより


今回の変更の目的は、マージの振る舞いがこれまで一貫していなかったのを統一すること。
現在は、マージされる側(mergee)の条件がマージする側(merger)によって置き換えられるのは、双方のarelノードが等価条件またはIN句の場合のみだった。
言い換えると、マージされる側の条件が等価条件でもIN句でもない場合(betweengtltなど)、双方の条件は同じカラムであっても維持されていた。
これではマージの振る舞いに熟知していないと予測が難しい。
元々自分は、この振る舞いは意図したものではなく単なる実装上の問題だと推測していた。理由は、mergeより後に導入されたunscoperewhereの挙動はmergeよりも一貫性が高くなっているため。
等価条件やIN句は条件のほとんどを占めるのが普通なので、この問題を踏んだことのある人はほとんどいないだろうと推測しているが、一貫性に欠ける現在の振る舞いを非推奨化し、将来のUXを改善するために今後完全に統一したいと思う。
ウォッチ20200615#39328プルリクメッセージを再録

🔗 define_attribute_methodsでエイリアス属性メソッドも再定義できるようになった

修正: #48931

undefine_attribute_methodsは、属性メソッドだけでなくエイリアス属性メソッドも削除するようになった。このコミットでは、define_attribute_methodsを変更して、エイリアス属性が宣言されている場合にメソッドを再定義するようにした。これにより、undefine_attribute_methodsの実行後にエイリアスメソッドを元に戻すオプションがアプリケーションやライブラリで提供されるようになる。

実装の詳細

既存のalias_attribute内部リストに加えて、aliases_by_attribute_nameも追加できるようにする。これは属性名ごとのエイリアス属性リストを保持する。このリストは、メソッドが再定義されるたびにエイリアスを復元するために、define_attribute_methodsで使われるようになる。
基本的には、属性でないものがエイリアス化されている場合を除き、define_attribute_methodsがほとんどの場合においてエイリアス属性メソッドと実際の属性メソッドの両方を定義する責任を持つようになる。

同PRより


つっつきボイス:「属性メソッドを削除するundefine_attribute_methodsが既にエイリアスメソッドも削除するようになったから、属性メソッドを定義するdefine_attribute_methodsもエイリアスメソッドを定義するようにした: このあたりはあまり使ったことない機能だけど、動作が一貫する方がよいでしょうね👍」

参考: Add new behavior of undefine_attribute_methods to CHANGELOG by nvasilevski · Pull Request #49015 · rails/rails

Railsは、#48533でエイリアス属性メソッドが定義されるターゲットを変更した。これにより、undefine_attribute_methodsは属性メソッドだけでなくエイリアス属性メソッドもクリーンアップするようになった。この振る舞いの変更は意図したものだが、適切に文書化やテストが行われていなかった。このコミットでは、Active Modelの変更ログで新しい動作を明文化し、テストでもその振る舞いをカバーした。

#49015より

🔗 gzipされたスキーマキャッシュがGit履歴のロールバックで変更とみなされないよう修正

動機/背景

このプルリクは、同一バージョンのスキーマキャッシュ.gzが同じmd5チェックサムを持つことを保証し、gitで誤った変更を通知しないようにする。

詳細

gzipのデフォルト設定では、変更時刻がファイルヘッダに含まれるため、以下を実行するとdb/schema_cache.yml.gzが変更される。

rails db:schema:cache:dump
rails db:rollback
rails db:migrate
rails db:schema:cache:dump

開発中はマイグレーションを前後にロールバックする必要があり、.gzファイルは中身がわからないため、混乱を引き起す。

これを緩和するために、キャッシュの書き込み前にzipper.mtime = 0を設定する。

同PRより


つっつきボイス:「スキーマの内容は変わっていないのにスキーマファイルのメタデータに記録される変更日付が変わっていた場合に変更扱いされていたのを修正、なるほど」「開発中にこれが起きると困るヤツだ」「スキーマのファイルオープンでzipper.mtime = 0を追加したんですね↓」

# activerecord/lib/active_record/connection_adapters/schema_cache.rb#L468
        def open(filename)
          FileUtils.mkdir_p(File.dirname(filename))
          File.atomic_write(filename) do |file|
            if File.extname(filename) == ".gz"
              zipper = Zlib::GzipWriter.new file
+             zipper.mtime = 0
              yield zipper
              zipper.flush
              zipper.close
            else
              yield file
            end
          end

参考: Zlib::GzipWriter#mtime= (Ruby 3.2 リファレンスマニュアル)

🔗 Active Recordのenumにエラーをraiseしないvalidateオプションが追加された

  • `enumにバリデーションオプションが追加された。
class Contract < ApplicationRecord
  enum :status, %w[in_progress completed], validate: true
end
Contract.new(status: "unknown").valid? # => false
Contract.new(status: nil).valid? # => false
Contract.new(status: "completed").valid? # => true
class Contract < ApplicationRecord
  enum :status, %w[in_progress completed], validate: { allow_nil: true }
end
Contract.new(status: "unknown").valid? # => false
Contract.new(status: nil).valid? # => true
Contract.new(status: "completed").valid? # => true

Edem Topuzov

同Changelogより


動機/背景

#13971はかなり前からあるissue。
@dhh2017年に貢献することを提案したが、オープンされたプルリク#41730 は2021年以降メンテナンスされていなかったので、このプルリクのコードレビューのコメントを考慮するようにした。

詳細

このプルは、enum型に:validateオプションを追加する。

保存する前にenum値をバリデーションしたい場合は、以下のように:validateオプションを使う。

class Conversation < ApplicationRecord
  enum :status, %i[active archived], validate: true
end

conversation = Conversation.new

conversation.status = :unknown
conversation.valid? # => false

conversation.status = nil
conversation.valid? # => false

conversation.status = :active
conversation.valid? # => true

以下のように追加のバリデーションオプション(nilを許す)も渡せる。

class Conversation < ApplicationRecord
  enum :status, %i[active archived], validate: { allow_nil: true }
end

conversation = Conversation.new

conversation.status = :unknown
conversation.valid? # => false

conversation.status = nil
conversation.valid? # => true

conversation.status = :active
conversation.valid? # => true

それ以外の場合(:validateオプションがない)は、ArgumentErrorがraiseされる(現在の標準的な振る舞い)。

class Conversation < ApplicationRecord
  enum :status, %i[active archived]
end

conversation = Conversation.new

conversation.status = :unknown # 'unknown'は無効なステータス(ArgumentError)

追加情報

この変更は破壊的にならないように意図している。

同PRより


つっつきボイス:「お、enum定義にvalidate: trueを追加することでエラーをraiseせずにenumをバリデーションできるようになったんですね👍: 今まではenumの値リストにないものを代入した時点でArgumentErrorになってた」

参考: § 17 enum -- Active Record クエリインターフェイス - Railsガイド

Railsのenumを使いこなす方法(翻訳)

🔗 複合主キーにidを含むモデルでprimary_key: :idを推測するようになった

動機/背景

従来の複合主キーモデルでは、関連付けに対してprimary_keyまたはquery_constraintsオプションを指定する必要があった。これらのオプションがない場合、CompositePrimaryKeyMismatchErrorが発生する。

ほとんどの場合、複合主キーには:id列が含まれることが期待される。関連付けでは既に:idが関連付けの主キーとして使われている(そして関連するモデルは<cpk_model>_idを外部キーとして推測する)ことが期待されている。

アプリケーション全体で複合主キー関連を使うためにユーザーがprimary_key: :idの定義を要求される必要はなく、むしろ:idカラムが使われるべきと推測できる。ユーザーは引き続き、関連付けでprimary_key:またはquery_constraints:を指定することで、この動作をオーバーライドできる。

モデルの複合主キーに:idが含まれていない場合、Railsはそれらの関連付けで主キーを推測しない。ユーザーは引き続きquery_constraintsまたはprimary_keyを指定する必要がある。

この変更の前は、複合主キーモデルの関連付けを設定するために以下の手順が必要だった。

class Order
  self.primary_key = [:shop_id, :id]

  has_many :order_agreements, primary_key: :id
end

class OrderAgreement
  belongs_to :order, primary_key: :id
end

この変更後は、primary_keyオプションを指定する必要はなくなる。

class Order
  self.primary_key = [:shop_id, :id]

  has_many :order_agreements
end

class OrderAgreement
  belongs_to :order
end

詳細

このプルリクのほとんどの変更は、以下の場合に関連付けのprimary_keyを推測することに関係している。

  • a) 関連付けの所有者が複合主キーモデルであり、かつ
  • b) 複合主キーに:idが含まれている場合

最終的に#save_has_one_associationをリファクタリングしたことで、この複合キー推論ロジックを#compute_primary_keyと共有できるようになった。

また、CompositePrimaryKeyMismatchErrorに関連するロジックをテストしていたモデルの一部も変更する必要があった。これは、(Active Recordはこれらの関連付けでprimary_keyを適切に推測できるようになったことで)これらの関連付けがもはや不正な形式ではなくなったため。
自分は「壊れている」モデルの主キーを変更して、id列を除外することにした。これにより、Railsが関連付け上で主キーを推測しないようになり、CompositePrimaryKeyMismatchErrorが期待通りに発生する。

同PRより


つっつきボイス:「これも複合主キーの改修ですね」「これまでの複合主キーモデルで複合主キーにidが含まれている場合はprimary_key: :idhas_many側にもbelongs_to側にも書かないといけなかったけど、複合主キーにidが含まれている場合はprimary_key: :idを書かなくてもよくなった: こう書けるのはたしかに嬉しい👍」「複合主キーにidが含まれていればprimary_key: :idであることが多いでしょうという前提なんですね」「どこまでその前提でいけるかはなかなか難しい問題かもしれませんが」


なお、つっつき後に新しい複合主キーガイドがmainブランチに追加されていることに気づきました↓。

参考: rails/guides/source/active_record_composite_primary_keys.md at main · rails/rails

🔗 whereでトリプルドット... rangeを使うとunscopeが効かない問題を修正

unscopeが特定のケースで動作しなかったのを修正。

# 改修前
Post.where(id: 1...3).unscope(where: :id).to_sql 
# "SELECT `posts`.* FROM `posts` WHERE `posts`.`id` >= 1 AND `posts`.`id` < 3"
# 改修後
    Post.where(id: 1...3).unscope(where: :id).to_sql 
# "SELECT `posts`.* FROM `posts`"

修正: #48094
Kazuya Hatanaka

同Changelogより


動機/背景

修正: #48094

whereでトリプルドット... rangeを使うとunscopeが効かない。

詳細

fetch_attributeactiverecord/lib/arel/nodes/and.rbで定義した(unscopeは述語を除外するかどうかの判断にfetch_attributeを使っている)。

同PRより


つっつきボイス:「ダブルドット..は終端の値を含むrangeで、トリプルドット...は終端を含まないrangeでしたね」「Arelにfetch_attributeを新たに定義することで修正している↓」

# activerecord/lib/arel/nodes/and.rb#L21
+     def fetch_attribute(&block)
+       children.any? && children.all? { |child| child.fetch_attribute(&block) }
+     end

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


なお、この修正はRails 7.0.8にもバックポートされました↓(ce75465)。

Rails 7.0.8がリリースされました

🔗 ActionController::Parametersextract_valueメソッドが追加

パラメータからシリアライズ済みの値を抽出する ActionController::Parameters#extract_valueメソッドを追加。

params = ActionController::Parameters.new(id: "1_123", tags: "ruby,rails")
params.extract_value(:id) # => ["1", "123"]
params.extract_value(:tags, delimiter: ",") # => ["ruby", "rails"]

Nikita Vasilevsky

同Changelogより


このコミットは、複合主キーを持つモデルでシリアライズ済みの複合id値を抽出する主要な手段として、ActionController::Parametersextract_valueメソッドを追加する。

#49020to_paramメソッドを拡張して複合識別子をサポートした後、Action Viewヘルパー(form_for(cpk_record)など)はデフォルトでアンダースコア("_")で結合された複合キーのリソースURLをビルドするようになった。これにより、結合された値をコントローラーが元の複合主キー値に戻す必要が生じる。extract_valueはそのためのヘルパーメソッド。

def show
  identifier = params.extract_value(:id, delimiter: TravelRoute.param_delimiter)
  @travel_route = TravelRoute.find(identifier)
   ...
end

潜在的な懸念

  • 実装があまりにも単純なので、新しいメソッドを追加するほどの正当な理由がない
  • extract_valueという名前は既存のextract!メソッドに似ているが、APIが異なる

同PRより


つっつきボイス:「Shopifyによる改修です」「ActionController::Parametersインスタンスに渡されたid: "1_123"のような複合主キーをextract_valueメソッドで["1", "123"]のように配列の形で取り出せるようになった: 複合主キーは前回も話したように(ウォッチ20230906)デリミタ付きでシリアライズされるので、コントローラでそれを元に戻せるextract_valueメソッドはあって欲しい👍」「まさに前回の#49020の続きなんですね」「実装はsplitメソッド一発という非常にシンプルなもの↓」

# actionpack/lib/action_controller/metal/strong_parameters.rb#934
    def extract_value(key, delimiter: "_")
      @parameters[key].split(delimiter)
    end

参考: § 10.7.3 split(value = nil) -- Active Support コア拡張機能 - Railsガイド


前編は以上です。

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

週刊Railsウォッチ: IRB 1.8.0でデバッグ機能強化、Ruby Prize 2023開催決定ほか(20230908後編)

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

Rails公式ニュース


CONTACT

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