- Ruby / Rails関連
週刊Railsウォッチ: Active StorageでIllustratorファイルをMuPDFとPopplerでプレビュー可能にほか(20240326前編)
こんにちは、hachi8833です。
🔗Rails: 先週の改修(Rails公式ニュースより)
- 公式更新情報: Ruby on Rails — Illustrator file preview, deprecations and more!
- 公式更新情報: Ruby on Rails — Active Record Basics Guide Refresh, Encrypted Attributes Re-Optimization, and more...
🔗 Active StorageでIllustratorファイルをプレビュー可能になった
- PR: Illustrator files are previewable with Poppler as well by jeremy · Pull Request #51236 · rails/rails
#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の修正でできなくなってたのか」
- PR: Illustrator files are previewable with Poppler as well by jeremy · Pull Request #51236 · rails/rails
参考: 【公式】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
🔗 カザフスタンのタイムゾーン移行に対応
- PR: Updating Astana with a Western Kazakhstan timezone by damiann · Pull Request #51317 · rails/rails
動機/背景
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
🔗 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.5
truncate_bytes` -- Active Support コア拡張機能 - Railsガイド
「String
にencoding
引数でエンコーディング指定すれば変わらなくなるけど、Rubyのforce_encoding
メソッドを使うことでString
のサブクラスに影響が生じないようにしたのはわかる」
参考: String#force_encoding
(Ruby 3.3 リファレンスマニュアル)
🔗 ActiveStorage::Blob#compose
でカスタムkey
をサポート
4dba136
でActiveStorage::Blob
にカスタムキーのサポートが追加されたが、79a5e0b
でcompose
を導入したときにこれを複製しそびれていた。
このプルリクは、compose
を変更してカスタムのkey
を渡せるようにすることで、背後のService Objectの名前をカスタマイズ可能にする。同PRより
つっつきボイス:「ActiveStorage::Blob
のcompose
にカスタムキーをサポートする機能が入ったけど、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 UPDATE
でVALUES()
を使うことが非推奨になったということだと思います↓」「なるほど」「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後編)
- 20240312前編 Rails 8に入るSolid Cacheほか
- 20240228 Rails 8でSprocketsがPropshaftに置き換わる、devcontainerサポートほか
- 20240227後編 Turbo Nativeアプリ、書籍『Everyday Rails Testing with RSpec』新版執筆開始ほか
- 20240221前編 form_withのmodelオプションへのnil渡しが非推奨化、Dockerfileでjemallocが有効にほか
- 20240207後編 aws-sdk-rubyの全gemにRBSファイルが追加ほか
- 20240206前編 Pumaのデフォルトスレッド数変更、Rails 1.0をRuby 3.3で動かすほ
- 20240125後編 RailsコントローラのparamsはHashではない、ruby-enumほか
- 20240123前編 Railsの必須Rubyバージョンが3.1.0以上に変更ほか
- 20240119後編 Ruby 3.3でYJITを有効にすべき理由、Turbo 8の注意点8つほか
- 20240117前編 Rails 8マイルストーン、2023年のRails振り返り、Solid Queueほか
ソースの表記されていない項目は独自ルート(TwitterやはてブやRSSやruby-jp SlackやRedditなど)です。
週刊Railsウォッチについて
TechRachoではRubyやRailsなどの最新情報記事を平日に公開しています。TechRacho記事をいち早くお読みになりたい方はTwitterにて@techrachoのフォローをお願いします。また、タグやカテゴリごとにRSSフィードを購読することもできます(例:週刊Railsウォッチタグ)