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

週刊Railsウォッチ: BasecampのHotwireページネーション、Query Object、Lograge gemほか(20220606前編)

こんにちは、hachi8833です。

参考: 犬・猫へのマイクロチップ埋め込み義務化 きょうから 無責任な飼育の抑止など見込む - ITmedia NEWS

つっつきボイス:「今度から有料でチップ装着と情報登録が義務付けられるんですよ: 私も飼ってて自分は前から実施してますけど」「施行ということは前から決まってたんですね」「チップそのものは15桁の番号のみで、電波から給電するのか」「チップは単なるIDタグで、情報は別途登録してそちらで紐付ける感じ」

週刊Railsウォッチについて

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

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

🔗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エンティティ&amp;に変換する
  • SafeBuffer#<<は、文字列がhtml_safe?ではないとみなして再度エスケープしており、&amp;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のビューでレンダリングするときは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

Rails: ビューのHTMLエスケープは#link_toなどのヘルパーメソッドで解除されることがある

🔗 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?は指定のインデックスが存在するかどうかをチェックするメソッドで、その意味を変えたくなかったのか」「IndexDefinitionvalidを追加することで、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:systemtest:modelsなど)を実行するとRakeでアプリが2回起動していたのが、すべてのtest:*がThorタスクとして定義されて直接test環境を読み込むようになった。
Étienne Barrié
同Changelogより


つっつきボイス:「アプリが2回も起動されていたんですか」「単に余分に起動されるならまだしも、2回実行されるべきでないものが実行されたりするのはよくないので修正すべきでしょうね」

「お、keys.mapfilter_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を整備したいですね」

Railsで重要なパターンpart 2: Query Object(翻訳)

🔗 Lograge: Railsのログを扱いやすく整形するgem

roidrage/lograge - GitHub


つっつきボイス:「この間取り上げた『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が出しているページネーションですか」

basecamp/geared_pagination - GitHub

「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ガイドでは以前からAlgolia↓の検索機能が使われていますが、ユーザーの要望に応えてYassLabの皆さんが除外機能を実装しました」

インタビュー: 超高速リアルタイム検索APIサービス「Algolia」の作者が語る高速化の秘訣(翻訳)


「ところで、日本語検索の除外指定はどういう単位で設定するかがポイントですね: Googleのように結果セットの件数が巨大な場合は除外が効果的ですが、たとえば1ページが長いコンテンツの場合は設定と検索クエリ次第では1件もヒットしなくなったりすることもある」「あ、たしかに」「検索の単位を細かくしすぎると除外しても大量にヒットしてしまったり、逆に大きくしすぎると何もヒットしなくなったりすることもあります」

「このあたりのチューニングはサービスを作る側にとってなかなか悩ましいんですが、ユーザーが使いこなすうちに運用でカバーできる面もありますね」


後でRailsガイドのProプランで検索機能を触った感じでは、同一ページの別パラグラフにある語を-で除外しても目的のパラグラフをスムーズに検索できました。おそらくパラグラフ単位で検索・除外されているのだろうと推測しました。

参考: Ruby on Rails ガイド:体系的に Rails を学ぼう


前編は以上です。

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

週刊Railsウォッチ: Railsコミュニティアンケート結果発表、書籍『Sustainable Web Development with Ruby on Rails』ほか(20220531)

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

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

Rails公式ニュース

Ruby Weekly

Hacklines

Hacklines


CONTACT

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