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

週刊Railsウォッチ: insert_allやupsert_allのタイムスタンプ自動更新、app/contextsにロジックを置くほか(20211025前編)

こんにちは、hachi8833です。供給そんなにヤバいのかしら。


つっつきボイス:「電子部品の他に鉄も値上がりしてると聞いてますね」「あ〜」「給湯器の値上がりが著しいとか」「新型MacBook、部品のあるうちに買っとくのがいいのかな...」「Appleはそれなりに部品の流通を確保していると思いますけど、どれかが滞ったら詰まったりして」「欲しいときに買うのが一番」

週刊Railsウォッチについて

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

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

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

今回は以下の公式情報から見繕いました。

🔗 insert_allupsert_allでタイムスタンプを自動更新するオプションが追加

このプルリクは、insert_allまたはupsert_all(および関連するメソッド)でレコードが作成された場合にタイムスタンプのカラム(created_atcreated_onupdated_atupdated_on)を自動設定するオプションを提供する。現時点では、これらのカラムを確実に設定するクリーンな方法は、カラム自体にデフォルトを設定するか、さもなければ属性として明示的に渡したうえでさらに既存レコードのcreated_atを上書きしないようon_duplicateのSQLをカスタマイズするしかなかった。
同PRより


つっつきボイス:「insert_allupsert_allのタイムスタンプって自動更新なのでは?と思いましたけど、このプルリクが出たということは今までは自動更新じゃなかったんですね」「record_timestamps:オプションをtrueにすればinsert_allupsert_allでタイムスタンプが自動更新されるようになったようです↓」

# activerecord/lib/active_record/insert_all.rb#L10
-   def initialize(model, inserts, on_duplicate:, returning: nil, unique_by: nil)
+   def initialize(model, inserts, on_duplicate:, returning: nil, unique_by: nil, record_timestamps: nil)
      raise ArgumentError, "Empty list of attributes passed" if inserts.blank?

      @model, @connection, @inserts, @keys = model, model.connection, inserts, inserts.first.keys.map(&:to_s)
      @on_duplicate, @returning, @unique_by = on_duplicate, returning, unique_by
+     @record_timestamps = record_timestamps.nil? ? model.record_timestamps : record_timestamps

record_timestampsはデフォルトがnilか」「falseを明示的に設定すると今まで通りになるんですね」

# activerecord/test/cases/insert_all_test.rb#L410
+ def test_upsert_all_does_not_implicitly_set_timestamps_on_create_when_model_record_timestamps_is_true_but_overridden
+   with_record_timestamps(Ship, true) do
+     Ship.upsert_all [{ id: 101, name: "RSS Boaty McBoatface" }], record_timestamps: false
+
+     ship = Ship.find(101)
+     assert_nil ship.created_at
+     assert_nil ship.created_on
+     assert_nil ship.updated_at
+     assert_nil ship.updated_on
+   end
+ end

🔗 コントローラの_htmlサフィックスの挙動を修正


つっつきボイス:「これはi18n関連ですね」「以下のようにキーに_htmlというサフィックスを追加するとhtml_safe?がtrueになってそのままビューに出力される機能は前からありますね」「ウォッチでも何度か話題になりました(ウォッチ20180723)」「う、知らなかった」「以下のhelloはエスケープされるけど、hello_htmlhtml_safe?がtrue、つまりサニタイズ済みとして扱われる↓というものです」「なるほど〜」

# actionpack/test/abstract/translation_test.rb#L20
              translation: {
                index: {
                  foo: "bar",
+                 hello: "<a>Hello World</a>",
+                 hello_html: "<a>Hello World</a>",
+                 interpolated_html: "<a>Hello %{word}</a>",
+                 nested: { html: "<a>nested</a>" }
                },
                no_action: "no_action_tr",
              },

参考: 4.4 安全なHTML変換 -- Rails 国際化 (i18n) API - Railsガイド

「そして今回のプルリクを見ると、今まではコントローラとビューで_htmlサフィックスの挙動が違っていたらしい」「え、コントローラでも使えるんですか?」

これは#27872をやり直したもの。

html_safeへの変換の一般的な動作を、Active Supportのprivateなモジュールに抽出するコミットを追加して、Action ViewとAction Packで異なっている挙動を合わせ忘れることのないようにした。

これにより、#39989で実現されたメモリ節約の一部が元に戻される(Action Viewの実装ではチェックが必要な可能性のあるキーごとにhtml_safeオプションをビルドするので)。Action Viewのループ内だけでオブジェクトのアロケーションをメモ化する方法が見つからなかったので、これについては妥協することにした。メモリを節約する方法についてアイデアがあれば求む。
同PRより

AbstractControllerが改修されているので↓、コントローラの実装はビューと別だったみたい: ActiveSupport::HtmlSafeTranslation.translateに切り出して共通化したことで修正したようですね」「なるほど」「この機能をコントローラで使ったことはなかったな〜」

# actionpack/lib/abstract_controller/translation.rb
# frozen_string_literal: true

+require "active_support/html_safe_translation"
+
module AbstractController
  module Translation
    mattr_accessor :raise_on_missing_translations, default: false

...

      i18n_raise = options.fetch(:raise, self.raise_on_missing_translations)
-     I18n.translate(key, **options, raise: i18n_raise)
+
+     ActiveSupport::HtmlSafeTranslation.translate(key, **options, raise: i18n_raise)
    end
    alias :t :translate

    # Delegates to <tt>I18n.localize</tt>. Also aliased as <tt>l</tt>.
    def localize(object, **options)
      I18n.localize(object, **options)
    end
    alias :l :localize
  end
end

🔗 ArelにFILTER句のサポートを追加


つっつきボイス:「PostgreSQLとSQLite3の場合にfilterメソッドが使えるようになった」「MySQLはサポートされてないのか残念」

機能リクエストの多かったrails/arel#518を再度オープンした(rails/arel#460にもある)。
以下のRubyコードを書くことで、

Model.all.pluck(
  Arel.star.count.as('records_total').to_sql,
  Arel.star.count.filter(Model.arel_table[:some_column].not_eq(nil)).as('records_filtered').to_sql,
)

以下のSQLが出力されるようになる。

SELECT
  COUNT(*) AS records_total
  COUNT(*) FILTER (WHERE some_column IS NOT NULL) AS records_filtered
FROM models

サポート対象はPostgreSQL 9.4以降(2014年12月、リリースノート)とSQLite 3.30以降(2019年10月、リリースノート
参考:

「FILTER構文って何でしょう?」「上のModern SQLサイトによると、以下のように集計関数に条件を指定できるとありますね: 集計関数をSELECT文で条件付けするよりも簡潔に書けそう」「お〜!」

# modern-sql.comより
SUM(<expression>) FILTER(WHERE <condition>)

「以下みたいにCASE WHENでもやれますけど↓、条件が増えてくるとどんどん行数が増えてしまう」「それは読みづらそう...」「filterメソッドはそういうときに便利でしょうね👍」

# modern-sql.comより
SUM(CASE WHEN <condition> THEN <expression> END)

参考: Window関数のFILTER句を極める

🔗 ビューのplain textモードの箇条書きを改善


つっつきボイス:「to_plain_textの改善だそうです」「箇条書きがネストしたときの書式を改善したのね」「そもそもto_plain_textというメソッドがあったことを知らなかった」「plain textモードを使う人って少なそうですけど、いるんでしょうね」

# 同PRより
• Item 0
• Item 1
  • Item A
    1. Item i
    2. Item ii
  • Item B
    • Item i
• Item 2

「今実装を見てますけど、" " * (depth - 1)とか"\n#{text}"のあたりが何というか生々しいですね」「自分でゴリゴリ実装したときのような感じが出てる」

# actiontext/lib/action_text/plain_text_conversion.rb#L93
+     def indentation_for_li_node(node)
+       depth = list_node_depth_for_node(node)
+       if depth > 1
+         "  " * (depth - 1)
+       end
+     end
+
+     def list_node_depth_for_node(node)
+       node.ancestors.map(&:name).grep(/^[uo]l$/).count
+     end
+
+     def break_if_nested_list(node, text)
+       if list_node_depth_for_node(node) > 0
+         "\n#{text}"
+       else
+         text
+       end
+     end

🔗 CSRF防止戦略のカスタマイズをサポート

概要
このプルリクは、protect_from_forgeryでのカスタムCSRF防止戦略を渡すサポートをAPIドキュメントで公式に追加する。
その他
現在のRailsは、CSRF保護戦略のカスタマイズを偶然サポートしている。protection_method_classにはクラスまたはシンボルオブジェクトを渡せるし、.to_s.classify呼び出しも両方で同じように振る舞う。
そこで@rafaelfrancaに相談した結果、このメソッドの振る舞いを変更してcase/when文で既存のCSRF防止戦略を明示的に返すようにし、かつ早期リターンによってクラスを戦略として渡せるようにした。
同PRより


つっつきボイス:「protection_method_classでCSRF防止の挙動を変えられるようにしたんですね: csrf-tokenの生成ポリシーを修正したいことはあるかもしれないので」「どんなときに変更したいんでしょうか?」「CSRFトークンの生成をRailsサーバー側以外で行いたい時とかかなあ」「なるほど」「たぶん自分でカスタマイズすると相当複雑になると思いますが」

参考: 3 クロスサイトリクエストフォージェリ (CSRF) -- Rails セキュリティガイド - Railsガイド

RailsのCSRF保護を詳しく調べてみた(翻訳)

🔗 has_secure_password利用時にpassword = nilしても値が残る問題を修正

# 更新情報より
user.password = 'something'
user.password = nil
# before:
user.password # => 'something'
# now:
user.password # => nil

つっつきボイス:「Active Modelのpassword=セッターでnilを代入してもpasswordリーダーで読み出すと消えていなかったのが修正されたのね」「これは修正しないといけないヤツ」「修正はinstance_variable_setを1行追加しただけなんですね↓」

# activemodel/lib/active_model/secure_password.rb#L95

        define_method("#{attribute}=") do |unencrypted_password|
          if unencrypted_password.nil?
+           instance_variable_set("@#{attribute}", nil)
            self.public_send("#{attribute}_digest=", nil)
          elsif !unencrypted_password.empty?
            instance_variable_set("@#{attribute}", unencrypted_password)
            cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
            self.public_send("#{attribute}_digest=", BCrypt::Password.create(unencrypted_password, cost: cost))
          end
        end

🔗Rails

🔗 Zeitwerkにアップグレードした話(Ruby Weeklyより)


つっつきボイス:「RailsアプリのオートローダーをZeitwerkにアップグレードしたときのノウハウ記事です」「ファイルやクラスのリネームも発生したのか」「今どきはたいていZeitwerkになっていると思いますけど、アプリが大きいと後からZeitwerkに乗り換えるのは大変でしょうね」

Rails: Zeitwerkオートロードの「1ファイルにクラスを複数置けない」問題を回避する

🔗 RailsにSorbetを導入

以下はつっつき後に見つけたツイートです。


つっつきボイス:「freee会計などを手掛けているfreeeがRubyの静的型チェッカーSorbetを導入した記事です」「記事でやっているようにビジネスロジックを中心に少しづつ型を追加していくのがよさそうですね」「SorbeとYARDを両方書くのは大変、たしかに」

「Sorbetを使っている会社のリストを見ると、開発元のStripe以外にShopifyなども使ってますね」「以前から型注釈を欲しいと思っていた会社が使っているんでしょうね」「SorbetはLanguage Server Protocolに対応しているのがありがたい」

参考: Official page for Language Server Protocol

先週土曜日のKaigi on Rails 2021のクロージングキーノートでも、RafaelさんがShopifyでSorbetを導入したことを話していましたね。

参考: Keynote by Rafael França - Kaigi on Rails 2021

🔗 Arel入門


つっつきボイス:「ArelはActive Record内部のクエリ生成APIですね」「Arel職人を目指す記事なのかな」「arel_tableとかを見ると昔のトラウマが😅」

# 同記事より
Organization.where(
    Organization.arel_table[:id].in(
    Comment.where(
      Comment.arel_table[:user_id].eq(user.id)
    ).distinct.pluck(:organization_id)
  )
)

参考: ActiveRecordを支える技術 - Arelとは何者なのか? (全5回) その1 - TIM Labs

「Arelを使うとこんなふうに書ける↓」「gt(2)はgreater than 2なんですね」「自分は普通にwhereとプレースホルダ?で書きますけど、事情によってはArelで書くこともたまにあります」

# 同記事より
users[:id].in([1,2,3]).to_sql
=> "`users`.`id` IN (1, 2, 3)"

users[:id].gt(2).to_sql
=> "`users`.`id` > 2"

users[:id].eq(3).to_sql
=> "`users`.`id` = 3"

「Arelでjoinが絡んだりコンポジションしたりするうちにだんだん複雑になりがち」「そうそう」「Arelはありがたい存在だけど、毎回Arelで書く気にはなれないな〜」

# 同記事より
users.join(photos, Arel::Nodes::OuterJoin).on(users[:id].eq(photos[:user_id]))

「BPSだとkazzさんがよくArelを使ってたかも」「Railsで汎用的なモジュールを書こうとするとArelが必要になってくることがあるんですよ」「なるほど」

追記: 今週金曜日の銀座Rails#38で、@osyoさんが『AST を使って ActiveRecord の where の条件式をブロックで記述しよう』というタイトルでお話しされるそうです。

🔗 Railsのビジネスロジックを「contexts」で整理(Ruby Weeklyより)


つっつきボイス:「また新しめのパターン」「contextという言葉のメタ度が高くてどうとでも解釈できそうな感じ」

「contextは、Elixir言語で動くPhoenixフレームワーク↓が由来と記事に書かれていました」「Elixir、知らない世界」「Elixirは見た目がRubyに似てて、型が書けるそうです」

参考: Elixir (プログラミング言語) - Wikipedia

app/contexts/の下にファイルを作って、そこにビジネスロジックを置くスタイルなんですね」「見た感じでは、GoF本で言うところのFacade(ファサード)を普通にcontextsに置いている感じかな🤔」

# app/contexts/accounts.rb
module Accounts
  def self.active_users
    User.all.active
  end

  def self.account_details(id)
    account = Account.find(id)
    # ...
  end
end

# app/contexts/accounting.rb
module Accounting
  def self.create_invoice
    # business logic magic
  end
end

参考: ギャング・オブ・フォー (情報工学) - Wikipedia -- GoF

「ElixirのフォーラムにcontextsとFacadeのことが書かれている↓: まさにFacadeパターンですね」

参考: Contexts in Phoenix 1.3 and Facade Pattern - Phoenix Forum / Chat / Discussions - Elixir Programming Language Forum

「記事では、Service Objectを使いたくないのでcontextにしたそうです」「Service Objectはクラスがやたらと増える傾向があるので、Facadeパターンを使うのはわかる: 自分もその方が好みです」「たしかに」

参考: Facade パターン - Wikipedia

「そういえばService Objectに置くのはたいていFacadeか、もうひとつ何かのパターンのどっちかだと以前おっしゃってましたね」「もうひとつはCommandパターンですね: RailsでService ObjectというとこのCommandパターンを使ったものを指すことが多いようです」「なるほど」「Commandパターンだと基本的に1クラス=1機能になるけど、Facadeパターンはそこにこだわらない感じ」

参考: Command パターン - Wikipedia

Railsのパターンとアンチパターン4: コントローラ編(翻訳)

🔗 GitLabコメント欄にmermaid構文でグラフを書く


つっつきボイス:「GitLabのコメント欄でmermaidというグラフ生成構文を使ってグラフを生成できるそうです」「元記事を見るとGitLab 10.3と随分昔からあるようなので、最近これを発見したのかも」「PlantUMLも使えるのね」

参考: mermaid - Markdownish syntax for generating flowcharts, sequence diagrams, class diagrams, gantt charts and git graphs.
参考: PlantUML: シンプルなテキストファイルで UML が書ける、オープンソースのツール

# docs.gitlab.comより: mermaidの例
graph TB

  SubGraph1 --> SubGraph1Flow
  subgraph "SubGraph 1 Flow"
  SubGraph1Flow(SubNode 1)
  SubGraph1Flow -- Choice1 --> DoChoice1
  SubGraph1Flow -- Choice2 --> DoChoice2
  end

  subgraph "Main Graph"
  Node1[Node 1] --> Node2[Node 2]
  Node2 --> SubGraph1[Jump to SubGraph1]
  SubGraph1 --> FinalThing[Final Thing]
end


docs.gitlab.comより

「コメント欄でちょっぴりグラフを書くのにいいのかも」「自分はDraw.ioを開いてスクショを貼る方が早いかな」「それもそうですね」

後で調べると、draw.ioドメインはセキュリティ上の理由でdiagrams.netドメインに移行していました。

参考: diagrams.net
参考: Blog - Open source diagramming is moving to diagrams.net, slowly


前編は以上です。

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

週刊Railsウォッチ: ruby/debugをChromeでリモートデバッグ、Rubyアプリの最適化ほか(20211019後編)

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

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

Rails公式ニュース

Ruby Weekly


CONTACT

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