- Ruby / Rails関連
週刊Railsウォッチ: BasecampのHotwireページネーション、Query Object、Lograge gemほか(20220606前編)
こんにちは、hachi8833です。
参考: 犬・猫へのマイクロチップ埋め込み義務化 きょうから 無責任な飼育の抑止など見込む - ITmedia NEWS
つっつきボイス:「今度から有料でチップ装着と情報登録が義務付けられるんですよ: 私も飼ってて自分は前から実施してますけど」「施行ということは前から決まってたんですね」「チップそのものは15桁の番号のみで、電波から給電するのか」「チップは単なるIDタグで、情報は別途登録してそちらで紐付ける感じ」
🔗Rails: 先週の改修(Rails公式ニュースより)
🔗 strip_tags
から返される文字列がhtml_safe?
で正しくタグ付けされるよう修正
概要
修正: #45218
strip_tags
から返される文字列にはHTML要素が含まれておらず、基本エンティティはエスケープ済みなので、HTMLコンテンツ内のPCDATAとして安全にインクルードできる。html-safe
と正しくタグ付けされることで、レンダリング中にSafeBufferに結合されるときにエンティティが二重エスケープされることが回避される。
その他情報
追加したテストには説明的なコンテキストがいくつか含まれているが、これらについても説明しておく。#45218のバグは、この振る舞いを記述して間違いがないかどうかをチェックする。
buffer = ActiveSupport::SafeBuffer.new
buffer << helper.strip_tags("<div>hello & goodbye</div>")
buffer # => "hello & goodbye"
この例で注目して欲しいのは以下。
strip_tags
は&
記号をエスケープしてHTMLエンティティ&
に変換するSafeBuffer#<<
は、文字列がhtml_safe?
ではないとみなして再度エスケープしており、&amp;
という二重エスケープになっているこれは期待と異なる誤った動作のように見える。実際に
strip_tags
から返される文字列は、PCDATAとしてブラウザで安全にパースできるという意味での"HTML safe"になる。このことは以下のようなXSS攻撃試行で示せる。
frag = "<div><<span>script</span>>xss();<<span>/script</span>></div>"
strip_tags(frag) # => "<script>xss();</script>"
返される文字列を
html_safe?
とマーキングすることで、二重エスケープが回避され、Rails内で(SafeBufferのみならず)HTMLコンテンツのエスケープや操作を試みるいかなるコードにおいても文字列そのものが正しく"HTML safe"であることが表現される。
同PRより
つっつきボイス:「なるほど、strip_tags
が返す文字列が実は安全だったのにhtml_safe?
がfalseを返していたのが修正されたんですね」「それを知らずに二重エスケープしてしまうことがあったのか」
- Rails API:
html_safe
--ActiveSupport::SafeBuffer
- Rails API:
html_safe
--String
「RailsのビューでレンダリングするときはActiveSupport::SafeBuffer.new
が使われますが、修正で&.html_safe
が追加されているということは、今まではStringを返していたんですね」「なるほど」
# actionview/lib/action_view/helpers/sanitize_helper.rb#L104
def strip_tags(html)
- self.class.full_sanitizer.sanitize(html)
+ self.class.full_sanitizer.sanitize(html)&.html_safe
end
🔗 PostgreSQL用のindex_exists?
にvalid:
キーワード引数が追加
自分が取り消した#45151を再度オープン。
セカンドオピニオンをひとつ。個人的にはこの変更は正しくない気がする。
index_exists?
は、返す値がfalseなら、作成するインデックスが機能することを確信するためのものなので、別のニュアンスを足すならドキュメントを更新すべき。
(中略)
index_exists?
がfalseを返すのに、add_index
がインデックスが存在するためにエラーになるのは混乱する。
(新しいキーワード引数index_exists ..., valid: true
をオプションにするならありかも?)
indexes(table_name)
とindex_name_exists?
は同じキーワード引数を受け取るべきということ?
PostgreSQLの内部用語である"invalid"インデックスが他のアダプタに広がるのは嬉しくない。MySQLを使っている人が「invalidインデックスって何?」といぶかしがるかもしれない。
PostgreSQLのvalidate
と同じようなことならやれそう。提案されたように、IndexDefinition
ごとにvalid
フィールドを持たせて、index_exists?
とindex_name_exists?
が:valid
を既存の**options
パラメータ経由で受け取ってフィルタリングを行う。
cc @byroot @ghiculescu
同PRと#45151より
つっつきボイス:「PostgreSQL用のindex_name_exists?
は指定のインデックスが存在するかどうかをチェックするメソッドで、その意味を変えたくなかったのか」「IndexDefinition
にvalid
を追加することで、indexes(table_name)
とindex_name_exists?
の両方で:valid
キーワード引数を使えるようにしたんですね」
# 同Changelogより
connection.index_exists?(:users, :email, valid: true)
connection.indexes(:users).select(&:valid?)
# activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb#L8
class IndexDefinition # :nodoc:
- attr_reader :table, :name, :unique, :columns, :lengths, :orders, :opclasses, :where, :type, :using, :comment
+ attr_reader :table, :name, :unique, :columns, :lengths, :orders, :opclasses, :where, :type, :using, :comment, :valid
def initialize(
table, name,
unique = false,
columns = [],
lengths: {},
orders: {},
opclasses: {},
where: nil,
type: nil,
using: nil,
- comment: nil
+ comment: nil,
+ valid: true
)
@table = table
@name = name
@unique = unique
@columns = columns
@lengths = concise_options(lengths)
@orders = concise_options(orders)
@opclasses = concise_options(opclasses)
@where = where
@type = type
@using = using
@comment = comment
+ @valid = valid
+ end
+
+ def valid?
+ @valid
end
🔗 主キーのないモデルのeager_load
を修正
概要
主キーのないモデルのeager_load
が正しく機能しない
修正されるissue: #29374
例
以下では、第2のモデルの背後には主キーを持たないDBオブジェクト(データベースVIEWなど)があるとする。
class MyModel < ActiveRecord::Base
has_one :model_without_primary_key, primary_key: :some_other_column
end
class ModelWithoutPrimaryKey < ActiveRecord::Base
belongs_to :my_model, primary_key: :some_other_column
end
これでレコードをいくつか作成すると、
eager_load
の結果がおかしくなる。
MyModel.create # => #<MyModel id: 1>
ModelWithoutPrimaryKey.create(some_other_column: 100, my_model_id: 1)
my_instance = MyModel.eager_load(:model_without_primary_key).first
my_instance.model_without_primary_key # => nil
my_instance.reload
my_instance.model_without_primary_key #=> #<ModelWithoutPrimaryKey ... >
モデルの背後にデータベースVIEWがある場合、通常のActive Recordのユースケースでこの問題が起きる。以下のようなチェインも同様。
my_instance.includes(:model_without_primary_key).order('model_without_primary_key.name')
クレジット
クレジットの95%は@chopraanmol1にあり、このプルリクも数年前にstaleされた彼らの#30212に負っている。
同PRより
つっつきボイス:「主キーのないモデルでeager_load
した結果が正しくなかったのが修正されたんですね」
「ところで主キーのないモデルってRailsで使うことあります?」「無理やりやればできなくもないと思いますけど、普通は使わなさそう」「Railsの外にあるデータベースが設計上サロゲートキーを使っていない場合は割とあったりするので、そういうときなら使うかも」「Railsなのに主キーがないとやりにくそうですよね」 「基本的にはRailsでやるべきではないでしょうし、どうしてもやりたかったら他のフレームワークを使えばいいのではという気持ちはあります」
🔗 config.active_storage.service
が未設定の場合に例外を出すように修正
つっつきボイス:「config.active_storage.service
を設定し忘れていたらraiseするように修正されたそうです」「Active Storageで使うサービスを指定するコンフィグか」「AWSなどのストレージサービスを設定するヤツですね」「storage.ymlでも設定できますね」「使うときにエラーを出してくれるのはいい👍」
参考: §2 セットアップ -- Active Storage の概要 - Railsガイド
🔗 テスト系タスクでアプリが2回起動されるのを修正
- テストタスクがdevelopmentで起動してからtestで起動するのを回避
Railsのサブタスクのいずれか(
test:system
やtest:models
など)を実行するとRakeでアプリが2回起動していたのが、すべてのtest:*
がThorタスクとして定義されて直接test環境を読み込むようになった。
Étienne Barrié
同Changelogより
つっつきボイス:「アプリが2回も起動されていたんですか」「単に余分に起動されるならまだしも、2回実行されるべきでないものが実行されたりするのはよくないので修正すべきでしょうね」
「お、keys.map
がfilter_map
に変更されている↓」「filter_map
はちょうど先週のウォッチで話題に出てましたね(ウォッチ20220531)」「ついでのリファクタリングかも」
# railties/lib/rails/command/base.rb#L163
def namespaced_commands
- commands.keys.map do |key|
+ commands.filter_map do |key, command|
+ next if command.hidden?
if command_root_namespace.match?(/(\A|:)#{key}\z/)
command_root_namespace
else
"#{command_root_namespace}:#{key}"
end
end
end
🔗 番外: behaviour
->behavior
Rails::Generators::Testing::Behaviour
を非推奨化し、今後はRails::Generators::Testing::Behavior
にする
Gannon McGibbon
同Changelogより
つっつきボイス:「タイポ修正?」「あ、behaviour
は英国風のスペルです」「なるほど、米国風のbehavior
に寄せたのね」「Rails内部で使うコードのようだからbreaking changeにはならなさそう」「以前の名前に依存するgemがもしあれば影響を受けるかもしれませんけどね」
# activesupport/test/core_ext/string_ext_test.rb#L757
-class StringBehaviourTest < ActiveSupport::TestCase
_class StringBehaviorTest < ActiveSupport::TestCase
def test_acts_like_string
assert_predicate "Bambi", :acts_like_string?
end
end
🔗Rails
🔗 RailsでQuery Objectが合うケース(Ruby Weeklyより)
つっつきボイス:「thoughtbotの記事です」「Query Objectの記事は定番ですね」
# 同記事より
# undefined method `by_education_level' for nil:NilClass
ServiceOffering
.by_state(params[:state])
.by_education_level(params[:education_level])
「モデルでスコープが増えてくると、スコープを特定の順序で毎回チェインしないといけなくなったり、スコープを把握しきれなくなったりして限界が来るので、記事にもあるようにドメイン固有のQuery Objectを作るというのはよく行われます」
# 同記事より
# For simplicity's sake, we are not applying a namespace to this class
class MarketplaceItems
def self.call(filters)
scope = ServiceOffering.all
if filters[:state].present?
scope = scope.where(state: filters[:state])
end
if filters[:education_level].present?
scope = scope
.joins(:vendor)
.where(vendors: {education_level: filters[:education_level]})
end
scope
end
end
MarketplaceItems.call(state: "CA", education_level: "Kindergarten")
「上のQuery Object↑の実装は、self.call
を定義しているあたりがちょっとService Objectっぽいですね: こういう実装も見かけます」
🔗 Query Objectの設計方針
「Query ObjectがActive Recordオブジェクトを返すのか、PORO(Plain Old Ruby Object)的なオブジェクトを返すのかは設計の考えどころ: Active Recordオブジェクトを返す方が後々使いやすいんですが、元々スコープを自由にチェインさせないためのQuery Objectだったはずなんだから、スコープヘルに陥らないためには以下のようにPORO的なオブジェクトに展開して返したいところでもある」「受け取ったものをさらにスコープチェインでフィルタしようとして、ついActive Recordオブジェクトが欲しくなっちゃったりしますね」
# 同記事より
class MarketplaceItems
COLUMNS = [:title]
# An alternative is to have a second method to return
# raw data, in addition to a main method that returns
# an ActiveRecord::Relation
def self.call(filters)
ServiceOffering
.extending(Scopes)
.by_state(filters[:state])
.by_education_level(filters[:education_level])
.pluck(*COLUMNS)
.map { |row| COLUMNS.zip(row).to_h }
end
end
「個人的には、POROで返すようにして、さらにフィルタしたくなったら別のクラスかメソッドを増やすのが好み」「スコープチェインが限界に来たからQuery Objectを作ったはずのに、それをさらにチェインして思わぬ挙動になるというのはありがちですね」「スコープが付いているのを知らずにスコープを付けてしまうのもありがち」「Query Objectはスコープチェインさせないように実装するのが個人的にやっぱり好きですね」
「お、これはActive RecordのスコープをActive Recordではない形で実装する例のようですね↓」「Scopeableというモジュールを自分で実装してそれをScopesモジュールでextend
してる」
# 同記事より
module Scopeable
def scope(name, body)
define_method name do |*args, **kwargs|
relation = instance_exec(*args, **kwargs, &body)
relation || self
end
end
end
# 同記事より
module Scopes
extend Scopeable
scope :by_state, ->(state) { state && where(state: state) }
scope :by_education_level, ->(education_level) do
education_level && joins(:vendor)
.where(vendors: {education_level: education_level})
end
end
「Query Objectはアプリが大きくなってくると欲しくなるもので、自分は割と好き」「暗黙のチェインが増えてきたらQuery Objectを整備したいですね」
🔗 Lograge: Railsのログを扱いやすく整形するgem
つっつきボイス:「この間取り上げた『Sustainable Web Development with Ruby on Rails』(ウォッチ20220531)でこのLogrageが推奨されているのを見て知りました」「Logrageは昔から使われてますね」「使ってます〜」「なるほど、定番gemなんですね」
「Railsのデフォルトのロガーはフォーマットがあまりイケてなくて不便なんですよ」「そういえば同書でもそう言ってました」「CloudWatch LogsやDocker Composeのログを出すときなんかは以下のように1行1データの形にしたい↓: でないとto_json
したときにJSONが壊れてしまう」「そうそう、そうなんですよ」 「そういうのを考えるとLogrageをインストールするのが早い」
# 同リポジトリより
method=GET path=/jobs/833552.json format=json controller=JobsController action=show status=200 duration=58.33 view=40.43 db=15.26
「Logrageはカスタムオプションがよくできていて、以下↓のように処理をログにはさめるのがとてもありがたい↓」「お〜なるほど」「たとえばユーザーIDやテナントIDを出力するようにカスタマイズしておくと、どのユーザーや顧客で問題が発生したかを把握しやすくなる👍」「ユーザー数の多いproductionアプリでこれがなかったら大変だと思います」「★3000超えも納得ですね」
# 同リポジトリより
Rails.application.configure do
config.lograge.enabled = true
config.lograge.custom_payload do |controller|
{
host: controller.request.host,
user_id: controller.current_user.try(:id)
}
end
end
🔗 ページネーションされたリストの項目削除をHotwire化する(Hacklinesより)
つっつきボイス:「上の記事でgeared_paginationというgemを使っているそうです↓」「Basecampが出しているページネーションですか」
「geared_paginationはカーソルベースのページネーションと書かれているので、一瞬RDBのカーソルのことかと思ったら違うのね」「広い意味でのカーソル的な概念っぽいかも」「一般によく使われるオフセットベースのページネーションは1ページの項目数が決まっているブロックごとに分割されますが、カーソルベースの場合はブロック分割せずに特定のレコードに注目してそこを基点にページネーションする感じですね」
-- 同リポジトリより: カーソルベース
SELECT *
FROM messages
WHERE (created_at = '2019-01-24T12:35:26.381Z' AND id < 7354857)
OR created_at < '2019-01-24T12:35:26.381Z'
ORDER BY created_at DESC, id DESC
LIMIT 30
参考: 「カーソル」を理解する:「データベーススペシャリスト試験」戦略的学習のススメ(20)(1/2 ページ) - @IT
「READMEにもあるように、カーソルベースだと項目数が可変のページネーションができる」「なるほど、こういうふうにリストのDeleteボタンを押すとその項目だけ消えるみたいなことができるんですね↓」
「たしかにこういう処理はオフセットベースだと消したときにずれるので、カーソルベースの方が向いている」「それをHotwireでやれるんですね」「Basecampが作って使っているなら公式に近いgemと思ってもよさそう👍」
🔗 Hotwireだけで電卓を作る(Ruby Weeklyより)
つっつきボイス:「JavaScriptを使わずにRailsとHotwireだけで作るという短い動画です」「たしかにHotwireを使いこなせばあらゆる演算をサーバーサイドに持って行けますね: こういう方向性は個人的にありだと思う👍」
🔗 その他Rails
#Railsガイド の検索機能に『除外検索』が追加されました! 🔍🆕
Railsガイドのボリュームも日に日に増してきていて、Rails 7.0対応版の時点では1,684ページ(PDF換算)あります。本機能がRailsを使ったプロダクト開発のお役に立てば嬉しいです...!! 🙏💖
📜 詳細記事を見る→https://t.co/BJGeRxj4S3 pic.twitter.com/XVDcF6HjHb
— Railsガイド 📕 (@RailsGuidesJP) June 2, 2022
つっつきボイス:「Railsガイドの検索機能に除外指定が付いたんですね」「Railsガイドでは以前からAlgolia↓の検索機能が使われていますが、ユーザーの要望に応えてYassLabの皆さんが除外機能を実装しました」
「ところで、日本語検索の除外指定はどういう単位で設定するかがポイントですね: Googleのように結果セットの件数が巨大な場合は除外が効果的ですが、たとえば1ページが長いコンテンツの場合は設定と検索クエリ次第では1件もヒットしなくなったりすることもある」「あ、たしかに」「検索の単位を細かくしすぎると除外しても大量にヒットしてしまったり、逆に大きくしすぎると何もヒットしなくなったりすることもあります」
「このあたりのチューニングはサービスを作る側にとってなかなか悩ましいんですが、ユーザーが使いこなすうちに運用でカバーできる面もありますね」
後でRailsガイドのProプランで検索機能を触った感じでは、同一ページの別パラグラフにある語を-
で除外しても目的のパラグラフをスムーズに検索できました。おそらくパラグラフ単位で検索・除外されているのだろうと推測しました。
参考: Ruby on Rails ガイド:体系的に Rails を学ぼう
前編は以上です。
バックナンバー(2022年度第2四半期)
週刊Railsウォッチ: Railsコミュニティアンケート結果発表、書籍『Sustainable Web Development with Ruby on Rails』ほか(20220531)
- 20220524後編 Railsコアチームとコミッターに新メンバー、ruby-buildでのRust YJITサポートほか
- 20220523前編 Hotwireの用途解説記事、RubyKaigi 2022プロポーザル募集開始ほか
- 20220517後編 rubygemsに「scoped gems」の提案、RSpecのブロック構文ほか
- 20220516前編 Active Modelで属性のパターンマッチをサポート、猫でもわかるHotwire入門ほか
- 20220511後編 Ruby 3.2.0devにRust版YJITがマージ、Docker Compose V2ほか
- 20220510前編 Active RecordにPromiseと非同期集計メソッドがマージ、climate_control gemほか
- 20220419後編 RubyのGCコンパクション改修、jemalloc、ReDoSの自動検出修正ほか
- 20220418前編 RailsConf 2022が5月17〜19日開催、認可機能解説記事ほか
- 20220412後編 HashieでRubyのハッシュを強化、最近のRubyコア解説記事ほ
- 20220411前編 Turbo Railsチュートリアル、Active Recordの「Leaky Abstraction」を削減ほか
- 20220406後編 RBS関連記事、Ruby formatterプロジェクト、Google Cloud Runほか
- 20220404前編 Ruby 3.2.0 Preview 1リリース、Rails向けDocker環境ジェネレータ、scientist gemほか
今週の主なニュースソース
ソースの表記されていない項目は独自ルート(TwitterやはてブやRSSやruby-jp SlackやRedditなど)です。
週刊Railsウォッチについて
TechRachoではRubyやRailsなどの最新情報記事を平日に公開しています。TechRacho記事をいち早くお読みになりたい方はTwitterにて@techrachoのフォローをお願いします。また、タグやカテゴリごとにRSSフィードを購読することもできます(例:週刊Railsウォッチタグ)