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

週刊Railsウォッチ(20200720前編)10月開催「Kaigi on Rails」CFP募集中、enumにデフォルト値設定機能、RailsでBitemporal Data Modelほか

こんにちは、hachi8833です。情報処理技術者試験の秋の募集が始まりました。


つっつきボイス:「そうそう、春のは中止でしたね」「受験する方はどぞよろしく〜」

  • 各記事冒頭には⚓でパーマリンクを置いてあります: 社内やTwitterでの議論などにどうぞ
  • 「つっつきボイス」はRailsウォッチ公開前ドラフトを(鍋のように)社内有志でつっついたときの会話の再構成です👄

臨時ニュース: Kaigi on RailsのCFPを7月末まで募集中

10/3(土)に「Kaigi on Rails」という大きなイベントがオンライン開催されます🎉。お知らせいただきありがとうございました!🙇

同イベントでは現在CFPを7/31まで募集中です。奮ってご応募ください!

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

以下のコミットログのChangelogを中心に見繕いました。ところでGitHubのコミットログの表示が少し変わってタブがなくなりましたね。

属性のデフォルトをコンフィグ可能にしつつ型を維持するようになった

class Post < ActiveRecord::Base
  attribute :written_at, default: -> { Time.now.utc }
end

# Rails 6.0
Post.type_for_attribute(:written_at) # => #<Type::Value ... precision: nil, ...>

# Rails 6.1
Post.type_for_attribute(:written_at) # => #<Type::DateTime ... precision: 6, ...>

これは#39797の別案。
コンテキスト: #39797のコメント

属性の既存の型をオーバーライドしようとする場合、オーバーライドに用いる型を明示的に指定するのが普通なので、現在の振る舞い(型の指定を省略すると既存の型情報を捨てる)が実用上かなり意味がないことについて同意する。また、これはほぼバグに近く、デフォルトを単にオーバーライドする方法がない。
そこで既存の型についての現在の振る舞いを変更する。修正なのでdeprecation期間は置かない。
#39797はcloseする。
kufu/activerecord-bitemporal/pull/57も参照。
同PRより大意


つっつきボイス:「そういえばいつの頃からかattributedefault:を指定できるようになってましたね」「それを指定したときに今までのようなType::Valueじゃなくてちゃんと中身の型を見るようになったということみたい」「type_for_attributeで取るならこの方がありがたい🙏」

Rails: ActiveRecord標準のattributes APIドキュメント(翻訳)

「変更点↓を見るとwhen Procではもともとtype_for_attribute(name)を返してたけど、それとシンボル以外はType::Value.newを返してたのか」「type_for_attributeで取り出し可能ならこの方がいいですね😋」「前は型の指定を省略すると既存の型情報がなくなってたんですね」「問答無用でValue.newしてたからそういうことでしょう」

# activerecord/lib/active_record/attributes.rb#L246
      def load_schema! # :nodoc:
        super
        attributes_to_define_after_schema_loads.each do |name, (type, options)|
          case type
          when Symbol
            adapter_name = ActiveRecord::Type.adapter_name_from(self)
            type = ActiveRecord::Type.lookup(type, **options.except(:default), adapter: adapter_name)
          when Proc
            type = type[type_for_attribute(name)]
          else
-           type ||= Type::Value.new
+           type ||= type_for_attribute(name)
          end

          define_attribute(name, type, **options.slice(:default))
        end
      end
※(2020/08/06 morimorihoge 修正/追記)初稿時`default:`の設定値にlambdaを設定するのも即値を設定するのも大差ない、といった要旨の内容で公開されておりましたが、明確に間違いですので訂正させて頂きます

PRにあるサンプルコードの`default: => { Time.now.utc }`という書き方は、クラス読み込み時ではなく実行時に動的に現在時刻を設定するときに必要なlambdaの使い方で、`default:`以外のDSLでもよく使われる書き方です。
この書き方をしなかった場合、Rails server起動時の時刻が常に設定されてしまうというRails初心者あるあるなバグを作りこんでしまうことになりますので気を付けましょう。

Active Recordのenum_default:でデフォルト値を設定できるようになった

# 同PRより
class Book < ActiveRecord::Base
  enum status: [:proposed, :written, :published], _default: :published
end

Book.new.status # => "published"

つっつきボイス:「アンスコ付きの_default:で書くのか」「ホントだ」「今までだとenumの初期化時に設定する感じだったのかな?まあ自分はPostgreSQLのenumはともかくRailsのenumってあんまり好きじゃないので使う機会少ないですけど」

# activerecord/lib/active_record/enum.rb#L160
    def enum(definitions)
      klass = self

      enum_prefix = definitions.delete(:_prefix)
      enum_suffix = definitions.delete(:_suffix)
      enum_scopes = definitions.delete(:_scopes)
+
+     default = {}
+     default[:default] = definitions.delete(:_default) if definitions.key?(:_default)
+
      definitions.each do |name, values|
        assert_valid_enum_definition_values(values)
        # statuses = { }
        enum_values = ActiveSupport::HashWithIndifferentAccess.new
        name = name.to_s
        # def self.statuses() statuses end
        detect_enum_conflict!(name, name.pluralize, true)
        singleton_class.define_method(name.pluralize) { enum_values }
        defined_enums[name] = enum_values
        detect_enum_conflict!(name, name)
        detect_enum_conflict!(name, "#{name}=")

        attr = attribute_alias?(name) ? attribute_alias(name) : name
 -      decorate_attribute_type(attr, :enum) do |subtype|
 +      attribute(attr, **default) do |subtype|
          EnumType.new(attr, enum_values, subtype)
        end

受信メールのparamsをURLでRails Conductorフォームに渡せるようになった

受信メールのテストは、Action Mailboxで使うためのfromやtoやsubjectやbodyといったフィールドをいつもRails Conductorフォームに入力しなければならないのがつらくなりがち。
このPRによって、以下のようなリンクを開けばフォームのフィールドに自動で入ってくれるようになる。
http://localhost:3000/rails/conductor/action_mailbox/inbound_emails/new?from=test@example.com
このURLを開くだけでいつでもRails Conductorで手動テストできるので時間を大いに節約できる。
同PRより大意


つっつきボイス:「Action Mailboxのテストというか動作確認が便利になるヤツですね: scaffoldでできるフォームにちまちま値を入れなくてもURL一発で確認できるようになった」「毎回入れるのはダルすぎますし」「地味だけどあると嬉しい機能😂」

issue: 関連付けが2回オートセーブされて一意性違反になる

# 同issueより
class Team < ApplicationRecord
  has_many :memberships
  has_many :players, through: :memberships
end

class Player < ApplicationRecord
  has_many :memberships
  has_many :teams, through: :memberships
end

class Membership < ApplicationRecord
  # memberships has a unique index on player_id and team_id
  belongs_to :player
  belongs_to :team 
end

player = Player.create!(teams: team])
player.update!(updated_at: Time.current)

ここからは以下のマイルストーンより見繕いました。


つっつきボイス:「今週はプルリクが少なめだったのでissueも掘ってみました」「ああ、関連付けのオートセーブが伝搬するヤツ」「after_createのタイミングで起きるらしいとか書いてありました」「この辺はいろいろ微妙なところがあって、たとえば永続化してない同士を関連付けると明示的にsaveしてないのにsaveされちゃったりするときがあるとか」「あ〜」「ActiveRecord::RecordNotUniqueで落ちるのはつらい」「これはバグでしょうね」「オートセーブ周りの挙動を把握するのは難しいので、どこで何が更新されるかがわかりにくいコードはなるべく書かないようにしてます」

後で見ると以下のプルリクがあることに気づきました。現時点ではopenですがそのうちマージされるかもですね。

#38166より前はafter_createコールバックでレコードを1件保存すると、コールバック以後に定義されたコレクション関連付けはすべてオートセーブされないようになっていた。この修正はそれと似ているが別の問題を持ち込んだ。after_createコールバックでレコードを1件保存するとその関連付けのレコードがjoinされて2回insertされるようになった。
コールバックでレコードが保存されるときに@new_record_before_saveの改変を回避する代わりに、直前の値をスタックに保存して後で戻す手が使える。これによって値は代入中や関連付けのオートセーブ時に改変されなくなり、かつ関連付けが複数回オートセーブされることも防げる。
同PRより

# activerecord/lib/active_record/autosave_association.rb#L365
-     def before_save_collection_association
-       @new_record_before_save ||= new_record?
-     end
+     def around_save_collection_association
+       previously_new_record_before_save = (@new_record_before_save ||= false)
+       @new_record_before_save = !previously_new_record_before_save && new_record?

-     def after_save_collection_association
-       @new_record_before_save = false
+       yield
+     ensure
+       @new_record_before_save = previously_new_record_before_save
      end

issue: _rails6_session cookieが近々rejectされる


つっつきボイス:「今年5月頃のissueですが、プルリクの下の方にChromeもついに変更を再開するとありました」「たしかFirefoxはもう変更されてたかな」「_rails6_session cookieがリジェクトされたらびっくりしそう」「たぶん今回の変更で世の中にあるサイトもいくつか動かなくなるかも」

参考: HTTP Cookie - HTTP | MDN

メモ: 安全ではないサイト (http:) は "secure" ディレクティブを付けてクッキーを設定することができなくなりました (Chrome 52 以降および Firefox 52 以降の新機能).
developer.mozilla.orgより

参考: 【一問一答】 Chrome の SameSite Cookie の変更とは? : Cookie への新たなアプローチ | DIGIDAY[日本版]

Rails

activerecord-bitemporal: ActiveRecordでBitemporal Data Modelを扱う


つっつきボイス:「珍しくREADMEが日本語で、@kamipoさんもコントリビュータに入ってますね」「bitemporal?」「履歴的なデータを持たせるということなのかな: 『レコードを読み込む場合は暗黙的に一番最新のレコードを参照します』とあるから現在の値が取れるということか」

employee = nil
Timecop.freeze("2019/1/10") {
  employee = Employee.create(emp_code: "001", name: "Jane")
}

Timecop.freeze("2019/1/15") {
  employee.update(name: "Tom")
}

Timecop.freeze("2019/1/20") {
  employee.update(name: "Kevin")
}

Timecop.freeze("2019/1/25") {
  # 現時点で有効なレコードのみを参照する
  pp Employee.count
  # => 1

  # name = "Tom" は過去の履歴レコードとして扱われるので参照されない
  pp Employee.find_by(name: "Tom")
  # => nil

  # 最新のみ参照する
  pp Employee.all
  # => #<Employee:0x000055ee191468a8
  #     id: 1,
  #     bitemporal_id: 1,
  #     emp_code: "001",
  #     name: "Kevin",
  #     valid_from: 2019-01-20 00:00:00 UTC,
  #     valid_to: 9999-12-31 00:00:00 UTC,
  #     deleted_at: nil>
}

「bitemporalという呼び方は知らなかったけど、これはいわゆるデータベースで時系列データを扱うときのベストプラクティスのひとつですね」「あ、そういうものなんですか」「データは時系列データなんですけど、参照するときは2種類の取り方があります: たとえば普通にJOINするときなんかだと時系列と無関係に現在有効な最新のものだけを取りたいなんてこともよくあるので、そのあたりのデータのルールを事前に決めておかないとうまく取り出せなかったりすることがあるわけです」「ふむふむ」「このデータベース設計はまったくRailsに限らない、普通によく使われる手法です」「それをやりやすくするgemということなんですね」

これにより DB 上に複数の履歴レコードや論理削除されているレコードがあっても『現時点で有効な』レコードが参照されます。
同リポジトリより

employee = nil
Timecop.freeze("2019/1/10") {
  employee = Employee.create(name: "Jane")
}

Timecop.freeze("2019/1/15") {
  employee.update(name: "Tom")
}

Timecop.freeze("2019/1/20") {
  # DB 上では履歴レコードや論理削除済みレコードなどが複数存在するが、暗黙的にクエリが追加されているので
  # 通常の ActiveRecord のモデルを操作した時と同じレコードを返す
  pp Employee.count
  # => 1

  pp Employee.first
  # => #<Employee:0x000056230b1ecdf8
  #     id: 1,
  #     bitemporal_id: 1,
  #     emp_code: nil,
  #     name: "Tom",
  #     valid_from: 2019-01-15 00:00:00 UTC,
  #     valid_to: 9999-12-31 00:00:00 UTC,
  #     deleted_at: nil>

  # 更新前の名前で検索しても引っかからない
  pp Employee.where(name: "Jane").first
  # => nil

  # なぜなら暗黙的に時間指定のクエリが追加されている為
  puts Employee.where(name: "Jane").to_sql
  # => SELECT "employees".* FROM "employees" WHERE "employees"."valid_from" <= '2019-01-20 00:00:00' AND "employees"."valid_to" > '2019-01-20 00:00:00' AND "employees"."deleted_at" IS NULL AND "employees"."name" = 'Jane'
}

「こういう設計にしておかないとうまくできないことがあります: たとえば商品情報として何月何日から販売して何月何日に販売終了するというように期間限定で販売するとか、逆にたとえば年末年始の期間だけ販売しないとか」「おぉ」「よくある適用開始日とか適用終了日とかを設定するもので、業務アプリでは非常によく使われますね」「ちなみに手元の串刺し辞書を引くと解剖用語しか出てきませんでした↓」

bitemporal: {形} 《解剖》: 両側頭骨の


つっつき後に以下のスライドを見つけました。

Shouldaマッチャーのバリデーションマッチャーしか使っていない理由(Ruby Weeklyより)


つっつきボイス:「Shouldaにいろいろ機能はあるけど自分はバリデーションマッチャーで十分、というような記事みたい」「RSpecでいろんなマッチャーを使っているとだんだん訳わからなくなってくることはよくある」「『テストしたいのは振る舞いであって実装ではない』『バリデーションは機能だからバリデーションマッチャーでテストする意味がある』みたいなことを書いてますね」「記事にもうちょっとコード例が多ければ楽に読めたかな😆」


記事の要点:

テストでよく疑問を寄せられるのがShouldaマッチャー。Shouldaマッチャーを使っているかとか、はたしていいものなのかとか。残念ながらShouldaマッチャーの大半は自分が使うことのないものだ。

たとえばActive Recordの関連付けが存在しているかどうかのテストだけを書くことにはあまり意義を感じられない。関連付けの存在テストと関連付けがあることでの振る舞いテストが両方あるとしたら前者に価値はないし、Postの関連付けの存在テストがあってもPostの振る舞いテストがなければあまり意味がない。自分は実装(が存在するかどうか)よりも振る舞いをテストしたい。

しかしバリデーションマッチャーは別。バリデーションはそれ自体が目的であり機能なのでShouldaマッチャーでテストする意義があると思う。Shouldaを使わないとしたら、バリデーションテストをきっぱり書かないか延々とバリデーションテストを書くかになるけど、自分はどちらも良くないと思うので、Shouldaに意義があるとすればやはりバリデーションマッチャーの部分だと思う。

discard: 論理削除gem(Ruby Weeklyより)

# 同リポジトリより
Post.all             # => [#<Post id: 1, ...>]
Post.kept            # => [#<Post id: 1, ...>]
Post.discarded       # => []

post = Post.first   # => #<Post id: 1, ...>
post.discard        # => true
post.discard!       # => Discard::RecordNotDiscarded: Failed to discard the record
post.discarded?     # => true
post.undiscarded?   # => false
post.kept?          # => false
post.discarded_at   # => 2017-04-18 18:49:49 -0700

Post.all             # => [#<Post id: 1, ...>]
Post.kept            # => []
Post.discarded       # => [#<Post id: 1, ...>]

つっつきボイス:「論理削除のgem来た」「新しめの割に★も1000超えてる」「正しくやれるとあるけど」

「あ〜、このundiscard↓を許すかどうかがめちゃ考えどころなんですよ...」「discardを取り消すってことですね」「二重否定っぽいところがちょっと嫌な予感😅」「だいたいundiscard的なことをやるとおかしくなりがちで、論理削除はそのものはまだしも、論理削除を取り消すとデータの整合性を維持するのが難しくなってくることが多い」「う〜む」

# 同リポジトリより
post = Post.first   # => #<Post id: 1, ...>
post.undiscard      # => true
post.undiscard!     # => Discard::RecordNotUndiscarded: Failed to undiscard the record
post.discarded_at   # => nil

「まあ論理削除したい人はだいたい後で戻したがる気がしますけど😆」「戻したかったら履歴で持っておいてまた作り直すという考え方もありますし」「単なるログデータぐらいだったら戻してもいいかなという気はしますけど」「データがロジックとかコールバックと結びついているときにundiscardすると割と地獄を見る👹」

「Railsはデータの制約をアプリケーション側に持たせようとする傾向があるので、論理削除はRailsとは特に相性悪そうな気がするんですよ」「自分もそう思います」「データベースだったらトリガーでやるようなことをRailsだとコールバックでやったりしますし」「データベースレベルで制約がかかっていれば、仮にdiscardundiscardに失敗しても整合性は失われませんけど」「まあ論理削除を使うなら慎重にやらないと」「でないと思いもよらないところでぬるぽが出るかもですし」「参照が失われるとつらい...」

参考: ぬるぽ - Wikipedia

「READMEの下の方に、なぜacts_as_paranoidとか↓を使わないかみたいなこと書いてますね」「paranoid系の論理削除gemはActive Recordのメソッドをオーバーライドするのが怖いんですよ」「deleteとかdestoryのオーバーライドはちょっとね...😅」

「一方このdiscard gemは論理削除メソッドを別に用意しているので、アプリケーションエンジニアが『今自分は論理削除しようとしているゾ』と意識できるようにするという発想でしょうね」「その方がお行儀がよろしい😋」「やるならdiscardのやり方の方がましかな〜」「少なくとも気が付かないうちに論理削除してしまうことは減るでしょうね」

RubyやRailsでコードを共有する方法おさらい(Hacklinesより)


つっつきボイス:「RubyやRailsでコードを共有する方法をカタログ的に書いた記事です」「昔ながらのRuby継承とか」「STIとか」「MIXINとか」「Module#includedとか」「デリゲートとか」

「Rubyのdef_delegatorって知らなかった〜」「へぇ〜」「へぇ〜」「まああってもいいですよね😋」

参考: Forwardable#def_delegator (Ruby 2.7.0 リファレンスマニュアル)

「まあRailsにはdelegateが前からありますけど↓」「Active Supportにあるヤツ」「記事にもあった」

参考: Module#delegate

「ところでこの記事には不思議にActiveSupport::Concernの話が載ってませんね」「あらホントだ」「モジュールインクルードの話があるならありそうなものですけど」

参考: ActiveSupport::Concern


記事見出しより:

  • 昔ながらのRuby継承
  • STI
  • クラスやモジュールにincludeする
  • extendでオブジェクトをミックス
  • Module#included
  • Rubyクラスレベルのデリゲート: Forwardable
  • SimpleDelegator
  • Railsのdelegate(シンタックスシュガー)

前編は以上です。

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

週刊Railsウォッチ(20200714後編)ruby-warning gemでワーニングを手軽に抑制、rubocop -aの振る舞いが変わる、書籍『MySQL徹底入門 第4版』ほか

今週の主なニュースソース

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

Rails公式ニュース

Ruby Weekly

Hacklines

Hacklines


CONTACT

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