週刊Railsウォッチ(20191028前編)RailsにSTI用メソッドsti_class_forとpolymorphic_class_forが追加、RuboCopを変更箇所だけにかけるgem、strftime書式生成サイトほか

こんにちは、hachi8833です。Blawn言語が盛り上がってますね。


つっつきボイス:「15歳でプログラミング言語を作ったという話題で、早速Qiitaにもやってみた記事が出てました」「しかもC++で😳」「少し追ってみたところでは、生まれたての言語らしく小さなバグがちょこちょこある様子で、今後を見守っていきたい感じですね☺️」「言語を作る人が増えてきっとMatzは内心とても嬉しかったと思います😋」

  • 各記事冒頭には⚓でパーマリンクを置いてあります: 社内やTwitterでの議論などにどうぞ
  • 「つっつきボイス」はRailsウォッチ公開前ドラフトを(鍋のように)社内有志でつっついたときの会話の再構成です👄
  • 毎月第一木曜日に「公開つっつき会」を開催しています: お気軽にご応募ください

お知らせ: 週刊Railsウォッチ「第16回公開つっつき会」(無料)

第16回目公開つっつき会は、11月14日(木)19:30〜にBPS会議スペースにて開催されます。今回は第一木曜日ではありませんのでご注意ください。

週刊Railsウォッチの記事にいち早く触れられるチャンスです!発言も自由です。皆さまのお気軽なご参加をお待ちしております🙇。

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

今回はmasterのコミットリストから見繕いました。

ActiveSupport::SafeBufferが6.0で動かない問題を修正

# activesupport/lib/active_support/core_ext/string/output_safety.rb#L298
      def set_block_back_references(block, match_data)
        block.binding.eval("proc { |m| $~ = m }").call(match_data)
+     rescue ArgumentError
+       # Can't create binding from C level Proc
      end

#34405with_indexメソッドと互換性がないらしい。
37422より大意

# #37422より
ActiveSupport::SafeBuffer.new('aaa').gsub!(/a/).with_index {|m,i| i }

# 期待する出力: 012
# 実際の出力: ArgumentError

つっつきボイス:「このwith_indexは、each_with_indexとまた別のメソッドでしたね」「こんなメソッドがあったんですね😳」「indexのオフセットも指定できるけど指定しないとゼロから始まるとかそんな感じで☺️」「Enumeratorでインデックスを使いたいときに便利そうですね☺️」「修正はrescue ArgumentErrorを追加して終了」

参考: instance method Enumerator#with_index (Ruby 2.6.0)
参考: with_indexが便利だという話とstable_sort_by - Qiita

新機能: STI用メソッドをActiveRecord::Inheritanceのpublic APIに追加

追加したメソッドを使ってSTIやポリモーフィック関連付けをextendできる。
クラスをリネームしてクラス名がデータベース内のデータとマッチしなくなったときに有用。
自分のモデルに以下のメソッドを実装すると、既に存在しなくなったクラスの名前でレコードを読み込めるようになる。以下はシンプルな実装例。
同PRより

# 同PRより
class Animal < ActiveRecord::Base
  @@old_names = {
    "Lion" => "BigCat"
  }
  def self.sti_name
    name = super
    @@old_names[name] || name
  end

  def self.sti_class_for(type_name)
    @@old_names.inverse[type_name]&.constantize || super
  end
end

つっつきボイス:「STI関連の機能追加だそうです」「extendできるとはエグい😆」「STIでクラス名が変わっても読み込めると: テーブルは変わらない前提ですねなるほど ☺️」

参考: 5 シングルテーブル継承 (STI)– Active Record の関連付け - Rails ガイド

「上の実装例を見るとsti_class_forというAPIが追加されたようなので、例でやっているように継承して使うという意図でしょうね☺️」「self.sti_nameもオーバーライドしておく必要があるのか、へ〜」「sti_nameは前からあったみたい」「これが追加されたメソッドのコード↓」

# activerecord/lib/active_record/inheritance.rb#173
+     # 指定されたtype_nameに対応するクラスを返す
+     #
+     # 継承カラムに保存された値に対応するクラスを探索するのに使われる
+     def sti_class_for(type_name)
+       if store_full_sti_class
+         ActiveSupport::Dependencies.constantize(type_name)
+       else
+         compute_type(type_name)
+       end
+     rescue NameError
+       raise SubclassNotFound,
+         "The single-table inheritance mechanism failed to locate the subclass: '#{type_name}'. " \
+         "This error is raised because the column '#{inheritance_column}' is reserved for storing the class in case of inheritance. " \
+         "Please rename this column if you didn't intend it to be used for storing the inheritance class " \
+         "or overwrite #{name}.inheritance_column to use another column for that information."
+     end

「実装例のLionBigCat、どっちがoldでどっちがnewだろう?🤔」「これだけだとわかりにくいけど、たぶんLionがnewでBigCatがoldなのかな〜」「constantizeは文字列からクラスやモジュールを生成するメソッドでしたね」

参考: constantize — ActiveSupport::Inflector

「なるほど、こう書けばクラス名が変わった場合にデータベースに入っている前のクラス名をアップデートしなくてもよくなると」「さすがに変えてからそのまま放置はしないでしょうね😆」「それやったらカオス確定😆: リネーム前のクラスが存在しなくなってるから後でコード読んだ人がパニクるし😇」「たとえば、いったんこの書き方でしばらく運用して、うまくいくようなら本格的にクラス名を移行するという使い方ができそうですね☺️」「なるほど!」

「あるいは、クラスからクラス名を取れるAPIが前からあるなら、その逆にクラス名からクラスを取れるAPIもあった方がいいよね、という発想で作られたのかもしれないと今思いました☺️」「そういう見方もあるのか😳」「コードのコメントに『このconstantizeはZeitwerkとコンパチなの?』『大丈夫、decorateしてある』というやり取りもありますね」

「これが2つ目に追加されたAPI↓: polymorphic_class_for

# activerecord/lib/active_record/inheritance.rb#L192
+     # 指定されたnameに対応するクラスを返す
+     #
+     # ポリモーフィックtypeカラムに保存された値に対応するクラスを探索するのに使われる
+     def polymorphic_class_for(name)
+       name.constantize
+     end

「そして既存のfind_sti_classsti_class_forを使う形に変わっている↓」「つかsti_class_forに切り出して委譲した形か」「たしかにfind_sti_classはオーバーライドしたくないし😆」「切り出したことで柔軟になった感じですね😋」

# activerecord/lib/active_record/inheritance.rb#L251
        def find_sti_class(type_name)
          type_name = base_class.type_for_attribute(inheritance_column).cast(type_name)
-         subclass = begin
-           if store_full_sti_class
-             ActiveSupport::Dependencies.constantize(type_name)
-           else
-             compute_type(type_name)
-           end
-         rescue NameError
-           raise SubclassNotFound,
-             "The single-table inheritance mechanism failed to locate the subclass: '#{type_name}'. " \
-             "This error is raised because the column '#{inheritance_column}' is reserved for storing the class in case of inheritance. " \
-             "Please rename this column if you didn't intend it to be used for storing the inheritance class " \
-             "or overwrite #{name}.inheritance_column to use another column for that information."
-         end
+         subclass = sti_class_for(type_name)
+
          unless subclass == self || descendants.include?(subclass)
            raise SubclassNotFound, "Invalid single-table inheritance type: #{subclass.name} is not a subclass of #{name}"
          end
+
          subclass
        end

Active Storageのコンパイル済みJSをソースと同期

# activestorage/test/javascript_package_test.rb
+# frozen_string_literal: true
+
+require "test_helper"
+
+class JavascriptPackageTest < ActiveSupport::TestCase
+  def test_compiled_code_is_in_sync_with_source_code
+    compiled_file = File.expand_path("../app/assets/javascripts/activestorage.js", __dir__)
+
+    assert_no_changes -> { File.read(compiled_file) } do
+      system "yarn build"
+    end
+  end
+end

Active Storageのコンパイル済みJSがソースコードと同期しない問題を追った。
これを修正するためにActive Storageのコンパイル済みJSバンドルとソースを強制的に同期するようにした。同期していればテストはパスし、していなければfailする。
同PRより大意


つっつきボイス:「これはテストコードの割と小さな修正ですね☺️」

ActiveModel::Error訳文参照でインデックス付き属性探索をサポート

このPRの意図は、ActiveModel::Errorのインスタンスでattributeがインデックス付きattributeの場合にインデックス付きsuffixなしでメッセージを探索できるようにすること。これはindex_errors: trueを使うActiveRecordモデルによくある。
#37447より大意

# 同PRより
class Manager < ActiveRecord::Base
  has_many :reports, index_errors: true
end

class Report < ActiveRecord::Base
  belongs_to :manager
  validates_presence_of :name
end

manager = Manager.new
invalid_report = Report.new
manager.reports = [invalid_report]
manager.save

error = manager.errors.first
error.attribute
# => :"reports[0].name"
error.type
# => :presence

上のようにattributeがインデックス化されているとする。これはReportレコードの場合はよいが、カスタム訳文メッセージの場合にはインデックスがあるためにうまくいかない。このPRでは探索キーからインデックスを削除する探索エントリを追加することで、インデックスなしで訳文を探索できるようになる。
#37447より大意


つっつきボイス:「エラーのI18n関連の修正だそうで、2つPRがあります」「Reportだとreports[0]みたいなメッセージ参照が生成されるけど、カスタムメッセージのときにインデックスが邪魔だったのね」「1つめのPRの修正は以下のように[インデックス]を削除しただけでした↓」「お〜、attributeからインデックス指定を削るだけでやれちゃうのか😳」「これでインデックスを気にせず書けるようになる😋」

attribute = attribute.to_s.remove(/\[\d\]/)

「で2つ目のPRではさらに上の修正の不足部分が修正されていました↓」「おっと+が漏れてた😆」「あやうく1桁インデックスしか処理できないところだった〜😆」「プルリクが2つ並んでたので気づきました☺️」

# activemodel/lib/active_model/error.rb#L20
-       attribute = attribute.remove(/\[\d\]/)
+       attribute = attribute.remove(/\[\d+\]/)

関連付けリレーションでのインスタンス作成でunscopeが効くように修正

#35868の意図は、関連付けの読み込みを一貫させることにある。関連付けのリレーションでのインスタンス作成は副作用を伴うべきではない。
同PRより大意

上の#35868はウォッチ20191015でも話題にしました。

# activerecord/lib/active_record/association_relation.rb#L18
-   def build(*args, &block)
+   def build(attributes = nil, &block)
      block = _deprecated_scope_block("new", &block)
-     scoping { @association.build(*args, &block) }
+     @association.enable_scoping do
+       scoping { @association.build(attributes, &block) }
+     end
+   end
    alias new build

-   def create(*args, &block)
+   def create(attributes = nil, &block)
      block = _deprecated_scope_block("create", &block)
-     scoping { @association.create(*args, &block) }
+     @association.enable_scoping do
+       scoping { @association.create(attributes, &block) }
+     end
    end

-   def create!(*args, &block)
+   def create!(attributes = nil, &block)
      block = _deprecated_scope_block("create!", &block)
-     scoping { @association.create!(*args, &block) }
+     @association.enable_scoping do
+       scoping { @association.create!(attributes, &block) }
+     end
    end

つっつきボイス:「@kamipoさんによる修正です」「これだけだとわかりにくいので、issue #37138を見てみると、unscopeしたはずの関連付けでunscopeが無視されるという現象が起きてたそうです」「うひゃぁ😱」「そもそもunscope使うなと言いたいけど😆」

期待される動作: new_comment = post.comments.unscope(where: :visible).newでデフォルトスコープがunscopeされるので、new_comment.visibleはfalseになる(そのフィールドのデータベースデフォルト値)
実際の動作: new_comment = post.comments.unscope(where: :visible).newのunscopeが効かず、default_scopeによってnew_comment.visibleがtrueになる

「以前訳した記事↓でも『default_scopeは使うな』というのがありました」「まあdefaultのに限らずunscopeしたくなることはまれになくもないんですけど、unscopeするぐらいならもう一回ビルドし直す方がいいんじゃね?って思いますし😆」「😆」

Railsのdefault_scopeは使うな、絶対(翻訳)

「再現コードでdefault_scopeは…あるか↓😇」「出たな😆」「visibleをVisibleCommentみたいにラップしちゃうなら、そっちの方でdefault_scopeを使うのは自分は別にいいと思いますし」「ふぅむ🤔」

# #37138より
class Comment < ActiveRecord::Base
  belongs_to :post
  default_scope -> { where(visible: true) }
end

「そして修正↓はというと、attributes = nil…?」「従来の*argsは実はattributesで、*は付いてたけどarrayじゃなくてhashを取るという前提だったらしい🤔」

# activerecord/lib/active_record/association_relation.rb#L18
-   def build(*args, &block)
+   def build(attributes = nil, &block)
      block = _deprecated_scope_block("new", &block)
-     scoping { @association.build(*args, &block) }
+     @association.enable_scoping do
+       scoping { @association.build(attributes, &block) }
+     end
+   end
    alias new build

「元の*argsという引数名はふわっとしてて少々雑な感じではありますね😆」「引数名をattributesに変えつつ、何も渡されなかった場合に明示的にnilが渡るようにしたのか」「ここは意味は変わらないはずだからリファクタリングですね☺️」「修正後はscoping { @association.create!(*args, &block) }enable_scoping↓のブロックで囲んでいて、これがキモかな」「難しいコードや😭」「さすが@kamipoさん」

# activerecord/lib/active_record/associations/association.rb#L46
+       @enable_scoping = false
...
+     def enable_scoping
+       @enable_scoping = true
+       yield
+     ensure
+       @enable_scoping = false
+     end

「以下がテストコード↓」「unscopeしてbuildしたらbulb.nameがnilになるのが正しいと」

# activerecord/test/cases/associations/has_many_associations_test.rb#241
+ def test_build_and_create_from_association_should_respect_unscope_over_default_scope
+   car = Car.create(name: "honda")
+
+   bulb = car.bulbs.unscope(where: :name).build
+   assert_nil bulb.name
+
+   bulb = car.bulbs.unscope(where: :name).create
+   assert_nil bulb.name
+
+   bulb = car.bulbs.unscope(where: :name).create!
+   assert_nil bulb.name
  end

「引数に*argsとか書くとレビューでツッコまれます?」「そうとも限らなくて、昔のRailsでは割と見かける書き方でしたし、引数を触らずに委譲したいコードだと*argsって書いたりしてましたね😋」「おぉ」

Rails

Rails 6のAction Cableテスト機能

# 同記事より
require "test_helper"

class PublishCommentaryJobTest < ActionCable::Channel::TestCase
  include ActiveJob::TestHelper

  # `assert_broadcast_on` asserts exact message sent on a channel stream.
  test "publishes commentary" do
    perform_enqueued_jobs do
      assert_broadcast_on(CommentaryChannel.broadcasting_for('match_1'), comment: "Hello and welcome everyone!!") do
        PublishCommentaryJob.perform_later(1, "Hello and welcome everyone!!")
      end
    end
  end

  # `assert_broadcasts` asserts the number of messages sent to stream
  test "asserts number of messages" do
    perform_enqueued_jobs do
      PublishCommentaryJob.perform_later(1, "Hello and welcome everyone!!")
      assert_broadcasts CommentaryChannel.broadcasting_for('match_1'), 1
    end
  end

  # `assert_no_broadcasts` asserts no messages sent to stream
  test "no comment published if invalid match id" do
    perform_enqueued_jobs do
      PublishCommentaryJob.perform_later(-1, "Hello and welcome everyone!!")
      assert_no_broadcasts CommentaryChannel.broadcasting_for('match_1')
    end
  end
end

つっつきボイス:「上の記事に出てくるaction-cable-testingはたしかEvil Martiansの人が作ったgemでした」「ほほぅ☺️」「このgemはRails 5で入りそこなったけどRails 6でマージされたと以下の翻訳記事にあります😋」

Rails 6のB面に隠れている地味にうれしい機能たち(翻訳)

「Action Cableのテスト書いたことないんで、そもそも書けるのか?ってちょっと思いましたけど😆」「テストを自力で書こうとすると難しそうですね🤔」

  • 接続テスト
  • チャネルのテスト
  • ブロードキャストのテスト

参考: ActionCable::Connection::TestCase

レガシープロジェクトでRuboCopを使う

抜粋:

  • .rubocop_todo.ymlを使わない方針で進める
  • 過去のコードよりも今後のコードチェックを重視
    • 古いコードベースをずっとそのままにするということではない
  • 古いコードベースで当面わずらわされないようにするgemの紹介

つっつきボイス:「レガシーコードになるべく触らずにコードチェックする系の記事で、以下のgemも紹介されています」

Pront

「その中でprontoというgemは変更部分だけをRuboCopとかでチェックできるようにするそうです↓」「GitHubにレビューコメント付けてくれる😋」「つまり修正のプルリク作るところまでやってくれるってことですよね😋」「prontoは自体はランナーを呼ぶようですが、RuboCopやさまざまなlintのランナーがずらりとありますね😍」


prontolabs/prontoより

「GitLabでも動くのかな?」「お、GitLabCIもやれるとあります❤️: 割と試しやすそうですし社内のGitLabで使ってみません?」

# GitLabCI用config例
lint:
  image: ruby
  variables:
    PRONTO_GITLAB_API_ENDPOINT: "https://gitlab.com/api/v4"
    PRONTO_GITLAB_API_PRIVATE_TOKEN: token
  only:
    - merge_requests
  script:
    - bundle install
    - bundle exec pronto run -f gitlab_mr -c origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME

参考: Prontoでソースレビュー自動化 - アクトインディ開発者ブログ

Overcommit

「overcommit gemはコミット時にローカルでGit Hooksを起動して必須の処理を回すんだったかな」「社内でも誰か使ってた覚えあります」「設定を欲張り過ぎるとコミットのたびにうるさく鳴き出しそう😆」

overcommitでmasterへのプッシュを禁止するとかもできるそうです↓。

参考: git hooksをovercommitで管理して作業効率の底上げを狙う - LCL Engineers’ Blog

rubocop_lineup

「rubocop_lineupは新しいせいか日本語記事まだないですね」「ブランチでmasterとの差分行だけをRuboCopでチェックする拡張だそうです」

rubocop_lineupになぜこの写真↓?と思って調べると、lineupには『面通しのために並ばせた容疑者の列』という意味もあるんだそうです(米国のみ)。毎度邪魔になるメッセージを常習犯に見立てた感じですね👮🏼‍♀️。


mysterysci/rubocop_lineupより

active_merchant: 支払いサービスの抽象化ライブラリ(Ruby Weeklyより)

# 同サイトより
# ゲートウェイのテストサーバーにリクエスト送信
ActiveMerchant::Billing::Base.mode = :test

# クレジットカードオブジェクトの作成
credit_card = ActiveMerchant::Billing::CreditCard.new(
  :number     => '4111111111111111',
  :month      => '8',
  :year       => '2009',
  :first_name => 'Tobias',
  :last_name  => 'Luetke',
  :verification_value  => '123'
)

if credit_card.valid?
  # TrustCommerceへのゲートウェイオブジェクトを作成
  gateway = ActiveMerchant::Billing::TrustCommerceGateway.new(
    :login    => 'TestMerchant',
    :password => 'password'
  )

  # 10ドル(1000セント)を認証
  response = gateway.authorize(1000, credit_card)

  if response.success?
    # 金額をキャプチャ
    gateway.capture(1000, response.authorization)
  else
    raise StandardError, response.message
  end
end

Active MerchantはeコマースシステムShopifyの抜粋です。このライブラリの主な設計原則は、ShopifyのシンプルAPIや統合APIの要件を用いて、内部APIが大きく異るさまざまな支払いゲートウェイにアクセスすることです。
同リポジトリより

TechRachoにも記事がありました↓。

ActiveMerchant を使ってPayPal Express Checkout の与信取得と回収機能を導入する


つっつきボイス:「支払いを抽象化するという触れ込みのactive_merchantはかなり前からあるみたいで、今も熱心にメンテされているようです」「使ったことなかった😆」「対応している支払いサービスもたくさんあって、当然のようにStripeにも対応してますね😋↓」「StripeにJP含まれてませんけど😆」「なかなか使い所が難しそう: 多言語展開するような全世界向けシステムで、国によって決済業者が違う、みたいなケースでマッチするんだろうけど🤔」


同リポジトリより

Stripe決済を自社サービスに導入してわかった5つの利点と2つの惜しい点

foragoodstrftime.com: strftimeの書式生成サイト


つっつきボイス:「strftimeの書式をさっと調べられるサイトです」「これは覚えられないヤツ😆」「要素をドロップして並べられるとか、随分凝ったサイトですね😆」「まあそこまでせんでも😆」「作ってみたかったんですよきっと☺️」

[Ruby/Rails] strftimeのよく使うテンプレート

なお、以下は以前もウォッチで紹介した同様の趣旨のサイトです。


前編は以上です。

おたより発掘

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

週刊Railsウォッチ(20191021)Rails 6でhas_many関連の修正やSprockets 4.0対応、Shrine 3.0がリリース、Minitestスタイルガイドほか

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

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

Rails公式ニュース

Ruby Weekly

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の監修および半分程度を翻訳、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れて更新翻訳中。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好きで、Goで書かれたRubyライクなGoby言語のメンテナーでもある。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

夏のTechRachoフェア2019

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ