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

週刊Railsウォッチ(20210315前編)Active Recordのenum関連改修、Active SupportのEnumerableでpluckが使えるほか

こんにちは、hachi8833です。

週刊Railsウォッチについて

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

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

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

以下のコミットリストのChangelogを中心に見繕いました。

🔗 config.action_text.attachment_tag_nameが追加

Railsフォーラムで、Action TextのHTMLタグ名を変えたいと思う人たちがいる。今はデフォルトではaction-text-attachmentとなっている。現時点でそうする方法のひとつは、リッチテキスト形式の出力をパースしてnokogiriでタグ名を変更すること。
このプルリクは、action-text-attachmentを任意の文字列に変えられるconfig.action_text.attachment_tag_nameオプションを追加する。
同PRより大意


つっつきボイス:「従来のActionText::Attachment::TAG_NAMEは名前がaction-text-attachmentに固定されていたのをユーザーがコンフィグで名前を変えられるようにしたのか↓」「Action Text周りでは最近こんな感じの変更がちょくちょく入ってるようですね」「コンフィグで変えられるものが増えるのはいいと思います👍」

# actiontext/app/helpers/action_text/content_helper.rb#L5
module ActionText
  module ContentHelper
    mattr_accessor(:sanitizer) { Rails::Html::Sanitizer.safe_list_sanitizer.new }
-   mattr_accessor(:allowed_tags) { sanitizer.class.allowed_tags + [ ActionText::Attachment::TAG_NAME, "figure", "figcaption" ] }
+   mattr_accessor(:allowed_tags) { sanitizer.class.allowed_tags + [ ActionText::Attachment.tag_name, "figure", "figcaption" ] }
    mattr_accessor(:allowed_attributes) { sanitizer.class.allowed_attributes + ActionText::Attachment::ATTRIBUTES }
    mattr_accessor(:scrubber)
...
# actiontext/lib/action_text/attachment.rb#L6
  class Attachment
    include Attachments::TrixConversion, Attachments::Minification, Attachments::Caching

-   TAG_NAME = "action-text-attachment"
-   SELECTOR = TAG_NAME
+   mattr_accessor :tag_name, default: "action-text-attachment"
+
    ATTRIBUTES = %w( sgid content-type url href filename filesize width height previewable presentation caption )
...

🔗 enumの予約済みオプション名に_を付けずに書けるようになった


つっつきボイス:「これは今週のPRではありませんが、以下の記事で知りました↓」

参考: Rails introduces new syntax for enum | Saeloun Blog

「なるほど、enumの引数受け取りをenum(attr_name, ..., **options)とすることで、オプションを最後に付ければアンダースコア付きの_prefix_scopesなどではなくアンダースコアなしのprefixscopesなどを使えるようにしたのか」「アンスコなしで書けるのは嬉しい👍」

Attribute APIに組み込まれている他の機能と違い、enumの予約済みオプション名の冒頭には_が付いている。

_付きだった理由は、enumがハッシュ引数を1個しか受け取らないため(このハッシュ引数はenumの定義と予約済みオプションの両方を含む)。

enumの予約済みオプション名でこの_を避けるために、他のAttribute APIの構文のようなenum(attr_name, ..., **options)を使える新しい構文をここに提案する。

変更前:

class Book < ActiveRecord::Base
  enum status: [ :proposed, :written ], _prefix: true, _scopes: false
  enum cover: [ :hard, :soft ], _suffix: true, _default: :hard
end

変更後

class Book < ActiveRecord::Base
  enum :status, [ :proposed, :written ], prefix: true, scopes: false
  enum :cover, [ :hard, :soft ], suffix: true, default: :hard
end

同PRより大意

「既存の_付きオプションとの互換性も確保されているようですね↓」

-     default = {}
-     default[:default] = definitions.delete(:_default) if definitions.key?(:_default)
+     definitions = options.slice!(:_prefix, :_suffix, :_scopes, :_default)
+     options.transform_keys! { |key| :"#{key[1..-1]}" }

コメントを見ると、このリリースでは既存の書式をdeprecateにしないけど、cop(注: RuboCopのルール)でオートコレクトするのが難しくなければdeprecateすべきと書かれてるので、今後非推奨になるのかも」「たぶん最終的には_なしの書式が正式になるんでしょうね」「_付きの_prefix_suffixあたりは既に使っている人がいそう」「そういえば_prefix_defaultは使ったことあります」

参考: ActiveRecord::Enum

enumを本格的に使うようになると、_scope_defaultなどのオプションが欲しくなりますね: 特に_prefixが使えないと不便」「なるほど」「次の2つはこの#41328に関連していそうなenum関連のプルリクです」

🔗 Enum型の属性aggregationを修正


つっつきボイス:「summinimummaximumといった集約系の処理を修正したのか: テストコードが変わっている↓」「"aggregation"は集約関数の集約なんですね」

# activerecord/test/cases/calculations_test.rb#L1165
- def test_aggregate_attribute_on_custom_type
-   assert_nil Book.sum(:status)
-   assert_equal "medium", Book.sum(:difficulty)
-   assert_equal "easy", Book.minimum(:difficulty)
-   assert_equal "medium", Book.maximum(:difficulty)
-   assert_equal({ "proposed" => "proposed", "published" => nil }, Book.group(:status).sum(:status))
-   assert_equal({ "proposed" => "easy", "published" => "medium" }, Book.group(:status).sum(:difficulty))
-   assert_equal({ "proposed" => "easy", "published" => "easy" }, Book.group(:status).minimum(:difficulty))
-   assert_equal({ "proposed" => "easy", "published" => "medium" }, Book.group(:status).maximum(:difficulty))
+ def test_aggregate_attribute_on_enum_type
+   assert_equal 4, Book.sum(:status)
+   assert_equal 1, Book.sum(:difficulty)
+   assert_equal 0, Book.minimum(:difficulty)
+   assert_equal 1, Book.maximum(:difficulty)
+   assert_equal({ "proposed" => 0, "published" => 4 }, Book.group(:status).sum(:status))
+   assert_equal({ "proposed" => 0, "published" => 1 }, Book.group(:status).sum(:difficulty))
+   assert_equal({ "proposed" => 0, "published" => 0 }, Book.group(:status).minimum(:difficulty))
+   assert_equal({ "proposed" => 0, "published" => 1 }, Book.group(:status).maximum(:difficulty))
  end

参考: PostgreSQL 12.4文書9.20. 集約関数
参考: MySQL :: MySQL 5.6 リファレンスマニュアル :: 12.19.1 GROUP BY (集約) 関数

「たとえばテストコードのBook.group(:status).sum(:difficulty))は従来だと{ "proposed" => "easy", "published" => "medium" }を返していたけど、修正後は{ "proposed" => 0, "published" => 1 }を返すようになった: enumが設定されたカラムで#groupしたときの挙動を修正したんですね」

issue #39248#39271を修正するため、#39255#39274では集約結果をattributeの型ごとにキャストするようにした。
しかし#41431を実装したときに、enumのマッピングを回避できることに気づいた。
今回の変更によって、#39039のexpectationが復帰する(特にEnumの場合)。
修正対象: #41600
同PRより大意

🔗 enum attributeに対するserialize(value)がサブタイプごとにキャストされるよう修正


つっつきボイス:「これもenumのattribute関連ですね」「この#39039を含むChangelogが別プルリクになっていたので併記しました↓」「SQLite3ではnilが返り、MySQLとPostgreSQLではエラーになってたのが修正されたんですね」

enum値を元のattributeの型で型キャストするようになった。
注目すべき変更点は、不明なラベルがMySQLの0にマッチしなくなったこと。

class Book < ActiveRecord::Base
  enum :status, { proposed: 0, written: 1, published: 2 }
end

変更前:

# SELECT `books`.* FROM `books` WHERE `books`.`status` = 'prohibited' LIMIT 1
Book.find_by(status: :prohibited)
# => #<Book id: 1, status: "proposed", ...> (for mysql2 adapter)
# => ActiveRecord::StatementInvalid: PG::InvalidTextRepresentation: ERROR:  invalid input syntax for type integer: "prohibited" (for postgresql adapter)
# => nil (for sqlite3 adapter)

変更後:

# SELECT `books`.* FROM `books` WHERE `books`.`status` IS NULL LIMIT 1
Book.find_by(status: :prohibited)
# => nil (for all adapters)

Ryuta Kamizono
同Changelogより大意

🔗 RAILS_DEVELOPMENT_HOSTS環境変数のサポートを追加


つっつきボイス:「RAILS_DEVELOPMENT_HOSTSとは?」「なるほど、ホスト名でアクセス制限するHostAuthorizationで許可済みのホストを、development環境でのみ環境変数からも渡せるようになったんですね↓」「言われてみれば欲しいときがありそう」「現状ではRAILS_ENV=productionでは効かないようなので注意ですね」

# railties/lib/rails/application/configuration.rb#L28
      def initialize(*)
        super
        self.encoding                            = Encoding::UTF_8
        @allow_concurrency                       = nil
        @consider_all_requests_local             = false
        @filter_parameters                       = []
        @filter_redirect                         = []
        @helpers_paths                           = []
        @hosts                                   = Array(([".localhost", IPAddr.new("0.0.0.0/0"), IPAddr.new("::/0")] if Rails.env.development?))
+       @hosts.concat(ENV["RAILS_DEVELOPMENT_HOSTS"].to_s.split(",").map(&:strip)) if Rails.env.development?
        @host_authorization                      = {}

Rails API: ActionDispatch::HostAuthorization

「CI連携で動作確認をプルリク単位で用意するようなHeroku Review Appsのなど環境を、ログ出力の関係などからRAILS_ENV=developmentで動かしたいようなケースでは、環境変数から許可済みホスト名を渡したいというケースがありそうですね: これまでは必要になったら自分でそういうコードを書いていたでしょうけど、Railsが用意してくれるならそれを使いたい👍」「なるほど!」

参考: 環境変数 - Wikipedia

🔗Rails

🔗 Active Recordのconcernをテストする(Ruby Weeklyより)


つっつきボイス:「ActiveSupport::Concernじゃないのかなと思ったら、Active RecordにincludeするActiveSupport::Concernモジュールが対象なのね」

参考: ActiveSupport::Concern

「Active Recordのconcernそのものをテストするには、たしかに記事にもあるようにFakeReviewableのようなfakeのクラスを作ることになるでしょうね↓」

# 同記事より
require_relative 'path/to/reviewable/shared/examples'

class FakeReviewable < ApplicationRecord
  include Reviewable
end

describe Reviewable do
  include InMemoryDatabaseHelpers

  switch_to_SQLite do
    create_table :fake_reviewables do |t|
      t.datetime :reviewed_at
    end
  end

  describe FakeReviewable, type: :model do
    include_examples 'reviewable' do
      let(:reviewable) { FakeReviewable.create }
    end
  end
end

「Rubyのモジュールは、ActiveSupport::Concernも含めて単体ではテストできないので、このようにモジュールをincludeするテスト用のfakeクラスを作ってテストするしかないでしょうね」「ふむふむ」

「これはテストにおけるfakeのパターンというヤツです」「fakeはテスト用語としてのfakeなんですね」「そうです」

参考: xUnit Test PatternsのTest Doubleパターン(Mock、Stub、Fake、Dummy等の定義) - 千里霧中

「テスト関連の記事にfakeとかdoubleという語が出てきたときに用語だとわかりにくくて😅」「慣れれば大丈夫です: テスト関連ドキュメントの文脈でたとえばfakeという語が出てくれば、それはテスト用語としてのfakeのはずです」「なるほど」「fakeやdoubleのような一般的な語が用語に使われるのは、コンピューターサイエンスだと割とあります」「こういう用語はそのつもりで読まないといけないんですね」

morimorihoge注)

専門用語は理解できる人たちにとっては解釈の揺れのない便利な用語なのですが、理解できない人たちにとっては意味不明だったりミスリードを誘ってしまう厄介なものでもあります。
# 類似のもので代表的なのはプロセスにSIGNALを送信するkillコマンドですね

専門用語を知らない人にも分かるよう説明することももちろん大事ですが、技術者同士の会話では専門用語を使う方がより短い時間で精度の高い情報が伝えられるという要素もあるので、こうした技術用語の脳内辞書を育てておくのはエンジニアとして大事なポイントかもしれません。

🔗 Railsセットアップ時に既存データを永続化するときの注意

Everyday Railsサイトの技術ブログです。


つっつきボイス:「Railsのセットアップで注意すべき点に関する記事だそうです」

新しい開発者がプロジェクトに参加しやすくできるよう、Railsアプリのbin/setupスクリプトを活用していますか?そうなっていないのであれば、セットアッププロセスをできるだけ自動化しておく価値があると思います。rails newで提供されているデフォルトのbin/setupは最初に手を付けるのに手頃です。そうしておけば今後参加する開発者の貴重な初動時間を節約できますし、セットアップ手順の潜在的な抜け落ちも見つけられます。(bin/setupは)Visual Studio Codeでコンテナベースの開発環境を構築する際にも非常に便利です。
しかしbin/setupの利用には注意点があります。
同記事冒頭より大意

「なるほど、この記事に書かれているような問題はRailsで開発しているとときどきありますね: Railsアプリのプロジェクトをフルスクラッチでgit cloneしたときはbin/setupが完走するのに、開発を始めた後に「一度全部やり直したい」と思ってbin/setupするとdb:prepareあたりで既にDBが作られていてエラーになるといったことがあります」「あ、心当たりが...😅」

「記事にもあるように、bin/setupのRubyコードを以下のように変更すれば↓、DBが存在していればdb:migrateを実行し、存在しなければdb:setupを実行するようになります」「へ〜、bin/railsコマンドにはdb:existsっていうオプションもあるんですね」

# 同記事より
puts "\n== Preparing database =="
system! 'bin/rails db:exists && bin/rails db:migrate || bin/rails db:setup'

後で調べると、bin/setupの該当部分はデフォルトでは以下のようになっていました(Rails 6.1.2)。

puts "\n== Preparing database =="
system! 'bin/rails db:prepare'

「たとえばCI(Continuous Integration)環境などでも、コンテナおよび依存する一時ファイルが存在していれば使い回し、存在しなければ新規で作成することを意識しないといけませんが、それに通じるものを感じますね」「なるほど」

🔗 Active SupportはEnumerablepluckを追加している

つっつきボイス:「お、再びboringrails.comの記事」「記事冒頭はActive Recordのpluckの使い方の説明、これは普通かな」「mapするよりpluckする方がいいというのはよく言われますね」「単にpluckすると結果が重複することがあるので、一意にしたければdistinct.pluckする、そうそう」

Rails: pluckでメモリを大幅に節約する(翻訳)

「おぉ、Active Supportにもpluckがあるって、マジで?」「やや」「Active SupportがEnumerablepluckを追加しているとは知らなかった!」

# 同記事より
[
  { id: 1, name: "David" },
  { id: 2, name: "Rafael" },
  { id: 3, name: "Aaron" }
].pluck(:name)
# rubydoc.infoより
[{ name: "David" }, { name: "Rafael" }, { name: "Aaron" }].pluck(:name)
# => ["David", "Rafael", "Aaron"]

[{ id: 1, name: "David" }, { id: 2, name: "Rafael" }].pluck(:id, :name)
# => [[1, "David"], [2, "Rafael"]]

「これができるということはハッシュにもpluckできますね: 元記事でもJSON.parseで取得したハッシュにpluckを実行している」「おぉ〜!」「いいこと知りました!」「pluckEnumerableに生えてるのって夢が広がりそう」「Ruby 3.0ならパターンマッチでやりそうな処理ですが、それ以前のRubyでもEnumerable#pluckでやれるのはいい👍」

🔗 その他Rails


つっつきボイス:「お、Rails.benchmarkをいろんな場所で呼べるようになったのか」「割と最近の修正ですね」

# 同記事より: 従来
mattr_accessor :logger, default: Rails.logger
extend ActiveSupport::Benchmarkable
include ActiveSupport::Benchmarkable

def process
  benchmark("=== Processing invoices ===") { process_invoices }
end

「従来だとモデルなどでbenchmarkを呼ぶときは上のようにextendincludeなどを使ったややこしい書き方をしないといけなかった↑けど、変更後は以下のようにRails.benchmarkのブロックに書けばできるようになったんですね↓」「なるほど!」「extendincludeなどを書かなくてもどこにでも書けるのは便利👍」

# 同記事より: 変更後
def process
  Rails.benchmark("=== Processing invoices ===") do
    logger.debug("=== Processing started ===")

    process_invoices

    logger.debug("=== Processing done ===")
  end
end
=== Processing started ===
=== Processing done ===
=== Processing invoices === (400.7ms)

Rails.benchmarklevel: :debugsilence: trueを指定できるのも便利ですね↓」

def process
  Rails.benchmark("=== Processing invoices ===", level: :debug, silence: true) do
    logger.debug("=== This won't be logged ===")

    process_invoices

    logger.debug("=== This won't be logged ===")
  end
end

前編は以上です。

バックナンバー(2021年度第1四半期)

週刊Railsウォッチ(20210309後編)RubyのIRBに隠れているイースターエッグ、Power Automate Desktop、SQLクエリのありがちなミス6つほか

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

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

Rails公式ニュース

Ruby Weekly


CONTACT

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