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

週刊Railsウォッチ: MariaDBのRETURNINGオプションをサポートほか(20231114前編)

こんにちは、hachi8833です。つっつき会を行った11/09はRubyWorld Conference 2023の初日でした。盛況&無事終了おめでとうございます🎉

週刊Railsウォッチについて

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

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

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

🔗 MariaDBでRETURNINGをサポート

このプルリクは、フィードバックに対処した#49315をrebaseしたもの。
この変更は、MySQLやMariaDBのuuidデータ型をサポートする自分の取り組み(#49752コメント)で必要となる。

INSERTの後で動的なデータベースデフォルト設定が反映されることをテストするテストケースを追加した。insert_allなどのメソッドでも:returningをサポートしているが、これらは既にテストされている。

rails/activerecord/test/cases/insert_all_test.rb

# Line 90 in [820fbd9](https://github.com/rails/rails/commit/820fbd9102a5488f3bfcc91ec35324b7076cc83e)
skip unless supports_insert_returning?

クローズされるissue: #49315

cc: @nvasilevski@adrianna-chang-shopify
同PRより


つっつきボイス:「INSERT文のRETURNINGはPostgreSQLでは昔からサポートされていますね」「最近だとSQLite3のRETURNINGもRailsでサポートされましたね(#49290)」

「基本的な質問ですけど、RETURNINGは何ができるんでしょうか?」「SELECT文が結果を返すのと似た感じで、INSERT INTO文で返したい戻り値を指定できるオプションですね: INSERT INTO文のRETURNINGオプションがとても便利なのは、idのような自動生成される結果も取れること」「あ、自動生成される値も返せるんですか」「仮にActive Recordでたとえると、もしこの機能がなければ、レコードセットをcreateしてから、生成されたidも含む結果を取りたい時にもう一度selectしなければならなくなりますけど、RETURNINGがサポートされていれば、確定したid値を含む結果を取得するところまでできるので、それを受け取って何かしたりできるのが、めちゃくちゃ便利、しかもトランザクションを別途張らなくていい」「なるほど、RailsがMariaDBでもその機能をサポートしたということなんですね」

参考: INSERT -- PostgreSQL 15 ドキュメント

RETURNING句を指定すると、INSERTは実際に挿入された(あるいはON CONFLICT DO UPDATE句によって更新された)各行に基づいて計算された値を返すようになります。 これは、通番のシーケンス番号など、デフォルトで与えられた値を取り出す時に主に便利です。 しかし、そのテーブルの列を使用した任意の式を指定することができます。 RETURNINGリストの構文はSELECTの出力リストと同一です。 挿入または更新に成功した行だけが返されます。 例えば、行がロックされていて、ON CONFLICT DO UPDATE ... WHERE句の conditionが満たされなかったために更新されなかった行は返されません。
INSERT -- PostgreSQL 15 ドキュメントより

「そうそう、この記事にもありますけど、MySQLだとLAST_INSERT_ID()というそれ用の関数でやったりしていましたね↓」

参考: MySQLではinsertで生成されるIDが帰ってこない問題へのアプローチ #Ruby - Qiita


後で探すと、MariaDBのINSERTにはRETURNINGオプションの説明がありましたが、MySQL 8.2にあるのはJSON型で型を指定するRETURNINGオプションだけのようです。

参考: INSERT...RETURNING - MariaDB Knowledge Base
参考: MySQL :: MySQL 8.2 Reference Manual :: 12.17.3 Functions That Search JSON Values

🔗 attr_internal_defineメソッドを最適化

@プレフィックスは常に削除されるので、必要ではない場合もある。

後方互換性の理由から、当面この@プレフィックスを処理するが、積極的に削除して非推奨警告を表示するようにする。
同PRより


つっつきボイス:「公式更新情報にはattr_internal_naming_format=(format)の内部が最適化されたとありますね」「どこかで見かけたような気がするメソッド名」「内部用のメソッドだし、インスタンス変数の使われ方もわからないけど、attr_*でアクセサを作るようにしたことで@を書かなくても済むように最適化したということなのかな🤔」

# activesupport/lib/active_support/core_ext/module/attr_internal.rb#L40
+ private
    def attr_internal_define(attr_name, type)
-     internal_name = attr_internal_ivar_name(attr_name).delete_prefix("@")
+     internal_name = Module.attr_internal_naming_format % attr_name
      # use native attr_* methods as they are faster on some Ruby implementations
      public_send("attr_#{type}", internal_name)
      attr_name, internal_name = "#{attr_name}=", "#{internal_name}=" if type == :writer
      alias_method attr_name, internal_name
      remove_method internal_name
    end

🔗 Active Storageのhas_one_attachedhas_many_attachedserviceオプションをprocとして渡せるようになった

動機/背景

このプルリクを作成した理由は、has_one_attachedメソッドとhas_many_attachedメソッドのserviceキーワード引数が値としてシンボルしか受け取れないため。 このプルリクによって、クラスレベルのコンテキストでserviceを定義できるようになる。

しかし自分たちの要件の1つとして、ユーザーの地域に基づいてファイルをアップロードしたいというのがある(地域によっていくつかの規制があるため)。そういうわけで、このプルリクでは、serviceをインスタンスレベルのコンテキストで定義可能にするため、serviceをProcとしても受け取れるように変更する。

詳細

このプルリクは、has_one_attachedメソッドとhas_many_attachedメソッドのロジックを以下のように変更する。

  • serviceキーワード引数にシンボルとprocのどちらでも渡せるようにする
  • シンボルの場合、サービスの設定は起動時にバリデーションされるが、procの場合は実行時にバリデーションされる
    同PRより

つっつきボイス:「has_one_attachedhas_many_attachedメソッドのserviceオプションにprocも渡せるようにした↓」「Active Storageで添付ファイルの保存先を今まではservice: :s3みたいにシンボルしか指定できなかったのね」「プルリクを上げた人たちは地域によってアップロード先のストレージを使い分けたいらしい」「そういうニーズもあるんですね」

# activestorage/test/models/attached/many_test.rb#L775
  test "attaching a new blob from an uploaded file with a service defined at runtime" do
    extra_attached = Class.new(User) do
      def self.name; superclass.name; end

      has_many_attached :signatures, service: ->(user) { "disk_#{user.mirror_region}" }

      def mirror_region
        :mirror_2
      end
    end

    @user = @user.becomes(extra_attached)

    @user.signatures.attach fixture_file_upload("cropped.pdf")
    assert_equal :disk_mirror_2, @user.signatures.first.service.name
  end

🔗 trilogy_adapterとmysql2_adapterのquote_stringメソッドをabstract_mysql_adapter.rbに移動

このメソッド(quote_string)の実装は、trilogyアダプタとmysql2アダプタで完全に同じなので、抽象mysqlアダプタで明示的に共有することでメリットを得られる。

これはコスメティックな変更に近いが、行うべき重要な変更だと思う。多くのアプリケーションがmysql2からtrilogyに移行することが予想されており、問題が発生したらtrilogyアダプタとmysql2アダプタで違いを調査することになる可能性がある。完全に同じメソッドがtrilogyアダプタとmysql2アダプタにあってもデバッグで役に立たないし、共通クラスに置くことで振る舞いが同じことが明確になる。

PostgreSQL

公平のために述べておくと、postgresqlアダプタにも完全に同じ実装がある。しかし、潜在的に使われる可能性のあるアダプタに適した実装ではないと思うので、これも抽象アダプタに移動してよいものか自信がない。

rails/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb

# Lines 74 to 78 in [403447d](https://github.com/rails/rails/commit/403447d06165b0e9a13f0942f11b97e2f9d426b8)
 def quote_string(s) # :nodoc: 
   with_raw_connection(allow_retry: true, materialize_transactions: false) do |connection| 
     connection.escape(s) 
   end 
 end 

自分がコードを正しく追えていれば、上を抽象アダプタに移動するとQuoting#quote_stringは使われなくなるだろう。

rails/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb

# Lines 88 to 90 in [403447d](https://github.com/rails/rails/commit/403447d06165b0e9a13f0942f11b97e2f9d426b8)
 def quote_string(s) 
   s.gsub("\\", '\&\&').gsub("'", "''") # ' (for ruby-mode) 
 end 

同PRより


つっつきボイス:「trilogyアダプタとmysql2アダプタにまったく同じメソッドがあるなら抽象クラスに引っ越させようという話、わかる」「リファクタリングですね」「trilogy_adapterとmysql2_adapterがあって、さらにabstract_mysql_adapter.rbがあるのが、理にはかなっているけどちょっと面白い構成」「base_mysql_adapterではなかった」

🔗 アップデートされたTrixがSprocketsでうまく動かない問題を修正

動機/背景

Trixが1.3.1から2.0.4に更新されたとき(fab1b52)、UMDバンドル(UMDバンドルで使われるベンダリングされた1.3.1ファイル)ではなく、2.0.4のESM(ESモジュール)バンドルが使われるようになった。これにより、SprocketsでTrixを使おうとすると問題が生じた。理由は、ESMバンドルでは変数がファイルスコープであるかのように宣言されるが、Sprocketsではグローバルスコープであるとみなされるため。

詳細

このコミットでは、TrixのESMバンドルをUMDバンドルに置き換える(これで2.0.4から2.0.7にアップグレードされる)ことで問題を修正する。
また、Rakeタスクも追加された。これは以前a42863fImportmap::Packagerを用いる自動ベンダリング向けにガイドのRakeタスクに追加されたものと似ている。

追加情報

修正: #49599
クローズ: #49615
同PRより


つっつきボイス:「Trix使っている人向けの修正」「TrixはAction Textと連携しているWYSIWYGエディタでしたね」「UMDって何だろうと思ったらUniversal Module Definitionでした↓」「CJSはCommonJS」

参考: CJS, UMD, ESMとは?その違い。 | milestones
参考: Action Text の概要 - Railsガイド

DRBragg/trix-gem - GitHub

🔗 rails newで生成されるDockerfileがKubernetesでエラーになる問題を修正

動機/背景

rails newで生成されるDockerfileでは、デフォルトのユーザー名とグループ名をUID:GIDではなく名前で設定していた。

Kubernetes podで以下のようにrunAsNonRoot: trueというセキュリティコンテキストで実行するとする。

apiVersion: v1
kind: Pod
metadata:
  name: rails-rootless
  labels:
    name: rails-rootless
    namespace: default
spec:
  containers:
    - name: rails-rootless
      image: rails-rootless
  securityContext:
    runAsNonRoot: true

Kubernetesでcontainer has runAsNonRoot and image has non-numeric user (rails), cannot verify user is non-rootエラーが発生する。

詳細

このプルリクでは、数値のUID:GIDを使うことで問題を修正する。ビルド時には、RAILS_UIDRAILS_GIDビルド引数を渡すことでオプションでカスタマイズできる。現在の振る舞いを維持するため、デフォルト値は1000:1000に設定される。
同PRより


つっつきボイス:「Kubernetes rootless podsで動かすために、可読性が落ちるのを承知でUIDとGIDを名前から数値に変更した、これは仕方なさそう」「BPSで扱ってるプロジェクトではKubernetesの採用事例はあまりありませんが、使っている所はガッツリ使ってるんでしょうね」

参考: Kubernetes | Rootless Containers

🔗 マイグレーションファイル作成に失敗したら0以外のステータスを返すよう修正

動機/背景

マイグレーションファイルの生成でエラーが発生しても、ステータス0で終了してしまう。

% bin/rails generate migration ChangeFieldToUsers
      invoke  active_record
    conflict    db/migrate/20231015071420_change_field_to_users.rb
Another migration is already named change_field_to_users: /path/to/rails/db/migrate/20231015020422_change_field_to_users.rb. Use --force to replace this migration or --skip to ignore conflicted file.
% echo $?
0

これは失敗であるにもかかわらずステータスが成功になるので、スクリプトなどで成功と見なされてしまう。このプルリクは、失敗時の終了ステータスとして1を返すように修正する。

% bin/rails generate migration ChangeFieldToUsers
      invoke  active_record
    conflict    db/migrate/20231015071420_change_field_to_users.rb
Another migration is already named change_field_to_users: /path/to/rails/db/migrate/20231015020422_change_field_to_users.rb. Use --force to replace this migration or --skip to ignore conflicted file.
% echo $?
1

追加情報

終了ステータスが常に0になる変更はc49bb4dで行われた。当時のコミットメッセージ:

どのコマンドで失敗時に0以外の値を返したいかを調査すべきだが、とりあえず警告を無効にして古い振る舞いを維持しておくことにする。

しかし失敗した場合は常に0以外の値を返すべきだろう。0にすべき場合はおそらくない。
同PRより


つっつきボイス:「失敗したのに0(=成功)を返して終了すると、CIとかスクリプトで呼び出したときに判定できなくなるヤツだ」「よくぞ見つけたという感じですね」

参考: 終了ステータス - Wikipedia

🔗 SQLite3にsupports_deferrable_constraints?を追加

動機/背景

SQLiteは機能豊富なデータベースエンジンであり、productionでの利用は増える一方である。その機能を幅広く開発者に提供するにはRailsでのサポートが必要。

PostgreSQLではdeferred(延期)外部キーをサポートしているが、(SQLite自身も少なくとも2011年からdeferred foreign keysをサポートしている。

詳細

supports_deferrable_constraints?の完全なコントラクトを実装することで、add_referenceメソッドとadd_foreign_keyメソッドのforeign_keyオプションに:deferrableキーを追加することで外部キーをdeferredにできるようになる。

add_reference :person, :alias, foreign_key: { deferrable: :deferred }
add_reference :alias, :person, foreign_key: { deferrable: :deferred }

このプルリクでは、SQLite3Adapterで以下を実装することでフルサポートを追加している。

  • ActiveRecord::ConnectionAdapters::SQLite3::SchemaCreation#visit_AddForeignKey
  • ActiveRecord::ConnectionAdapters::SQLite3::SchemaCreation#visit_ForeignKeyDefinition
  • ActiveRecord::ConnectionAdapters::SQLite3Adapter#supports_deferrable_constraints?

  • ActiveRecord::ConnectionAdapters::SQLite3::SchemaStatements#assert_valid_deferrable

また、以下も変更している。

  • ActiveRecord::ConnectionAdapters::SQLite3::SchemaStatements#add_foreign_key
  • ActiveRecord::ConnectionAdapters::SQLite3Adapter#foreign_keys

追加情報

ActiveRecord::ConnectionAdapters::SQLite3Adapter#foreign_keysメソッドが適切に動作するために、CREATE TABLE SQLを取得することでどの外部キーがdeferrableでどの外部キー制約がdeferredかを解析するクエリを追加する必要があった。このユースケースをサポートするため、table_structure_with_collationメソッドが現在依存しているtable_structure_sqlメソッドを抽出した。
同PRより

SQLiteのdeferred Foreign Keyもサポートされたそうです。


つっつきボイス:「最近RailsのSQLite3を強化しているfractaledmindさん(ウォッチ20231004など)がまた新機能を追加しました」「SQLite強い人出た」「PostgreSQLの新機能をRailsで使えるようにしたりするのと同じノリで、SQLite3でもDEFERRABLE INITIALLYを使って外部キーでdeferrableをサポートしたということですね👍: PostgreSQLのも外部キーの制約をdeferrableにできる機能だった」

# activerecord/lib/active_record/connection_adapters/sqlite3/schema_creation.rb#L5
    module SQLite3
      class SchemaCreation < SchemaCreation # :nodoc:
        private
+         def visit_AddForeignKey(o)
+           super.dup.tap do |sql|
+             sql << " DEFERRABLE INITIALLY #{o.options[:deferrable].to_s.upcase}" if o.deferrable
+           end
+         end
+
+         def visit_ForeignKeyDefinition(o)
+           super.dup.tap do |sql|
+             sql << " DEFERRABLE INITIALLY #{o.deferrable.to_s.upcase}" if o.deferrable
+           end
+         end
+

Rails 7: PostgreSQLの外部キー制約にdeferrableを指定可能になった(翻訳)

🔗 PostgreSQL向けのset_constraintsヘルパーを追加

PostgreSQLにset_constraintsヘルパーが追加された。

Post.create!(user_id: -1) # => ActiveRecord::InvalidForeignKey

Post.transaction do
  Post.connection.set_constraints(:deferred)
  p = Post.create!(user_id: -1)
  u = User.create!
  p.user = u
  p.save!
end

Cody Cutrer
同CHANGELOGより


つっつきボイス:「お、PostgreSQLのset_constraintsをサポートしたんですね: こういうのを設定した後コネクションプールに残ると困るので、終わったら片付けてくれるかが気になったけど、上のサンプルコードを見るとトランザクション内にset_constraints(:deferred)を書く分には大丈夫っぽいのかな」「PostgreSQLのドキュメントにも"現在のトランザクション"とあるので↓、そういう使い方なら大丈夫そうですね」

SET CONSTRAINTSは、現在のトランザクションにおける制約検査の動作を設定します。 IMMEDIATE制約は、1つの文の実行が終わるごとに検査されます。 DEFERRED制約は、トランザクションがコミットされるまで検査されません。 全ての制約は、IMMEDIATEDEFERREDのどちらかのモードを持ちます。
PostgreSQL 15ドキュメント SET CONSTRAINTSより

🔗 assert_changesassert_no_changesのエラーメッセージを改善

assert_changesのエラーメッセージでは.inspectでオブジェクトが表示されるようになり、nilと空文字列の違いや、文字列とシンボルの違いなどがわかりやすくなった。
assert_no_changesのエラーメッセージでは、実際の値が表示されるようになった。

pcreux
同CHANGELOGより


つっつきボイス:「assert_changesassert_no_changesが失敗したときのエラーメッセージにオブジェクトを表示するようになった: 地味だけど便利な改修👍」

# activesupport/lib/active_support/testing/assertions.rb#L197
        unless from == UNTRACKED
-         error = "Expected change from #{from.inspect}, got #{before}"
+         error = "Expected change from #{from.inspect}, got #{before.inspect}"
          error = "#{message}.\n#{error}" if message
          assert from === before, error
        end

        after = exp.call

        error = "#{expression.inspect} didn't change"
-       error = "#{error}. It was already #{to}" if before == to
+       error = "#{error}. It was already #{to.inspect}" if before == to
        error = "#{message}.\n#{error}" if message
        refute_equal before, after, error

        unless to == UNTRACKED
-         error = "Expected change to #{to}, got #{after}\n"
+         error = "Expected change to #{to.inspect}, got #{after.inspect}\n"
          error = "#{message}.\n#{error}" if message
          assert to === after, error
        end

🔗Rails

お詫びと訂正(2023/11/15)

記事公開後に上のご指摘をいただきました。ご指摘ありがとうございます。

記事見出しを以下のように変更し、元のトピックを折り畳みにしました。確認漏れ大変失礼いたしました。お詫びして訂正いたします。

週刊Railsウォッチ: Rails 6.1のサポートは来年6月1日終了ほか(20231114前編)

週刊Railsウォッチ: MariaDBのRETURNINGオプションをサポートほか(20231114前編)

補足情報: Rails 7.1リリース前の9/22に、fb6c600でmainブランチのメンテナンスポリシーが更新されて6.1.zがセキュリティ修正の対象から削除されましたが、これは今後のRails 7.2が対象です。現行の7-1-stableブランチでは引き続き6.1.zがセキュリティ修正の対象となっています。

修正前の記事(クリックで展開)

🔗 Rails 6.1のサポート終了は2024年6月1日


同サイトより


つっつきボイス:「この間BPSの社内Slackに貼っていたリンクです」「そうそう、Rails 6.1の終了が来年半ばって、割とすぐなんですよね」「7か月先って半年ちょいですね」「そんなに早いのか〜」「ところで7.0の終了はいつでしょうか?」「上のサイトにはまだEOLは表示されていないので大丈夫👌」

🔗 actual_db_schema: Gitブランチ切り替え時のdbスキーマ変更の手間を軽減(RubyFlowより)

widefix/actual_db_schema - GitHub


つっつきボイス:「開発中にGitブランチを切り替えたときのスキーマ変更をサポートするgemだそうです」「"ただしマイグレーションはすべてリバーシブルであること"」「こういうツールが欲しい人がいるのはわかるけど、CIでも読み込まれることになるので、チーム全員が使うならまだしも、一部の人しか使わないなら少なくともプロジェクトのGemfileには書きたくないかな」「たしかに、その人しか使わない外部ツールになっている方が無難ですね」「development環境であってもGemfileのgemが増えてくると古くなったときにGitHubのDependabotで警告が鳴りまくったりするので、なるべくシンプルにしておきたい」「ですね」

参考: Dependabot を使う - GitHub Docs

🔗 「Bundlerチョットワカルマニュアル」


つっつきボイス:「jnchitoさんがBundlerについて初心者向けにひととおりまとめてくれた記事です」「bundle execを付ける場合と付けない場合とか、定番の話が集約されていると若手に説明しやすいのでありがたい🙏」「"gemのバージョンを完全に固定するときはコメントに理由を書く"、これはたしかにやった方がいい」「"Gemfile.lockは手で編集してはいけない"、これホント」「昔やっちゃってた〜」

--path vendor/bundleが不要な件、これが必要になることがあるとすれば、プロジェクト全体をzipで固めてソースコードとして単体イメージでデプロイ/実行したいときぐらいしかないと思うので、通常は不要でしょうね」「以前は何も考えずに--path vendor/bundleを付けてました😅」「特に現代ならDockerイメージの中にgemが保存されるので、わざわざ書く意味はほぼないと思います」


前編は以上です。

バックナンバー(2023年度第4四半期)

週刊Railsウォッチ: Kaigi on Rails発表「Simplicity on Rails」を見るほか(20231107)

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

Rails公式ニュース

RubyFlow

160928_1638_XvIP4h


CONTACT

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