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

週刊Railsウォッチ: orderでコレーション指定をサポート、awesome_nested_set、GitHub Copilotほか(20220221前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

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

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

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

🔗 Active Recordのdestroy_association_async_jobコンフィグが効くように修正

アプリケーションでdependent: :destroy_asyncオプションを指定したhas_many関連付けでは、バックグラウンドで関連レコードをdestroyするジョブをconfig.active_record.destroy_association_async_jobコンフィグで指定できるはずだが、無視される。つまり、常にデフォルトのActiveRecord::DestroyAssociationAsyncJobがバックグラウンドでレコードをdestroyする。

このPRはconfig.active_record.destroy_association_async_jobの設定を無視しなくなる。
同PRより


つっつきボイス:「非同期でレコードをdestroyするジョブをカスタマイズするコンフィグが常にデフォルトで上書きされていたのか」「これはバグ」「=||=にすることで修正されてますね↓」

# activejob/lib/active_job/railtie.rb#L45
-     ActiveSupport.on_load(:active_record) do
        self.destroy_association_async_job = ActiveRecord::DestroyAssociationAsyncJob
+       self.destroy_association_async_job ||= ActiveRecord::DestroyAssociationAsyncJob
      end

参考: §3.7.34 config-active-record-destroy-association-async-job -- Rails アプリケーションを設定する - Railsガイド

🔗 orderCOLLATEを安全なSQL文字列として使えるようになった

#36448でORDER BYに関数を渡せるようになった。COLLATEも利用できるようにすべき。

Post.order('title COLLATE "C"')

その他
PostgreSQLではコレーション名を引用符で囲む必要がありそうだが、MySQLやSQLiteはそうではない。
同PRより


つっつきボイス:「お〜、ついにorderでコレーションが書けるようになった🎉」「今までできなかったのは何ででしょう?」「単にこれまでActive Recordのインターフェイスではこの書き方が許可されていなかったんですよ」「あ、そういうことですか」

「たしか以前はselectするカラムにコレーションを書いて、それをorderしたような覚えがありますが、orderの中でコレーションを直接書けるようになったのはありがたい👍」「なるほど」「今までも回避方法はありましたけど、そもそもコレーションの構文がRDBMSによって違うんですよ」

「言われてみればコレーション(照合順序)は標準のSQLじゃなくてRDBMSの拡張だから違っててもおかしくないか」「テストコードでもRDBMSによってコレーションに書けるものも違ってくるのがわかりますね↓」

# activerecord/test/cases/unsafe_raw_sql_test.rb#169
  test "order: allows valid arguments with COLLATE" do
    collation_name = {
      "PostgreSQL" => "C",
      "Mysql2" => "utf8mb4_bin",
      "SQLite" => "binary"
    }[ActiveRecord::Base.connection.adapter_name]

参考: 【MySQL】照合順序とは? - Qiita

🔗 mysql2アダプタでActiveSupport::Durationを適切に扱うよう修正

#44404 の作業中にテストカバレッジを拡大しようとしたところ、MySQLアダプタでActiveSupport::Durationオブジェクトもquoteで処理する必要があることに気づいた。これは#42440#16069の「一般的なRailsのプラクティスとMySQLの組み合わせでクエリを外部から操作される可能性の緩和」の続き。

このケースでは、ActiveSupport::Durationオブジェクトで.to_sを呼ぶとセクション数を整数で返す。これがMySQLアダプタでquoteされないままだと、抽象のConnectionAdaptersに渡され、そこで文字列ではなく数値として処理されてしまう。

これは技術的にはセキュリティ修正という形になるが、(攻撃の)ターゲットベクタ(媒介者)とするのは難しそうではある。自分はその点について概念実証(PoC: Proof of Concept)を行っていない。
同PRより


つっつきボイス:「mysql2アダプタでActiveSupport::Durationが文字列にキャストされていなかったのを修正したようですね」「他のアダプタのテストも足したみたい」「コメントによるとキャストの修正前はPostgreSQLだとエラーになったけどMySQLだと通っちゃったのか」

# activerecord/lib/active_record/connection_adapters/mysql/quoting.rb#L8
      module Quoting # :nodoc:
        def quote_bound_value(value)
          case value
-         when Numeric
+         when Numeric, ActiveSupport::Duration
            quote(value.to_s)
          when BigDecimal
            quote(value.to_s("F"))
          when true
            "'1'"
          when false
            "'0'"
          else
            quote(value)
          end
        end
# activerecord/test/cases/adapters/mysql2/bind_parameter_test.rb#75
        def test_where_with_duration_for_string_column_using_bind_parameters
          count = Post.where("title = ?", 0.seconds).count
          assert_equal 0, count
        end

後で読み返すと、rafaelfrancaが「びっくりさせないで〜😂」「このissueを攻撃に転用するのは難しそう、それ以外の問題はありそうだけど」みたいな感じでコメントしていました。

🔗 DBのrakeタスクでVERSION envの数値に_が使えるようになった


つっつきボイス:「これはコミットのみでした」「環境変数のVERSIONの数値にアンスコを使えるようになった」「これはマイグレーションのバージョンっぽいですね」「バージョン指定でこんな書き方ができるのか」「Rubyは数値のアンスコでこうやって桁区切りできますよね」「マイグレーションの日時みたいな数値をフラットに書くと読みづらいので、アンスコを使えるようにしたい気持ちはワカル」

# activerecord/test/cases/tasks/database_tasks_test.rb#1430
      ENV["VERSION"] = "2000_01_01_000042"
      assert_equal 20000101000042, ActiveRecord::Tasks::DatabaseTasks.target_version

🔗 ルーティングのtrailing_slash: trueオプションを修正


つっつきボイス:「こちらはルーティングの修正だそうです」「trailing_slash: trueって、ルーティングでURLの末尾にスラッシュを付けるという意味なんですか」「ヘルパーでURLを取得したときにスラッシュを付けられるようですね: これがやりたくなるときもたまにありそう👍」

# 同Changelogより
get '/test' => "test#index", as: :test, trailing_slash: true

test_path() # => "/test/"

🔗Rails

🔗 RailsのRubyはRails方言(Ruby Weeklyより)


つっつきボイス:「RailsのActive Supportのことかな」「記事にもあるように、Active SupportはRubyにたくさんパッチを当てていますね↓」「こうして見ると多い」

# 同記事より
{Array=>{:original_methods=>196, :as_methods=>251, :added_by_as=>55},
 Class=>{:original_methods=>117, :as_methods=>172, :added_by_as=>55},
 Date=>{:original_methods=>132, :as_methods=>240, :added_by_as=>108},
 DateTime=>{:original_methods=>142, :as_methods=>277, :added_by_as=>135},
 File=>{:original_methods=>236, :as_methods=>279, :added_by_as=>43},
 Hash=>{:original_methods=>182, :as_methods=>246, :added_by_as=>64},
 Integer=>{:original_methods=>148, :as_methods=>206, :added_by_as=>58},
 Module=>{:original_methods=>114, :as_methods=>165, :added_by_as=>51},
 Object=>{:original_methods=>63, :as_methods=>83, :added_by_as=>20},
 Range=>{:original_methods=>133, :as_methods=>171, :added_by_as=>38},
 String=>{:original_methods=>188, :as_methods=>256, :added_by_as=>68},
 Symbol=>{:original_methods=>92, :as_methods=>114, :added_by_as=>22},
 Time=>{:original_methods=>121, :as_methods=>257, :added_by_as=>136}}

「記事の下の方では、Active Support出身のメソッドがRuby本体にもたくさん取り込まれたとありますね」「そうそう」「自分の中では出世メソッドと呼んでます😆」

「Railsを動かしているRubyはあくまでRails方言、たしかに」「素のRubyを書いていると、たまにActive Supportにしかない機能を使いそうになっちゃうことある」「そうそう、present?と書いた直後にないことに気づいたり」「blank?はRubyにありますけどね」「どちらにもあるけど挙動が違うメソッドもありますね: Rubyだと引数が使えないけどRailsだと使えるように拡張されていたり」

Rails: present?より便利なActiveSupportのpresenceメソッド(翻訳)

「Active Supportはちと大きいので、HanamiのようにActive Supportがない環境なら記事にもあるようにdry-rbを使う手もありますね↓: dry-rbは必要最小限かつよくできてる👍」

参考: dry-rb - Home

🔗 awesome_nested_set: 入れ子集合を扱うgem(Ruby Weeklyより)

collectiveidea/awesome_nested_set - GitHub


つっつきボイス:「awesome_nested_setは、acts_as_nested_setやBetterNestedSetを置き換えられるそうです」「acts_as_で始まるのは古いライブラリに多いですね」

lftとかrgtというこの書き方は見覚えある↓: ネストした集合を扱える感じ」「lftrgtは左と右なんですね」

# 同リポジトリより
class CreateCategories < ActiveRecord::Migration
  def change
    create_table :categories do |t|
      t.string :name
      t.integer :parent_id, null: true, index: true
      t.integer :lft, null: false, index: true
      t.integer :rgt, null: false, index: true

      # optional fields
      t.integer :depth, null: false, default: 0
      t.integer :children_count, null: false, default: 0
      t.timestamps
    end
  end
end

なお、この記事ではacts_as_nested_setが使われています↓。

Railsで木構造を扱うには

🔗 SQLでネステッドセットを扱うパターン

「ネストしたデータセットをデータベースでやろうとすると大変そうですよね」「実はそのような場合のベストプラクティスはある程度確立しているんですよ: こういう構造を適切に作ると、たとえばネストの上の層のデータを短いSQLクエリで書けて、かつインデックスが効くようにできたりします」「お〜」

「たとえばこのancestryというgem↓の場合は、内部でスラッシュ区切りの階層データ構造を持つ形で多階層カテゴリを扱っている」

stefankroes/ancestry - GitHub

参考: 多階層カテゴリでancestryを使ったら便利すぎた - Qiita

「awesome_nested_setのようにlftrgtを指定する方法は、たしかSQLのパターンにあったはず: そうそう、このQiita記事↓にあるNested set model(入れ子集合モデル)やNested intervals(入れ子区間)なんかがそうですね」「こういうパターンがあるのか〜」

参考: 階層構造(入れ子集合モデル)について - Qiita
参考: Nested set model - Wikipedia
参考: Nested intervals - Wikipedia

lftrgtというとツリー構造みたいなものでしょうか?」「Nested set modelはツリーとは違って、たしかid空間を効率よく配置する戦略ですね」

「ちょうどこの記事の図↓にあるように、上のベン図に合うように下の区間でidが設定される: このようにデータを配置することでidの区間の指定にインデックスが効くようになります」「お〜なるほど!」「SQLのテクニック集的な書籍には必ず載っていると言ってもいいと思います: naive treeと一緒に紹介されることが多いですね」


gihyo.jp/dev/serial/01/sql_academy2/000501より

参考: 第5回 SQLで木構造を扱う~入れ子集合モデル (1)入れ子集合モデルとは何か :SQLアタマアカデミー|gihyo.jp … 技術評論社
参考: SQLアンチパターン ナイーブツリー - Qiita

🔗 Railsアプリの脆弱性警告対策


つっつきボイス:「Railsにインストールしたgemに含まれているGemfile.lockで警告が出たのか」「gemの問題なのですぐ対応しにくいヤツですね」「記事では可能なものについてはgem作者に対応してもらいつつ、すぐ対応できないものはDockerからgemのGemfile.lockを削除した、なるほど」

🔗 JetBrains IDEのGitHub Copilotプラグイン


つっつきボイス:「JetBrains IDEのGitHub Copilotプラグインは今使っていてなかなか便利👍」「私はVSCodeでGitHub Copilotを使ってます: VSCodeのIntelliSenseとケンカするっぽいので後者はとりあえず外してますが」

「GitHub Copilotってそんなにいいんですか?」「いいですよ〜❤️、ぜひTechnical Previewに申し込んで使ってみてください↓」

参考: GitHub Copilot · Your AI pair programmer

「GitHub Copilotの補完が邪魔になることもないではないけど、かなり賢い(JetBrains IDEではEscでキャンセルできます)」「空のファイルだとRubyにJSのコードを突っ込んできたりすることもありましたけど、コードを書く前に自然言語で構わないのでコメントに概要を書いたりして、なるべくヒントを与えてあげるとどんどんよくなりますね」「そうそう、下手するとコメントまで補完してくれる」

「単に一部を補完させるより、コメント以外のコードをいったん全部消してGitHub Copilotになるべく書かせるぐらいの気持ちで使うといいみたいです」「使ってみたい〜、早速申し込んでみました」「この間GitHub Copilotの使いこなしを見せてくれた人は、GitHub Copilotの振る舞いを完全に掌握したら生産性が3倍は向上したと話してました(個人の感想です)」

「GitHub Copiloはtコード以外にも、このツイートみたいにロケール情報なんかもビシバシ補完してくれますね↑」「GitHub Copilotは自分のプロジェクト内のコードを優先的に扱うので、補完されるメソッドチェーンなどもちゃんとプロジェクトに沿ってくれる」「いいな〜」「もちろん補完されたコードは調べますけど、メソッド名を探さなくて済むのが楽」「変数名やメソッド名をちゃんと付けるモチベも高まりそうですね」「楽しみに待ってます」

🔗 その他Rails

「小技記事です」「fixtureのデータをdb:seedでも使えるようにする、誰もが一度はやりたくなるヤツ」「同じデータを2つも作りたくないですよね」「もちろんdb:seedで入れるデータが開発・テスト用限定で、productionには入れないものでないといけないと思いますが」「たしかにseedの扱いってプロジェクトで違うことがありますよね」


「お、Hanami v2.0.0のアルファ版」「Ruby 3.0以上が必須ですって」「Hanamiを使っている人ならきっと大丈夫」



「正確には配列の配列をハッシュの配列に置き換えたんですが、アロケーションはたしかに減っていました」「<<で配列に追加してループで回したりするとハッシュよりアロケーションが増えそうな気もしますね」「今回はJeremy Evansさんの教え↓にしたがってハッシュに変えたんですが、たしかに前はそれをやってました😅」「何でも配列にしてよかったのはRuby 1.8までですね」「そういえば元々Ruby 2.0のときに書いたコードでした」

『Polished Ruby Programming』(Jeremy Evans著)を読みました


前編は以上です。

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

週刊Railsウォッチ: Bundler自身のバージョンロック機能、gem署名メカニズムの提案ほか(20220216後編)

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

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

Rails公式ニュース

Ruby Weekly


CONTACT

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