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

週刊Railsウォッチ: Active Modelで属性のパターンマッチをサポート、猫でもわかるHotwire入門ほか(20220516前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

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

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

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

🔗 Active Modelでパターンマッチングを利用可能に

Active Modelで(ひいてはActive Recordでも透過的に)パターンマッチングを利用できるようになるとよい。
複数の属性で条件をチェックしたいときに、以下のようにパターンマッチングの構文が使えるとよい。

case Current.user
in { superuser: true }
  "Thanks for logging in. You are a superuser."
in { admin: true, name: }
  "Thanks for logging in, admin #{name}!"
in { name: }
  "Welcome, #{name}!"
end

同PRより


つっつきボイス:「お〜、Rubyのパターンマッチング構文をActive Modelで直接使えるようにインターフェイスを用意したのか」「何と!」「"いいね"👍がたくさん付いてる」

Ruby 3のパターンマッチング応用(1)ポーカーゲーム(翻訳)

「なるほど、一般にパターンマッチを実装するには、ハッシュを返すdeconstruct_keysを定義すればいいのね↓」「こうすれば自分のクラスでもパターンマッチが使えるようになるのか」「わかりやすくて実用的ないい機能👍」

# activemodel/lib/active_model/attribute_methods.rb518
    def deconstruct_keys(keys)
      deconstructed = {}

      keys.each do |key|
        string_key = key.to_s
        deconstructed[key] = _read_attribute(string_key) if attribute_method?(string_key)
      end

      deconstructed
    end

参考: Ruby2.7の(実験的)新機能「パターンマッチ」で遊ぶ - メドピア開発者ブログ


後でこちらの記事も目に止まりました↓。

#deconstruct_keys を誤って実装すると非効率的な結果となることがあるので、たくさんのキーを取りたい場合は愚直に書いた方が早いし見やすくなるとので注意が必要とのことでした😮
Ruby 2.7 で導入予定のパターンマッチングを試したら無限大の可能性を感じた話 - Feedforce Developer Blogより

🔗 長い文字列をDate・Time・DateTimeにキャストしたときにエラーにならないよう修正


つっつきボイス:「issueを見ると、" " * 129をDateに変換したときにnilにならずにエラーになっていたのか↓」「いかにも無効な日付文字列」「よくぞ見つけましたね」

# #45003より
# frozen_string_literal: true

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  # Activate the gem you are reporting the issue against.
  gem "activemodel", "~> 7.0.0"

end

require "active_model"
require "minitest/autorun"

class BugTest < Minitest::Test
  def test_stuff
    assert_nil ActiveModel::Type::Date.new.cast(" " * 129)
  end
end

「Dateなどの_parseメソッドに関連しているみたい」「ドキュメントを見るとlimit: 128というデフォルト値があるので、これを踏んだんでしょうね↓」「たしかにパーサーに無限長の文字列を渡せるのはあまりよくないので、Rubyでデフォルトの上限値を設けているのはわかる」

参考: Class: Date (Ruby 3.1.2) -- _parse

# ruby-doc.orgより
_parse(string[, comp=true], limit: 128) → hash

「ここではDate._parseを呼ぶときにArgumentErrorをrescueしたうえで握りつぶす形で回避してますね↓」「128文字を超える文字列を渡したかったというより、意味のわかりにくいArgumentErrorを回避したかったということなのかも」

# activemodel/lib/active_model/type/date.rb#L55
        def fallback_string_to_date(string)
-         new_date(*::Date._parse(string, false).values_at(:year, :mon, :mday))
+         parts = begin
+           ::Date._parse(string, false)
+         rescue ArgumentError
+         end
+
+         new_date(*parts.values_at(:year, :mon, :mday)) if parts
        end

「ところで、このプルリクには珍しく👎も付いてますね」「エラーを握りつぶしていいかどうかは議論が分かれるところではあるけど、ArgumentErrorが直感的でないのもわかる」

🔗 矛盾するリレーション上の計算でクエリを発行しないようにする

従来は、countなどのリレーション計算で、User.where(id: []).countのような矛盾があってもクエリが発行されてしまっていた。
既に@jhawthornが以下のようなプルリクで、矛盾時のクエリ発行を回避するようにしてくれていた。

@composerinteraliaと@jhawthornと私は、リレーションでの計算になかった最適化にスポットを当ててこれまでのプルリクでガイドとして利用していた。
このプルリクは、countaverageminimummaximumのリレーションが矛盾している場合のクエリ発行を回避する。
同PRより


つっつきボイス:「contradictionは"矛盾"」「User.where(id: []).countはidが空配列なので意味がないことを指してるみたい」「そういうときに無駄なクエリを投げないようにしたんですね」「ActiveRecord::WhereClause(ドキュメントなし)にcontradiction?というメソッドがあるのか」「こういうふうに早い段階で別の方法に切り替えるのは最適化でよく行われますね: 自明なケースなら除外しやすい👍」

# activerecord/lib/active_record/relation/calculations.rb#L340
      def execute_simple_calculation(operation, column_name, distinct) # :nodoc:
        if operation == "count" && (column_name == :all && distinct || has_limit_or_offset?)
          # Shortcut when limit is zero.
          return 0 if limit_value == 0

+         relation = self
          query_builder = build_count_subquery(spawn, column_name, distinct)
        else
          # PostgreSQL doesn't like ORDER BY when there are no GROUP BY
          relation = unscope(:order).distinct!(false)
          column = aggregate_column(column_name)
          select_value = operation_over_aggregate_column(column, operation, distinct)
          select_value.distinct = true if operation == "sum" && distinct
          relation.select_values = [select_value]
          query_builder = relation.arel
        end

-       query_result = skip_query_cache_if_necessary do
-         @klass.connection.select_all(query_builder, "#{@klass.name} #{operation.capitalize}", async: @async)
+       query_result = if relation.where_clause.contradiction?
+         ActiveRecord::Result.empty
+       else
+         skip_query_cache_if_necessary do
+           @klass.connection.select_all(query_builder, "#{@klass.name} #{operation.capitalize}", async: @async)
+         end
        end

        query_result.then do |result|
          if operation != "count"
            type = column.try(:type_caster) ||
              lookup_cast_type_from_join_dependencies(column_name.to_s) || Type.default_value
            type = type.subtype if Enum::EnumType === type
          end
          type_cast_calculated_value(result.cast_values.first, operation, type)
        end
      end

「こういう1件もヒットしないクエリってものすごく重くなることがあるんですよ」「1件もないことを証明するには全件チェックしないといけない、たしかに」「さんざん調べた結果"ありませんでした"だと悲しいですね」

参考: 消極的事実の証明 - Wikipedia

🔗 insert_allupsert_allalias_attributesもサポート

# 同PRより
class Book < ApplicationRecord
  alias_attribute :title, :name
end

Book.insert_all [{ title: "Remote", author_id: 1 }], returning: :title

ちょっと便利になる。現在はBookの'title'属性でActiveModel::UnknownAttributeErrorエラーになる。
同PRより

つっつきボイス: 「お、insert_allupsert_allalias_attributesを解決してクエリを投げてくれるようになった」「今までエイリアスできなかったんですね」

alias_attributesは、Railsの管理下にない外部のデータベースにアクセスするときなんかによく使ってます」「あぁ、カラム名がすごくわかりにくいときとかですね」「古いシステムでカラム名やテーブル名が8文字までみたいな謎のローカルルールがあったりとか」「8文字はつらそう...」

🔗 デフォルト値付きのカラムで暗号化属性をサポート

デフォルト値を持つカラムで定義される暗号化属性のサポートを追加する。
従来はコンテンツが暗号化されていなかったためにこうしたカラムを読み出せなかった(config.active_record.encryption.support_unencrypted_dataを有効にした場合を除く)。これで、レコード作成時にこれらの値が暗号化されるようになる。
#44993でこの問題を調べてくれた@fatkodimaに感謝し、ここで共著者に追加した。

修正されるissue: #44314#43664
クローズされるissue: #44993
同PRより


つっつきボイス:「暗号化した属性に暗号化されていないデフォルト値があるとどうなるかを考えてみれば、これが欲しくなるのもわかる気がする」

参考: Rails API encrypt_attribute -- ActiveRecord::Encryption::EncryptableRecord

🔗 MemoryStore#write(name, val, unless_exist: true)に失効したエントリを渡したときのチェック方法を修正

キーが失効しているときのActiveSupport::Cache::MemoryStore#write(key, val, unless_exist: true)を修正。
#write_serialized_entryでキーが存在するかどうかをチェックするときに、exist?ではなく@data.key?が使われていたために失効がバイパスされていた。
同PRより

# activesupport/lib/active_support/cache/memory_store.rb#L166
        def write_entry(key, entry, **options)
          payload = serialize_entry(entry, **options)
          synchronize do
-           return false if options[:unless_exist] && @data.key?(key)
+           return false if options[:unless_exist] && exist?(key)

            old_payload = @data[key]
            if old_payload
              @cache_size -= (old_payload.bytesize - payload.bytesize)
            else
              @cache_size += cached_size(key, payload)
            end
            @data[key] = payload
            prune(@max_size * 0.75, @max_prune_time) if @cache_size > @max_size
            true
          end
        end

つっつきボイス:「これはバグ修正」「MemoryStoreを本番で使うことがほとんどないので気づかれなかったのかも」

参考: §2.3 ActiveSupport::Cache::MemoryStore -- Rails のキャッシュ機構 - Railsガイド

MemoryStoreってテストで使うヤツでしたっけ?」「Redisのような外部キャッシュストアなしで使えるキャッシュストアです」「Rubyプロセスのメモリ上にキャッシュストアを構築するからセットアップが楽なんですね」「その代わりプロセスを再起動すると全部消えるので、本番では普通使わないと思います」「なるほど」「MemoryStoreはないよりあった方が多少速くなると思いますが、今どきはマシンも高速になっていますし、OSのキャッシュも効いたりすると思うので、昔ほどは使ってないかも」

🔗Rails

🔗『猫でもわかるHotwire入門 Turbo編』


つっつきボイス:「お〜これは力作」「ボリュームも内容も充実してそう」「無料ありがたい🙏」「"〜でもわかる"みたいなタイトルは久しぶりかも」


そういえば英語圏だと「〜 for dummies」シリーズや「〜 101」シリーズが、日本で言う「〜でもわかる」的な入門向けタイトルですね。「〜 for Zombies」(ゾンビでもわかる)みたいなタイトルも見かけます。

参考: dummies - Learning Made Easy
参考: Adams 101 Book Series | World of Books


「自分がこういう本を書くとしたら、5年ぐらい通用する内容にしたい気持ち」「わかります」「自分も大学の授業で教えるときにで使う教材も改定を重ねながら5年ぐらい使ってますね: 教材ではあまり特定の技術に依存しないようにしているから長持ちするという面もあると思いますが」

🔗 ブランチカバレッジでSpecの品質を改善する(Ruby Weeklyより)


つっつきボイス:「カバレッジ計測用のsimplecov gemでbranchを指定するとブランチカバレッジが取れるようになる↓」

# 同記事より
require 'simplecov'

SimpleCov.start do
  enable_coverage :branch
end

require_relative './example2.rb'

RSpec.describe 'number_to_dollar' do
  subject { number_to_dollar(2.56) }

  it 'returns dollar and cents' do
    expect(subject).to eq('2 dollar(s) 56 cent(s)')
  end
end

simplecov-ruby/simplecov - GitHub

「ブランチカバレッジはよく登場する概念」「ブランチカバレッジって初めて聞いたかも」「ここで言うブランチは分岐の意味ですね」「なるほど、コードの分岐をどれだけテストで押さえているかというカバレッジの指標の方ですか」

参考: ブランチカバレッジ(ぶらんちかばれっじ):情報システム用語事典 - ITmedia エンタープライズ

「たとえばifのあるコードがテストで1度も実行されなければブランチカバレッジで発見可能ということになりますね」「お〜」「ただ、変数に事前にどんな値が設定されるかで実行条件が変わったりするようなコードだと、ブランチカバレッジですべての変数のパターンを網羅できるとは限らないことも考えられる」「たしかに」「ちなみに今のRubyはソースコードの各部分について読み取りと実行が行われたかどうかという情報を取れます」

🔗 書籍『Modern Front-End Development for Rails, Second Edition』


つっつきボイス:「さっき見かけて気になってた本です」「HotwireとStimulusとTurboというRails 7から標準で使えるフロントエンド系の機能に加えて、Reactとの組み合わせについても解説されているということなのかな」「今年9月発売だからまだ先か」「ベータ版ならもう買えるようですね: The Pragmatic Bookshelfシリーズではこういうふうに書けた部分から本にすることもよくやっている」

「Pragmatic Bookshelfの本は、理論的に高度な本よりはテーマに沿った網羅的な内容の本が多いという印象: 個人的には、自分が初めて取り組む分野について流し読みするのに向いている感じで割と読みやすい」「なるほど」「もちろん著者にもよりますけどね」

「Pragmatic Bookshelfの本は、たとえばだいぶ昔のですがRSpec 2の本が有名かな↓」「あ、これもそうなんですか」「日本語版も出てましたね」

🔗 その他Rails


つっつきボイス:「Everyday Railsの改訂版がダウンロードできる❤️」「輪読会のような場が修正の機会になるというのはたしかにありますね: 自分も大学の授業で使う教材を2回ぐらい授業で使って学生からフィードバックをもらうといろいろ改善される」「最初の授業はベンチマークの場でもありますよね」

🔗 教材で大事なバージョン指定

「割と前ですが、教材を2回目に使う授業の直前にRailsがメジャーバージョンアップされたときは、教材のとおりに動かなくなって大変だったことがありましたね: _6.0.4_のようにバージョン指定すれば回避できますが」「そういえばこのテクニックはRailsチュートリアル™でも使われています↓」

# railstutorial.jpより
rails _6.0.4_ new hello_app

「そうそう、ビギナー向けの教材は書いてあるとおりに動くことがとても大事なので、こうやってRailsやBundlerのバージョンをがっちり指定するのは教材づくりの定番ですね」「学習意欲に水を差さないために大事」「バージョン違いでつまづいて苦手意識を持ってしまったら残念」「なお、こういうバージョン指定のインストールは、教材以外に実際の業務で使うこともあります」


前編は以上です。

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

週刊Railsウォッチ: Ruby 3.2.0devにRust版YJITがマージ、Docker Compose V2ほか(20220511後編)

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

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

Rails公式ニュース

Ruby Weekly


CONTACT

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