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

週刊Railsウォッチ: Active StorageでIllustratorファイルをMuPDFとPopplerでプレビュー可能にほか(20240326前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

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

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

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

🔗 Active StorageでIllustratorファイルをプレビュー可能になった

#51235のMuPDFサポートをPopplerに拡張した。

Blobのプレビューと表示のテストカバレッジを追加。
同PRより


つっつきボイス:「お〜、Adobe Illustratorの.aiファイルをプレビューできるようになるとは」「ポッパーに見えたけどポップラーでした」「プルリクメッセージより公式更新情報の方が詳しく書かれていますね↓」「公式情報にある#51236のプルリクがリンク切れなので後で探しておきます」

  • PR: Illustrator files are previewable with Poppler as well by jeremy · Pull Request #51236 · rails/rails
    この機能は、Marcel 1.0.2以前ではたまたま可能だった: マジックバイトスニッフィングによってIllustratorファイルが内部的にPDFであると判断され、宣言されたContent-Typeがapplication/illustratorでファイル拡張子が.aiであってもapplication/pdfとして扱われていた。Marcel 1.0.3ではapplication/pdfよりも具体的なapplication/illustratorに修正されたが、MuPDFプレビューアはPDFとそのサブタイプしか受け取れない。このプルリクエストは、PDFとその子タイプでIllustratorファイルを再び明示的にプレビューアで利用できるようにする。従来Illustratorファイルを表示できていたのは、単に運が良かっただけだった。別のプルリク#51236によって、PopplerでもIllustratorファイルをプレビューできるようになった。
    Ruby on Rails — Illustrator file preview, deprecations and more!より

「MarcelはMIMEタイプ識別用のgemでしたね」「そういえば以前あったmimemagicのGPL問題でもMarcelに言及しましたね(ウォッチ20210329)」「今までのMuPDFではたまたま.aiファイルを表示できていたけど、Marcelの修正でできなくなってたのか」

rails/marcel - GitHub

参考: 【公式】Adobe Illustrator グラフィックデザインの定番ソフト

# activestorage/lib/active_storage/previewer/poppler_pdf_previewer.rb#L4
  class Previewer::PopplerPDFPreviewer < Previewer
    class << self
      def accept?(blob)
-       blob.content_type == "application/pdf" && pdftoppm_exists?
+       pdf?(blob.content_type) && pdftoppm_exists?
+     end
+
+     def pdf?(content_type)
+       Marcel::Magic.child? content_type, "application/pdf"
      end

「今回2つのプルリクでMuPDFでもPopplerのどちらを使っても表示できるようになったということですね」「PopplerはPDFレンダリングライブラリで、MuPDFはPDF/XPS、E-bookのビューア、Active StorageではどちらかをPDFプレビューに使うとRailsガイドにありますね」

参考: Poppler
参考: MuPDF
参考: 1.1 要件 -- Active Storage の概要 - Railsガイド

「PDFベースのIllustratorファイルという言葉が出てくるけど、これはAdobeのヘルプ↓にある、IllustratorでPDF互換のAIファイルを作れる機能を指しているようですね」

参考: Illustrator でのネイティブファイルと PDF ファイルの最適化

🔗 生SQLが提供された場合はON DUPLICATE KEY UPDATEでVALUESのエイリアスを使わないように修正

動機/背景

#51274のコメントに沿って生SQLを使うと混合クエリが発生するようになった。

Model.upsert_all(..., on_duplicate: Arel.sql("x=VALUES(x)"))
INSERT table (...) VALUES (...) as values_alias ON DUPLICATE KEY UPDATE x=VALUES(x)

および

Model.upsert_all(..., on_duplicate: Arel.sql("x=x"))
INSERT table (...) VALUES (...) as values_alias ON DUPLICATE KEY UPDATE x=x

上のどちらも以下のエラーになる。

ActiveRecord::StatementInvalid: Trilogy::ProtocolError: 1052: Column 'x' in field list is ambiguous

詳細

生SQLでは値のエイリアスを使わない古い構文(従来の振る舞い)を使う方がよいと思う(値がエイリアスされているかどうかを判定できないため↓)。

Model.upsert_all(..., on_duplicate: Arel.sql("x=VALUES(x)"))
INSERT table (...) VALUES (...) ON DUPLICATE KEY UPDATE x=VALUES(x)

同PRより


つっつきボイス:「これとこの後の#51286で値のエイリアスの話が登場していました」「ON DUPLICATE KEY UPDATEのときにas values_aliasが付いてしまうのがよくないということかな: Active RecordやArelを使っているとこのas values_aliasが付いてくることがよくあるんですが、ここではそれによって解釈が変わってしまったということのようですね」「修正はここだけでした↓」

# activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb#L641
      def build_insert_sql(insert) # :nodoc:
        no_op_column = quote_column_name(insert.keys.first)
        # MySQL 8.0.19 replaces `VALUES(<expression>)` clauses with row and column alias names, see https://dev.mysql.com/worklog/task/?id=6312 .
        # then MySQL 8.0.20 deprecates the `VALUES(<expression>)` see https://dev.mysql.com/worklog/task/?id=13325 .
        if !mariadb? && database_version >= "8.0.19"
          values_alias = quote_table_name("#{insert.model.table_name}_values")
          sql = +"INSERT #{insert.into} #{insert.values_list} AS #{values_alias}"

          if insert.skip_duplicates?
            sql << " ON DUPLICATE KEY UPDATE #{no_op_column}=#{values_alias}.#{no_op_column}"
          elsif insert.update_duplicates?
-           sql << " ON DUPLICATE KEY UPDATE "
            if insert.raw_update_sql?
-             sql << insert.raw_update_sql
+             sql = +"INSERT #{insert.into} #{insert.values_list} ON DUPLICATE KEY UPDATE #{insert.raw_update_sql}"
            else
+             sql << " ON DUPLICATE KEY UPDATE "
              sql << insert.touch_model_timestamps_unless { |column| "#{insert.model.quoted_table_name}.#{column}<=>#{values_alias}.#{column}" }
              sql << insert.updatable_columns.map { |column| "#{column}=#{values_alias}.#{column}" }.join(",")
            end
          end

🔗 key_providerをメモ化してテスト実行速度低下を解消

動機/背景

自分たちのアプリの1つをmainブランチで最新にしてみているが、テストの実行に5倍の時間がかかることに気づいた。速度低下の原因は#51019まで遡る。

Scheme#key_providerのメモ化は、with_encryption_contextによるkey_providerのオーバーライドを妨げるため、上のプルリクで完全に削除された。暗号化属性にキーを提供するか、暗号化属性を決定論的として宣言すると、属性を読み込むたびにキープロバイダをインスタンス化するためにキーを導出しなければならなくなる。これが個別のテストで数百回発生する可能性があり、最終的に以下の2つが数百回呼ばれることになる。

ActiveSupport::KeyGenerator#generate_key

および

OpenSSL::KDF.pbkdf2_hmac

このためテストで著しいオーバーヘッドが発生する。

with_encryption_contextによって実際にオーバーライドされるのは、デフォルトのプロバイダとして使われる値、すなわちActiveRecord::Encryption.key_providerである。
これは、スキームにkey_provider(直接渡す)か、keyか、deterministicがない場合にScheme#key_providerでしか使われない(これはdefault_key_provider経由で呼び出されるため)。

# rails/activerecord/lib/active_record/encryption/scheme.rb
# Lines 52 to 54 in 3efae44
  def key_provider
    @key_provider_param || build_key_provider || default_key_provider
  end
#rails/activerecord/lib/active_record/encryption/scheme.rb
#Lines 91 to 93 in 3efae44
 def default_key_provider 
   ActiveRecord::Encryption.key_provider 
 end

しかしbuild_key_providerが優先され、この実行のコストは非常に高い。

# rails/activerecord/lib/active_record/encryption/scheme.rb
# Lines 83 to 89 in 3efae44
 def build_key_provider 
   return DerivedSecretKeyProvider.new(@key) if @key.present? 

   if @deterministic 
     DeterministicKeyProvider.new(ActiveRecord::Encryption.config.deterministic_key) 
   end 
 end 

@key@deterministicもオーバーライドできないため、このプルリクではビルドの必要が生じたときにこれらをメモ化する。
同PRより


つっつきボイス:「Railsのmainブランチでテストが遅くなっていたことに気づいたそうです」「修正前だとdefault_key_providerよりbuild_key_providerの方が優先度が高かったので何百回もビルドが走っていたんですね」「これは遅くなりそう」「@key_provider_from_key ||= if @key.present?のようにキープロバイダを||=でメモ化する形で修正したのか: 優先順位は変えずにビルドした場合はメモ化するようにしたということですね👍」

# activerecord/lib/active_record/encryption/scheme.rb#L52
      def key_provider
        @key_provider_param || build_key_provider || default_key_provider
        @key_provider_param || key_provider_from_key || deterministic_key_provider || default_key_provider
      end
# (略)
-       def build_key_provider
-         return DerivedSecretKeyProvider.new(@key) if @key.present?
+       def key_provider_from_key
+         @key_provider_from_key ||= if @key.present?
+           DerivedSecretKeyProvider.new(@key)
+         end
+       end

-         if @deterministic
+       def deterministic_key_provider
+         @deterministic_key_provider ||= if @deterministic
            DeterministicKeyProvider.new(ActiveRecord::Encryption.config.deterministic_key)
          end
        end

🔗 カザフスタンのタイムゾーン移行に対応

動機/背景

2024年3/1付けで、カザフスタン(全地域)が単一のタイムゾーンUTC+5に移行した。
カザフスタンのタイムゾーンが更新された最新のtzinfo-datagemを使っても、Active SupportはAsia/Dhakaのバングラデシュのタイムゾーン(こちらでは同じTZオフセットの変更を得られない)を指していたため、表示されるオフセットが誤っていた。

pry(main)> TZInfo::Data::Version::TZDATA
=> "2024a"
pry(main)> ActiveSupport::TimeZone['Astana']
=> #<ActiveSupport::TimeZone:0x0000ffff6aa41540 @name="Astana", @tzinfo=#<TZInfo::DataTimezone: Asia/Dhaka>, @utc_offset=nil>
pry(main)> ActiveSupport::TimeZone['Astana'].now.utc_offset
=> 21600 # UTC+6
pry(main)> ActiveSupport::TimeZone['Astana'].tzinfo.identifier
=> "Asia/Dhaka"

詳細

このプルリクは、アスタナ(カザフスタンの首都)を西カザフスタンのTZInfo識別子Asia/Almatyを指すように変更する。変更後の識別子は、期待されるタイムゾーンとUTCオフセットとよくマッチするようになる。
同PRより


つっつきボイス:「タイムゾーンは国の政策次第で変わるものなので、こういうことはちょくちょく起きます」「今までのアスタナはバングラデシュのタイムゾーンと同じだったのか、へ〜!」

# activesupport/lib/active_support/values/time_zone.rb#L134
      "Mumbai"                       => "Asia/Kolkata",
      "New Delhi"                    => "Asia/Kolkata",
      "Kathmandu"                    => "Asia/Kathmandu",
-     "Astana"                       => "Asia/Dhaka",
      "Dhaka"                        => "Asia/Dhaka",
      "Sri Jayawardenepura"          => "Asia/Colombo",
      "Almaty"                       => "Asia/Almaty",
+     "Astana"                       => "Asia/Almaty",
      "Novosibirsk"                  => "Asia/Novosibirsk",
      "Rangoon"                      => "Asia/Rangoon",
      "Bangkok"

参考: カザフスタン - Wikipedia
参考: アスタナ - Wikipedia

tzinfo/tzinfo-data - GitHub

🔗 truncate_bytesに空の引数を渡してもエンコーディングが変わらないよう修正

動機/背景

特定の状況では、String#truncate_bytesは切り詰めたものと異なるエンコーディングの文字列を返すことがある。これは、String.newに引数がないとASCII-8BITエンコーディングの空文字を返すようになっているため。そうなると、文字列の各書記素クラスタや省略文字列に応じて、結果文字列がASCII-8BITを維持してしまう可能性がある。
今回の変更によって、元の文字列エンコーディングを保持するようになる。

なお、String.newにはencodingキーワード引数を渡せるので、以下のように書ける。

String.new(encoding: Encoding::UTF_8)

ただし、これは使わずにforce_encodingで元のエンコーディングを設定している。その理由は、Stringのサブクラスがこのキーワード引数を保持しなくて済むようにするため。たとえばSafeBufferはこのキーワード引数を保持しない。
気づいてくれた@jeremy、ありがとう!
同PRより


つっつきボイス:「force_encodingを呼び出すことで引数を渡してもエンコーディングが変わらないようにしたんですね↓」「すごくシンプルな修正ですね」「ちなみに自分は文字列に対してtruncate_bytesのようなバイト単位の処理をなるべく使わないようにしているので、truncate_bytesを使う機会ってあまりなかったな」

# activesupport/lib/active_support/core_ext/string/filters.rb#L101
  def truncate_bytes(truncate_to, omission: "…")
    omission ||= ""
    case
    when bytesize <= truncate_to
      dup
    when omission.bytesize > truncate_to
      raise ArgumentError, "Omission #{omission.inspect} is #{omission.bytesize}, larger than the truncation length of #{truncate_to} bytes"
    when omission.bytesize == truncate_to
      omission.dup
    else
-     self.class.new.tap do |cut|
+     self.class.new.force_encoding(encoding).tap do |cut|
        cut_at = truncate_to - omission.bytesize

        each_grapheme_cluster do |grapheme|
          if cut.bytesize + grapheme.bytesize <= cut_at
            cut << grapheme
          else
            break
          end
        end
        cut << omission
      end
    end
  end

参考: 5.5truncate_bytes` -- Active Support コア拡張機能 - Railsガイド

Stringencoding引数でエンコーディング指定すれば変わらなくなるけど、Rubyのforce_encodingメソッドを使うことでStringのサブクラスに影響が生じないようにしたのはわかる」

参考: String#force_encoding (Ruby 3.3 リファレンスマニュアル)

🔗 ActiveStorage::Blob#composeでカスタムkeyをサポート

4dba136ActiveStorage::Blobにカスタムキーのサポートが追加されたが、79a5e0bcomposeを導入したときにこれを複製しそびれていた。
このプルリクは、composeを変更してカスタムのkeyを渡せるようにすることで、背後のService Objectの名前をカスタマイズ可能にする。

同PRより


つっつきボイス:「ActiveStorage::Blobcomposeにカスタムキーをサポートする機能が入ったけど、blobをcomposeした時点の結果にkeyでカスタムのキーを含められる機能を入れ忘れたので後追いで追加したんですね」「なるほど」「キーはなくてもよさそうなので、いわゆるキーというよりはメタ情報的なものかなと思うけど、後で特定のblobで処理方法を変えたいときなんかにこのキー名を使うなんてことができそう👍」

# activestorage/test/models/blob_test.rb#L138
+ test "compose with custom key" do
+   blobs = 3.times.map { create_blob(data: "123", filename: "numbers.txt", content_type: "text/plain", identify: false) }
+   blob = ActiveStorage::Blob.compose(blobs, key: "custom_key", filename: "all_numbers.txt")
+
+   assert_equal "custom_key", blob.key
+   assert_equal "123123123", blob.download
+ end

参考: Rails API compose -- ActiveStorage::Blob

🔗 MySQL 8.0.19で発生するようになったエラーを修正

動機/背景
このプルリクは、8.0.18以下のMySQL 8.0で発生するエラーを修正する。

詳細

  • 再現手順
git clone https://github.com/rails/rails
cd rails
git clone https://github.com/rails/buildkite-config .buildkite/
RUBY_IMAGE=ruby:3.3 docker-compose -f .buildkite/docker-compose.yml build base &&
  CI=1 MYSQL_IMAGE=mysql:8.0.18 docker-compose -f .buildkite/docker-compose.yml run mysqldb runner activerecord 'rake db:mysql:rebuild test:mysql2'
  • このコミットがないとこうなる
▶(クリックで展開)
... snip ...
Error:
InsertAllTest#test_upsert_all_implicitly_sets_timestamps_on_create_when_model_record_timestamps_is_false_but_overridden:
ActiveRecord::StatementInvalid: Mysql2::Error: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'AS `ships_values` ON DUPLICATE KEY UPDATE updated_at=(CASE WHEN (`ships`.`name`<' at line 1
    /usr/local/bundle/gems/mysql2-0.5.6/lib/mysql2/client.rb:151:in `_query'
    /usr/local/bundle/gems/mysql2-0.5.6/lib/mysql2/client.rb:151:in `block in query'
    /usr/local/bundle/gems/mysql2-0.5.6/lib/mysql2/client.rb:150:in `handle_interrupt'
    /usr/local/bundle/gems/mysql2-0.5.6/lib/mysql2/client.rb:150:in `query'
    lib/active_record/connection_adapters/mysql2/database_statements.rb:104:in `block (2 levels) in raw_execute'
    lib/active_record/connection_adapters/abstract_adapter.rb:997:in `block in with_raw_connection'
    /rails/activesupport/lib/active_support/concurrency/load_interlock_aware_monitor.rb:23:in `handle_interrupt'
    /rails/activesupport/lib/active_support/concurrency/load_interlock_aware_monitor.rb:23:in `block in synchronize'
    /rails/activesupport/lib/active_support/concurrency/load_interlock_aware_monitor.rb:19:in `handle_interrupt'
    /rails/activesupport/lib/active_support/concurrency/load_interlock_aware_monitor.rb:19:in `synchronize'
    lib/active_record/connection_adapters/abstract_adapter.rb:969:in `with_raw_connection'
    lib/active_record/connection_adapters/mysql2/database_statements.rb:102:in `block in raw_execute'
    /rails/activesupport/lib/active_support/notifications/instrumenter.rb:58:in `instrument'
    lib/active_record/connection_adapters/abstract_adapter.rb:1112:in `log'
    lib/active_record/connection_adapters/mysql2/database_statements.rb:101:in `raw_execute'
    lib/active_record/connection_adapters/abstract_mysql_adapter.rb:237:in `execute_and_free'
    lib/active_record/connection_adapters/mysql2/database_statements.rb:23:in `internal_exec_query'
    lib/active_record/connection_adapters/abstract/database_statements.rb:171:in `exec_insert_all'
    lib/active_record/connection_adapters/abstract/query_cache.rb:26:in `exec_insert_all'
    lib/active_record/insert_all.rb:55:in `execute'
    lib/active_record/insert_all.rb:13:in `block in execute'
    lib/active_record/connection_adapters/abstract/connection_pool.rb:384:in `with_connection'
    lib/active_record/connection_handling.rb:270:in `with_connection'
    lib/active_record/insert_all.rb:12:in `execute'
    lib/active_record/persistence.rb:363:in `upsert_all'
    test/cases/insert_all_test.rb:561:in `block in test_upsert_all_implicitly_sets_timestamps_on_create_when_model_record_timestamps_is_false_but_overridden'
    test/cases/insert_all_test.rb:809:in `with_record_timestamps'
    test/cases/insert_all_test.rb:560:in `test_upsert_all_implicitly_sets_timestamps_on_create_when_model_record_timestamps_is_false_but_overridden'

bin/rails test /rails/activerecord/test/cases/insert_all_test.rb:557

E
... snip ...
8856 runs, 25842 assertions, 1 failures, 52 errors, 41 skips

追加情報
#51274の続き。

MySQL 8.0.19と8.0.20のリリースノートとWLとコミットを参照。

MySQL は、挿入される行とその列に対するINSERT INTO ... ON DUPLICATE KEY UPDATE ステートメントの VALUES 句と SET 句でエイリアスをサポートするようになった。 次のようなステートメントを考えてみる。

MySQL :: WL#6312: Referencing new row in INSERT ... VALUES ... ON DUPLICATE KEY UPDATE

新しい行値にアクセスするためにINSERT ... ON DUPLICATE KEY UPDATEステートメントで VALUES()を使うことは現在非推奨であり、将来のMySQLリリースでは削除される可能性がある。
代わりに、MySQL 8.0.19以降で実装されているように、新しい行とそのカラムのエイリアスを使う必要がある。

MySQL :: WL#13325: Deprecate VALUES syntax in INSERT ... ON DUPLICATE KEY UPDATE

同PRより


つっつきボイス:「プルリクに添付されているエラーログを取り出してみると↓、これもさっきの#51325と同じように、ON DUPLICATE KEY UPDATEのときにASとvaluesが使われたときの解釈が変わったことでエラーになったみたい: エイリアスと呼んでいるのはAS句のことだと思います」

ActiveRecord::StatementInvalid: Mysql2::Error: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'AS ships_values ON DUPLICATE KEY UPDATE updated_at=(CASE WHEN (ships.name<' at line 1

「上の引用でVALUES()が非推奨になったとあるのは、ON DUPLICATE KEY UPDATEVALUES()を使うことが非推奨になったということだと思います↓」「なるほど」「INSERT INTO x VALUES()のようなVALUES()の使い方はSQL標準ですね」

-- https://dev.mysql.com/doc/relnotes/mysql/8.0/en/news-8-0-19.htmlより
INSERT INTO t
    VALUES (9,5), (7,7), (11,-1)
    ON DUPLICATE KEY UPDATE a = a + VALUES(a) - VALUES(b);

前編は以上です。

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

週刊Railsウォッチ: Rubyでシリアルポートにアクセス、Active Record vs Sequelほか(20240313後編)

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

Rails公式ニュース


CONTACT

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