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

週刊Railsウォッチ(20210119前編)PostgreSQLのCTEを使えるActiveRecordExtended gem、2021年初頭のRails展望記事ほか

こんにちは、hachi8833です。

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

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

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

今回は公式更新情報を中心に見繕いました。いくつかは先週のウォッチで先回りしてました(ウォッチ20210112)。


つっつきボイス:「Rails公式の更新情報も新年号っぽく出ていました🎍」

同じフォームを複数のHTTPメソッドで送信するformmethod:オプション

ブラウザは<form>を送信するときに、送信を開始した要素をFormDataの一部としてシリアライズし、そこにnamevalueといった属性を含めている。
ブラウザが<form>送信でサポートするHTTP verbはGETPOSTのみに限られている。現在のRailsでは、_method="VERB"をシリアライズしてFormDataに加える<input type="hidden" name="_method" value="VERB">をビルドすることでこの制約を回避している。
このコミットは同一のフォーム内で複数のHTTP verbをサポートするために、
フォームのビルド中にform.button formmethod: "..."が呼び出されると、_methodの値をformmethod:で指定した任意の値に書き換える。
同PRより大意

参考: FormData - Web API | MDN


つっつきボイス:「なるほど、buttonヘルパーでformmethod:を使ってHTTP verbを指定できるようになったんですね: 以下のDeleteボタンの場合、form_with側のmethod:で指定しているPUTをDELETEで上書きしてからフォームを送信するようになる」「これをできるようにした気持ちは何となく想像が付きますね」

<!-- changelogより -->
<%= form_with model: post, method: :put do %>
  <%= form.button "Update" %>
  <%= form.button "Delete", formmethod: :delete %>
<% end %>
<!-- changelogより: 上の生成結果 -->
<form action="posts/1">
  <input type="hidden" name="_method" value="put">
  <button type="submit">Update</button>
  <button type="submit" formmethod="post" name="_method" value="delete">Delete</button>
</form>

「どんなときに便利なんでしょうか?」「まさに上のコード例でやっているように、たとえば同じフォームの中にUpdateボタンとDeleteボタンの両方を楽に置きたいときなどですね: 同じことはformmethod:がなくてもできますが、その場合はDeleteボタンのリンクを別途作って、このPostモデルのidをリンクで指定してHTTPのDELETEメソッドを送信できるようにする必要があったんですよ」「そういえばそうだったかも」

formmethod:ができたおかげで、そういうことをしなくてもbuttonメソッドにformmethod: :deleteと書くだけで、モデルのidをリンクで指定せずに同じフォームにDeleteボタンも置けるわけです」「あ、なるほど!」「idを指定せずに書けるのは便利だと思います👍」「上のコード例のような代表的なCRUDのときに便利ですよね」

参考: CRUD - Wikipedia

Action Textのカスタマイズ機能を強化

拡張可能なレイアウト
(このプルリクは、)リッチテキストコンテンツを「囲む」HTMLのレンダリング方法を、layouts/action_text/contents/_content.html.erbのような拡張可能なテンプレートとして公開する。それによってユーザーランド側でのカスタマイズを促進しつつ、#render_action_text_contentヘルパー呼び出しをaction_text/contents/_content.html.erbパーシャルに移動することで、リッチテキスト自身のレンダリング方法を制御するprivate APIが失われないようにする。

拡張可能かつアタッチ可能なto_attachable_partial_path
あるレコードについてアプリケーションがカノニカルなパーシャルを宣言すると、リッチテキストに変換された場合にそのパーシャルの使われ方をオーバーライドする方法がなかった。

たとえば、デフォルトのPerson < ApplicationRecordインスタンスが#to_partial_pathへの呼び出しに対して"people/person"を返すと、app/views/people/_person.html.erbパーシャルが(問答無用で)レンダリングされる。

改修前は、<action-text-attachment sgid="...">要素があるとAction Textがそれに対応するAttachableインスタンス(普通はActiveRecord::Baseのインスタンス)を取得し、それが持つ#to_partial_pathに対応するパーシャルをレンダリングすることでリッチテキストHTMLに変換していた。

ここで提案する改修は、#to_partial_pathではなくAttachable#to_attachable_partial_pathを呼ぶというものだ。なおデフォルトでは#to_attachable_partial_path#to_partial_pathのエイリアスになる。

ガイド
これらのテンプレートのカスタマイズ方法と、ActionText::AttachableインスタンスがレンダリングされてHTMLになるまでのわかりやすい説明をRails GuidesのAction Text Overviewに追記した。
同PRより大意


つっつきボイス:「Action Textで表示する中身のリッチテキストのレンダリング方法をカスタマイズできるようにした、ということみたい: このあたり↓とかを見ると、attachment(添付ファイル)周りをカスタマイズしたいという要望があるのかも」「ふむふむ」「レンダリング方法をオーバーライドできるようにしたいというのはもっともですね」

# actiontext/app/helpers/action_text/content_helper.rb#L21
    def render_action_text_attachments(content)
      content.render_attachments do |attachment|
        unless attachment.in?(content.gallery_attachments)
          attachment.node.tap do |node|
-           node.inner_html = render(attachment, in_gallery: false).chomp
+           node.inner_html = render_action_text_attachment attachment, locals: { in_gallery: false }
          end
        end
      end.render_attachment_galleries do |attachment_gallery|
        render(layout: attachment_gallery, object: attachment_gallery) do
          attachment_gallery.attachments.map do |attachment|
-           attachment.node.inner_html = render(attachment, in_gallery: true).chomp
+           attachment.node.inner_html = render_action_text_attachment attachment, locals: { in_gallery: true }
            attachment.to_html
          end.join.html_safe
        end.chomp
      end
    end

「サードパーティのWYSIWYG機能をそのまま使うのでなく、Railsの機能でWYSIWYGを作り込んでしまうと、フロントエンド側のメンバーもWYSIWYGを扱おうとするときにconflictが起きてしまうので、慎重に考えたいところです: 特にWYSIWYGは生成したHTMLや中間言語に変換されたテキストがRDBMSの中にデータとして保存されるので、一度使い始めると乗り換えが難しい」「たしかに!」

Redisの情報を取る#infoを追加


つっつきボイス:「RedisCacheStore#infoができたということは、Redisにもinfoというメソッドがありそう(しばらく探す): コマンドのところにあった↓」

参考: INFO – Redis

「このINFOコマンドにもstatsがあるから、たぶんそれに相当する情報を取得するんでしょうね」「あるなら欲しい値ですね」

# activesupport/lib/active_support/cache/redis_cache_store.rb#L
      def stats
        redis.with { |c| c.info }
      end

Linkヘッダーを無効にするオプションを追加

stylesheet_link_tagjavascript_include_tagを使うとLinkヘッダーを自動生成するサポートがPR #39939で追加された。しかしすべてをプリロードすべきとも限らない(例: IEはこのヘッダをサポートしていないのでレガシーIEスタイルシートへのリンクのプリロードは不要だし、ブラウザによってはstylesheet_link_tagjavascript_include_tagがIEの条件付きコメントの中に入り、使わない場合でもプリロードがトリガされる)。これによって帯域幅のコストが増加してアプリケーションのパフォーマンスが落ちる。
Linkヘッダーの複雑なニーズを抱えているサイトで柔軟性を高めるために、本コミットでは同ヘッダーを完全に無効にするオプションを追加して、Linkヘッダーの生成方法をアプリケーション側で決定できるようにする。
メモ: 本コミットの意図は、6-1-stableにバックポートすることと、new_framework_defaults_6_1.rbにそれ用のエントリを追加して、アプリケーションを6.1にアップグレードするときにデフォルトのオプションをどうするかを決められるようにすること。多くのサイトはレガシーIEの影響を受けるので。
同PRより大意


つっつきボイス:「stylesheet / javascript helperで読み込むコンテンツを常にプリロードしたいとは限らない」「IE対応している場合、RailsがLinkヘッダーを常に直接生成するのはうれしくないときがあるので、生成するかどうかを制御するようにした: たしかにこれが欲しい場合ありますね」「if lt IE 7みたいな書き方をするレガシーIE対応、最近だんだん見かけなくなってきましたけどそういえばありましたね↓」「あったあった」

参考: 条件付きコメントを使ったIE対策コーディング - HTML・CSSテックラボ - [SMART]

<!-- rfs.jpの同記事より -->

<!-- IE6以下のみ表示 -->
<!--[if lt IE 7]><html class="ie6" lang="ja"><![endif]-->

<!-- IE7のみ表示 -->
<!--[if IE 7]><html class="ie7" lang="ja"><![endif]-->

<!-- IE7のみ表示 -->
<!--[if IE 8]><html class="ie8" lang="ja"><![endif]-->

<!-- IE8より上、もしくはIE以外のみ表示 -->
<!--[if (gt IE 8)|!(IE)]><!--><html lang="ja"><!--<![endif]-->

Action Viewの#excerptヘルパーのパフォーマンスを改善

ここからはコミットリストから見繕いました。


つっつきボイス:「#excerptってメソッドあったのね↓」「excerptは”抜粋”か」「お〜、以下のQiita記事を見るとWeb上の長いテキストの一部を取り出してそれ以外を...とかに置き換えてくれるメソッドなのか」「なるほど、Googleの検索結果とか、表紙の記事リストで記事冒頭だけ抜粋するときとかで使われるヤツですね」「これ自分で実装したことあったんですが、それ用のメソッドがあったとは…」

参考: Rails TextHelperまとめ - Qiita

# api.rubyonrails.orgより
excerpt('This is an example', 'an', radius: 5)
# => ...s is an exam...

excerpt('This is an example', 'is', radius: 5)
# => This is a...

excerpt('This is an example', 'is')
# => This is an example

excerpt('This next thing is an example', 'ex', radius: 2)
# => ...next...

excerpt('This is also an example', 'an', radius: 8, omission: '<chop> ')
# => <chop> is also an example

excerpt('This is a very beautiful morning', 'very', separator: ' ', radius: 1)
# => ...a very beautiful...

「Railsの#excerpt↓はsplitを使ってやってる: 当時の自分が実装した方法とは違うな」「今回改良したのは、このメソッドのコア部分のcut_excerpt_partのパフォーマンスだったんですね」

# File actionview/lib/action_view/helpers/text_helper.rb, line 175
def excerpt(text, phrase, options = {})
  return unless text && phrase

  separator = options.fetch(:separator, nil) || ""
  case phrase
  when Regexp
    regex = phrase
  else
    regex = /#{Regexp.escape(phrase)}/i
  end

  return unless matches = text.match(regex)
  phrase = matches[0]

  unless separator.empty?
    text.split(separator).each do |value|
      if value.match?(regex)
        phrase = value
        break
      end
    end
  end

  first_part, second_part = text.split(phrase, 2)

  prefix, first_part   = cut_excerpt_part(:first, first_part, separator, options)
  postfix, second_part = cut_excerpt_part(:second, second_part, separator, options)

  affix = [first_part, separator, phrase, separator, second_part].join.strip
  [prefix, affix, postfix].join
end

「自分のときはどう実装したかな(一同で当時のコードを見る): RubyのStringScannerscan_untilを使って実装してた」「お〜、これですか」「このときは他の処理もやってたので少し複雑でしたね」

excerptヘルパー、いつからあったんだろう?」「コミットリストを見ると13 years agoとあるので随分前からあったらしい」「Railsにこれだけたくさんメソッドがあると、知らないものもまだまだありそうですね」

番外: ルーティングテーブルのダークモード用CSSを修正


同PRより


つっつきボイス:「修正はシンプルですが、ダークモード対応がちょっと面白かったので拾いました」「1行おきに背景色を変えて縞々にしてる」「CSSのtr:nth-child(even)を追加するという方法でやってますね↓」

# actionpack/lib/action_dispatch/middleware/templates/routes/_table.html.erb#L53
  @media (prefers-color-scheme: dark) {
+   body {
+     background-color: #222;
+     color: #ECECEC;
+   }
+
    #route_table tbody tr:nth-child(odd) {
      background: #333;
    }

+   #route_table tbody tr:nth-child(even) {
+     background: #444;
+   }
+

参考: :nth-child() - CSS: カスケーディングスタイルシート | MDN

Rails

2021年初頭にRailsの現状を展望する(Hacklinesより)


つっつきボイス:「見出しからして、Railsは2021年にもありか?という感じの記事かな」「はい、新年っぽいRailsの現状展望記事を拾いました」

「こういう記事のグラフやデータは、何を母集団とした調査かによって変わってくるので、そこを踏まえたうえで読みたい」「そうですね」「2021年のRailsコミュニティに関するセクションとか面白そう」「Railsは画期的に新しいサービスの構築に向いているという感じの締めくくりですね」

「眺めた限りですが、記事の立場としては割とRails擁護的かな: こういう記事に書かれているようなことは、Railsを長年使っている開発者ならだいたい心得ているものですが、これからRailsを学ぼうかどうしようか迷っている人にはよさそうですね」

「実はここ1年ほど、以下の名記事↓のような『Railsは今後もありか?』とか「Railsを選ぶ理由」とか「Railsは死んだ」的ないい英語記事がなかなか見当たらなくて、ウォッチで取り上げられそうなものがめったになかったんですよ」「お、そうでしたか😆」「記事の件数は多いんですが、この1年ほどは主観で書いたような記事ばかり目について、こういういい記事はもう出てこないんだろうかと思い始めていたところでした」

Railsは2018年も現役か?: 前編(翻訳)

Railsは2019年も「あり」か?#1(翻訳)

「言われてみればこの記事はややRails寄りっぽくはあるものの、エビデンスや出典を示すなど比較的ちゃんと書かれたもののように思えますね」「私もそんな気がしています: この記事なら翻訳してもいいかなと思いました」

🌟 ActiveRecordExtended: RailsにPostgreSQL独自の機能を追加(Ruby Weeklyより)🌟

GeorgeKaraszi/ActiveRecordExtended - GitHub


つっつきボイス:「ActiveRecordExtendedはPostgreSQLを使うRails向けの拡張だそうです」「どれどれ」

「なるほど、ぽすぐれのArrayカラム用のANYやALLのような機能がRailsのメソッドとして使える↓: こういうのを自分で作ったことありました」「allは既存のメソッドと名前がかぶってますけどね」

# 同リポジトリより
alice = User.create!(tags: [1])
bob   = User.create!(tags: [1, 2])
randy = User.create!(tags: [3])

User.where.any(tags: 1) #=> [alice, bob]
# 同リポジトリより
alice = User.create!(tags: [1])
bob   = User.create!(tags: [1, 2])
randy = User.create!(tags: [3])

User.where.all(tags: 1) #=> [alice]

「IPアドレスを扱うメソッドなどもある↓」「全般にこのgemは、PostgreSQLに組み込まれている特定の型を活用するメソッドを提供するもののようですね: 使うかどうかは好みが分かれそうではありますが」「なるほど」

alice = User.create!(ip: "127.0.0.1/16")
bob   = User.create!(ip: "192.168.0.1/16")

User.where.inet_contains(ip: "127.0.0.254") #=> [alice]
User.where.inet_contains(ip: "192.168.20.44") #=> [bob]
User.where.inet_contains(ip: "192.255.1.1") #=> []

「やや、このgemはPostgreSQLのCTE(Common Table Expression)のメソッド↓をサポートしてるじゃない!!」「おぉ?」「これはちょっとアツいかも: CTEのためだけにこのgemを使ってもいいんじゃないかって思いました」「俄然色めき立ってきましたね」

# 同リポジトリより
alice = User.create!
bob   = User.create!
randy = User.create!
ProfileL.create!(user_id: alice.id, likes: 200)
ProfileL.create!(user_id: bob.id,   likes: 400)
ProfileL.create!(user_id: randy.id, likes: 600)

User.with(highly_liked: ProfileL.where("likes > 300"))
    .joins("JOIN highly_liked ON highly_liked.user_id = users.id") #=> [bob, randy]

「しかもwithjoinsをこうやってつなげられる↓」「おおぉ〜」「これでついにぽすぐれのWITH句が使える!」

User.with(highly_liked: ProfileL.where("likes > 300"), less_liked: ProfileL.where("likes <= 200"))
    .joins("JOIN highly_liked ON highly_liked.user_id = users.id")
    .joins("JOIN less_liked ON less_liked.user_id = users.id")

# OR

User.with(highly_liked: ProfileL.where("likes > 300"))
    .with(less_liked: ProfileL.where("likes <= 200"))
    .joins("JOIN highly_liked ON highly_liked.user_id = users.id")
    .joins("JOIN less_liked ON less_liked.user_id = users.id")

「こんなふうにあっさりPostgreSQLのCTEを使えるというのは相当なものですね👍」「今までActive RecordではWITH句をうまく書けないなってずっと思っていたんですが、こんなふうに書けるなら使ってみたい」「後はこれをどのぐらいハードに使っても大丈夫なのかが知りたいですね」「単純なCTEは十分動きそうな感じ」

「CTEサポートって、ありそうでなかったんですね」「冒頭のanyallみたいなのは簡単にメソッドにできるんですけど、CTEだと以下のようにWITH句が先行するので↓、単純に#fromの引数などを使って書き換えたりできないんですよ: でもこのgemでやっているということは可能なのか」

WITH "highly_liked" AS (SELECT "profile_ls".* FROM "profile_ls" WHERE (likes >= 300))
SELECT "users".*
FROM "users"
JOIN highly_liked ON highly_liked.user_id = users.id

「どうやって実現してるんだろう?」「リポジトリを掘ると、このあたり↓でWITHを増やしてますね」「merge_ctes!build_withとか、いろいろ頑張ってる感ある」「やるな〜」

#ActiveRecordExtended/lib/active_record_extended/active_record/relation_patch.rb#7
module ActiveRecordExtended
  module RelationPatch
    module QueryDelegation
      delegate :with, :define_window, :select_window, :foster_select, to: :all
      delegate(*::ActiveRecordExtended::QueryMethods::Unionize::UNIONIZE_METHODS, to: :all)
      delegate(*::ActiveRecordExtended::QueryMethods::Json::JSON_QUERY_METHODS, to: :all)
    end

    module Merger
      def normal_values
        super + [:union, :define_window]
      end

      def merge
        merge_ctes!
        super
      end

      def merge_ctes!
        return unless other.with_values?

        if other.recursive_value? && !relation.recursive_value?
          relation.with!(:chain).recursive(other.cte)
        else
          relation.with!(other.cte)
        end
      end
    end

    module ArelBuildPatch
      def build_arel(*aliases)
        super.tap do |arel|
          build_windows(arel) if window_values?
          build_unions(arel)  if union_values?
          build_with(arel)    if with_values?
        end
      end
    end
  end
end

「CTEの機能だけ切り出してくれる方がいいのかも?」「他の機能は単なる拡張ですし、別にあっても困らないので大丈夫ですよ」「あ、そうでしたか」「へ〜、window関数やUNIONもこのgemで使えるようですが、こういう機能が一緒に入っても構いませんし、あれば使うかもしれませんね」「なるほど」「ALLやANYのためにこういうgemを入れようとは思いませんが、このgemでないとできない機能であるCTEサポートメソッドを使えるなら入れたい」

「ActiveRecordExtended、誰もがやりたいと思っていたCTEのメソッド化を実現したのは個人的にポイント高いですね: どれ、GitHubの★ポチっちゃおう」「私もポチりました: 久々にウォッチで🌟が出せます」

というわけで🌟を進呈します。おめでとうございます!

追いかけボイス:「CTE(WITH句)自体はPostgreSQL独自のものではなく、最近のMySQLでもサポートしているんですが(SQL標準ではSQL99以降)、このGemはMySQLで使えるとは書いていないのでMySQL版のCTE Gemも探せばあるかもしれないですね」

参考: MySQL :: MySQL 8.0 Reference Manual :: 13.2.15 WITH (Common Table Expressions)

Sidekiqやgood_jobがRactor導入を構想中(Ruby on Rails Discussionsより)

I’m the author of GoodJob 13; I plan to support Ractors once Ractors are supported in Rails. I’ve also seen Mike Perham mention similarly 15 about Sidekiq.
同コメントより(by bensheldon)

mperham/sidekiq - GitHub

bensheldon/good_job - GitHub

good_jobは以前取り上げました(ウォッチ20200803)。


つっつきボイス:「Rails Discussionsでたまたま見かけたんですが、sidekiqの作者とgood_jobの作者がRactorを取り入れたいと発言してました: まだ構想を述べてる段階ですが」「この流れはわかりますね: この辺のgemを作ってる人はRactor使いたいと思いますよ」「Ractorやりたいでしょうね」

「ジョブのスレッドをRactor化することはできると思いますけど、Railsのジョブはいろんなものを読み込むので、Ractorによってパフォーマンスがどのぐらいよくなるかはやってみないとわからないかもしれませんね」「たしかに」「RactorはRubyのクラス変数に自由にアクセスできなかったと思うので、Ractorを使うにはgemの改造が必要になるのかもしれないとちょっと思いました」「ふ〜む」

参考: ruby/ractor.md at master · ruby/ruby

その他Rails


つっつきボイス:「先週StimulusReflexの記事↓を書いてくれたWebチームのebiさんが読みたそうかと思って貼ってみました」

StimulusReflex の Setup~Quick Start をやってみる


前編は以上です。

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

週刊Railsウォッチ(20210113後編)Ruby 3.0 Ractor解説記事、Vercelホスティングサービス、教育用OS xv6ほか

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

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

Rails公式ニュース

Ruby on Rails Discussions

Ruby Weekly

Hacklines

Hacklines


CONTACT

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