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

週刊Railsウォッチ: マイグレーションをStrategyパターンで拡張可能にほか(20220704前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

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

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

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

参考: Comparing @{2022-06-23}...main@{2022-06-30} · rails/rails

🔗 マイグレーションをExecutionStrategyでカスタマイズ可能にする

概要
このプルリクはマイグレーションの実行周りにStrategyパターンを導入する。現在のMigrationオブジェクトは、スキーマステートメントのコマンドをコネクションアダプタに委譲するのにmethod_missingを使う。中間的なストラテジーオブジェクトを導入してアプリケーションが独自のストラテジーオブジェクトを構成可能にすることで、アプリケーション作者がマイグレーションを実行するときの柔軟性が大きく向上する。たとえばユーザーは以下を行いたいことがあるだろう。

  • スキーマステートメントに#create_tableなどのキーワード引数を追加する
  • 特定の操作を禁止する(特定メソッドが呼び出されたらそのストラテジーがraiseできるようにするなど)
  • dbスキーマに実際の変更を加えない以下のようなマイグレーションの「dry run」を可能にする
    • マイグレーションを実行して、呼び出された操作を出力する
    • 操作や変更に対して何らかのチェックを行う

Shopifyでは特定のユースケースがある。productionのマイグレーションでSQLをすぐに実行せず、代わりにマイグレーションをJSON DDLに変換して、複数のdbシャードすべてに渡って実行するサービスに送信するというもの。ストラテジーオブジェクトをカスタマイズしてMigrationクラスで利用できれば、JSONコマンドを生成する独自のオブジェクトを作成してAPI呼び出しで別サービスに送信できるようになる。

このプルリクでは以下を導入する。

  • すべてのストラテジーが継承すべきExecutionStrategyベースクラス
  • 現状のマイグレーションの振る舞いを維持するDefaultStrategy(メソッドをコネクションに委譲する)

その他の情報
コンフィグオプションも1つ追加してRailsガイドのコンフィグに追記したが、時期尚早かもしれない。現時点では、ストラテジーがマイグレーションとコネクションを両方とも保持する(これなら暫定的に動かしやすい)が、このあたりを変更するかもしれないし、ドキュメントでもストラテジーがどんなコードになるかをもっと明確にしたい気持ちもある。取りあえずRailsガイドにこれ以上追記はしないことにしてもよいし、残しておいてこれを元に本格的なドキュメントを書いてもよい。
同PRより


つっつきボイス:「マイグレーションに機能が追加されたのかな」「Strategyパターンをマイグレーションに導入して、デフォルトの挙動をDefaultStrategyにしつつカスタマイズ可能にしたようですね」

参考: Strategy パターン - Wikipedia

「よく見たらShopifyのプルリクだ」「ここは想像ですけど、Shopifyがやりたい特定のストラテジーでマイグレーションを拡張するプルリクをそのままRailsに投げてもすんなりとマージされないだろうから、Strategyパターンを導入して誰でもストラテジーで柔軟にカスタマイズ可能にしたのかもしれませんね」

「ドキュメントの利用例↓ではdrop_tableを利用禁止にしている: こんなふうにカスタムストラテジーを定義すれば、Rails本体に手を加えずに既存のDSLも変更できるし、やりたければ独自のDSLを追加してもいい」「あると嬉しい機能かも」「Shopifyが必要としている機能ですけど、他のプロジェクトでもマイグレーションでこういったカスタムストラテジーを実装したいケースはありそう👍」

# guides/source/configuring.md
class CustomMigrationStrategy < ActiveRecord::Migration::DefaultStrategy
  def drop_table(*)
    raise "Dropping tables is not supported!"
  end
end
config.active_record.migration_strategy = CustomMigrationStrategy

🔗 関連付け先の単数形の名前をwhere内から複数形で参照すると警告を出す

これはwhereですべてのキー(キーにはリフレクションやカラム名が入る可能性もある)にexpand_from_hashを実行して名前を一律で単数形に変換する処理を削除する。これによって、カラム名を単数形にするのにかかる時間を大幅に節約できる。
以前の#45163では単数形化処理全体が削除されたが、その後少し考え直した。whereは非常によく使われるAPIであり、誤ったリレーションによる問題はデバッグが難しいので、少なくとも1リリースをかけて警告を表示するのがよいと思う。

この挙動は、ActiveRecord::Base.allow_deprecated_singular_assocaitions_name = falseconfig.active_record.allow_deprecated_singular_assocaitions_name = falseで設定可能。

cc: @byroot 手のひらを返して申し訳ない。自分がこの変更に気が付かずに振る舞いが変わり始めたときのことを想像して、同じようなRailsユーザーのことが心配になったので。
同PRより


関連付け先の単数形の名前をwhere内から複数形で参照すると警告を出すようにする。config.active_record.allow_deprecated_singular_associations_name = falseとすることで、パフォーマンスのよい新しい書き方を選択できるようになる。
Adam Hess
同Changelogより


つっつきボイス:「リレーション名が単数形のpostの場合にwhere(posts: { id: 1 })で複数形のpostsとして参照するのが非推奨になったらしい」「修正細かいな〜: 最初このpostsがテーブル名に見えちゃいました」「自分も最初そう思った」

# guides/source/configuring.md
class Post
  self.table_name = "blog_posts"
end

class Comment
  belongs_to :post
end

Comment.join(:post).where(posts: { id: 1 }) # テーブル名がpostsでない場合は非推奨
Comment.join(:post).where(post: { id: 1 }) # 代わりにリレーション名を使うこと

「普段の自分は非推奨の方法で書いていた気もする」「自分はArel大好きなのでArelで書いちゃいますけど」「Arel嫌いです〜😭」「Arel楽しいですよ😋」

参考: Arel の使い方 [Rails] – Site-Builder.wiki

🔗 development環境用gemのインストールをスキップするオプションを追加


つっつきボイス:「以前#39282rails new--minimalオプションを付けられるようになっていましたけど↓、そのセットアップにskip_dev_gemsも追加されたようですね」「ミニマルならdevelopment環境用gemは不要というのはわかる」

Rails 6.1で`rails new`の生成を最小限にするフラグが追加(翻訳)

🔗 Action Cableサーバーをanchor: trueでマウントするようになった

Action Cableサーバーがanchor: trueでマウントされるようになった。
/cableで始まるルーティングがAction Cableと衝突しなくなる。
Alex Ghiculescu
同Changelogより


つっつきボイス:「#45489を見ると、Action Cableを使ったときに以下のような/cable-で始まるルーティングが今までAction Cableの予約語みたいになってて使えなかったのか」「anchor: trueで使えるようになるんですね」「Action Cableあまり使ってなくて気が付かなかった」

# #45489より
get "/cable-hyphenated-slug", to: "test#index"
get "/cable_underscored_slug", to: "test#index"

🔗 database.ymlでforeign_keys: falseを指定可能になった


つっつきボイス:「コンフィグレベルで外部キーを無効にできるようになったんですね」「スキーマ生成のときに外部キーを生成しないようにできるらしい」

# 同PRより
development:
  <<: *default
  database: db/development.sqlite3
  foreign_keys: false

「SQLite3向けの機能でしょうか?」「ぐぐってみると、SQLite3はデフォルトで外部キーを使えないけどPRAGMA foreign_keys=true;を実行すれば使えるようになるみたい↓: SQLite3以外でも外部キーをサポートしていない環境で使いたいことがあるのかもしれませんね」

参考: sqlite3で外部キーを有効にする | プロサバメモ

🔗Rails

🔗 Vite RubyでRails 7のアセットをバンドルする

ElMassimo/vite_ruby - GitHub


つっつきボイス:「Evil MartiansでAnyCableのメンテナーをやっているVladimir Dementyevさんの記事です」「Vite Rubyは、Vite.jsというバンドラーをRubyから使えるようにするものなんですね: Rails標準のjsbundling-railsなどの代わりにこれを使ってRails 7でライブリロードやHMR(Hot Module Reload)をできるようにした感じかな」「Webpackerを使わずにanycable_rails_demoをRails 7にアップグレードしたかったそうです」

参考: Home | Vite

anycable/anycable_rails_demo - GitHub

「Vite Rubyにはこんな感じのコードジェネレータがあるのね↓」

# 同記事より
Building with Vite ⚡️
vite v2.9.13 building for development...

transforming...

✓ 13 modules transformed.

Could not resolve './**/*_controller.js' from frontend/controllers/index.js
error during build:
Error: Could not resolve './**/*_controller.js' from frontend/controllers/index.js
    at error (/app/node_modules/rollup/dist/shared/rollup.js:198:30)
    at ModuleLoader.handleResolveId (/app/node_modules/rollup/dist/shared/rollup.js:22508:24)
    at /app/node_modules/rollup/dist/shared/rollup.js:22471:26

Build with Vite failed! ❌

「vite-plugin-full-reloadやstimulus-vite-helpersとかいろいろ使ってる」「こうやって新しいものを切り開いていくところがEvil Martiansらしい👍」「Rails標準でないものを組み合わせて動かすにはRailsのコンポーネントを深く理解する必要があるので、なかなか簡単にはやれないですよね」

ElMassimo/vite-plugin-full-reload - GitHub

ElMassimo/stimulus-vite-helpers - GitHub

🔗 motion: JSを書かずにインタラクティブなUIコンポーネントを作る(Ruby Weeklyより)

unabridged/motion - GitHub


つっつきボイス:「motionは、見たところRailsでインタラクティブなUIコンポーネント的なものを実現するライブラリかな: map_motionみたいなDSLを書くとAction Cable経由でERBのdata: { motion: "add" }のところに出したりできるようですね↓」

# 同リポジトリより
class MyComponent < ViewComponent::Base
  include Motion::Component

  attr_reader :total

  def initialize(total: 0)
    @total = 0
  end

  map_motion :add

  def add
    @total += 1
  end
end
<!-- 同リポジトリより -->
<div>
  <span><%= total %></span>
  <%= button_tag "Increment", data: { motion: "add" } %>
</div>

「ViewComponentの上でAction Cableと連携する感じで、ちょっとしたUIコンポーネントやWebSockets通信などをJavaScriptを書かずに作れるのがポイントかな: 制約もいくつかあるようだし、小さなアプリで使う分にはいいかも👍」

github/view_component - GitHub

参考: WebSocket API (WebSockets) - Web API | MDN

🔗 Rubyのfetchメソッド(Ruby Weeklyより)


つっつきボイス:「記事にも書かれているように、Rubyのfetchは第2引数を渡さないとキーがない場合にKeyError例外になる↓」

# 同記事より
h = { "a" => "AAA", "b" =>"BBB", "c" => nil }
h.fetch("c")    # 'nil'が返る
h.fetch("z")    # KeyErrorが発生

参考: Hash#fetch (Ruby 3.1 リファレンスマニュアル)

「第2引数でデフォルト値を渡すと、キーが存在しない場合はデフォルト値を返し、キーに対応する値がnilの場合はそのnilを返す↓: fetchではデフォルト値を渡すのがベストプラクティス的ではありますね」

# 同記事より
h = { "a" => "AAA", "b" =>"BBB", "c" => nil }
h.fetch("a", "Not here")    # 'AAA`が返る
h.fetch("c", "Not here")    # 'nil'が返る
h.fetch("z", "Not here")    # 'Not here'が返る(KeyErrorにならない)

||だとデフォルト値についてはfetchと同じ結果にならない↓: ハッシュに[]でアクセスする場合、キーが存在しなくてもnilが返されるだけでエラーにならないのはRubyの仕様ですね」

# 同記事より(編集部で変更)
h["a"] || default_value    # 'AAA'が返る
h["c"] || default_value    # 'Not here'が返る
h["z"] || default_value    # 'Not here'が返る

「そういえばPythonのdictだと[]で存在しないキーにアクセスしたときにエラーになりますね」「他の言語だとそういう仕様の方をよく見かける気はしますね: ハッシュの[]アクセスでキーが存在しない場合にnilを返すかエラーにするのかは、どちらがよいという話ではなく言語設計者がその仕様を選んだに過ぎないんですが、他の言語からRubyに来た人はそういうところで少し戸惑うかも」「いわゆるデザイナーズチョイスですね」「Rubyでエラーにしたければfetchを使うのが普通でしょうね」

参考: pythonのdictionaryでKeyErrorを出さないようにする - Qiita

「お、Rubyのfetchでキーが存在しないときだけ処理を変えたい場合は、こうやってブロックを渡すと、キーが存在しない場合だけブロックが評価されるのか↓」「fetchにブロックを渡せるって知らなかったかも」「記事は値がnilの場合とキーが存在しない場合で処理を分けたかったんですね」

# 同記事より
h.fetch("a"){ default_value }    # 'AAA'が返る(メッセージは出ない)
h.fetch("c"){ default_value }    # 'nil'が返る(メッセージは出ない)
h.fetch("z"){ default_value }    # 'Not here'メッセージが出力される

「同じことを&によるProc渡しでも書けるのね↓」「自分はここまでしないかな〜」「ただしMethodオブジェクトのアロケーションが重いからブロックの方がいいと追記されてますね」

# 同記事より
h.fetch("a", &method(:default_value))  # 'AAA'が返る(メッセージは出ない)
h.fetch("c", &method(:default_value))  # 'nil'が返る(メッセージは出ない)
h.fetch("z", &method(:default_value))  # 'Not here'メッセージが出力される

fetchは普段あんまり使ってなかったかも」「自分はよく使います」

🔗 Active Recordに隠れているStore機能


つっつきボイス:「Active Recordに組み込まれているStoreを使うとNoSQLっぽくアクセスできると書かれていますね」「ActiveRecord::StoreはJSONシリアライズしたものを1個のカラムに保存できたりしますね: かなり昔からあって存在も何となく知っていたけど、普段は使わないかな」「たしかにメインで使うものではないかも」

# 同記事より
class Item < ApplicationRecord
  store_accessor :user_attributes, :color
  store_accessor :user_attributes, :name, prefix: true
  store_accessor :user_attributes, :location, prefix: 'primary'
end
# 同記事より
=>item = Item.create!(color: 'red', user_attributes_name: 'Jonathan', primary_location: 'New Zealand')
>#<Item:0x000055d63f4f0360
 id: 4,
 user_attributes: {"color"=>"red", "name"=>"Jonathan", "location"=>"New Zealand"}>
=>item.color
>"red"
=> item.user_attributes_name
>"Jonathan"
=> item.name
>NoMethodError: undefined method `name'...
=> item.primary_location
>"New Zealand"

参考: Rails API ActiveRecord::Store

「こういうのを使いたくなる場合があるとすれば、スキーマを決めるほどでもないような情報を保存するときでしょうね: たとえばログインユーザーが使えるテーマのリストとか、ウィンドウの表示ペインを縦に並べるか横に並べるかみたいなコンフィグ情報をJSONで保存したいときとか」「なるほど」

「そういう情報をJSONシリアライズして1カラムに保存しておくとインポートやエクスポートがしやすくなるというのはありますね: たとえばJetBrains IDEのXMLコンフィグファイルをエクスポートして他の環境に持っていけたりしますけど、そういうのをやるには便利」「言われてみれば、UIで使えるフォントのリストみたいに環境によって変わりそうなものはスキーマを作りたくないですね」

ActiveRecord::Storeは、スキーマを決めるほど厳密でもないけどモデルでアクセサを使いたいような、ある意味中途半端なデータを扱うときに便利でしょうね」「なるほど」「もちろん重要なデータについてはスキーマを定義しますし、そういうものにActiveRecord::Storeを使うべきではありませんが、そうでないデータもたまにあるんですよ」「そうなってから利用を検討すればいいということですね」


前編は以上です。

バックナンバー(2022年度第2四半期)

週刊Railsウォッチ: attr_accessorが通常のメソッドより速い理由、ES2022の新機能ほか(20220628後編)

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

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

Rails公式ニュース

Ruby Weekly


CONTACT

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