nobo's blog

プログラミング、日常、etc...

Active Supportのpluralizeメソッド見てみる

Active Record の基礎 2.1 命名規約

Active Record の基礎 - Railsガイド

Railsでは、データベースのテーブル名を探索するときに、モデルのクラス名を複数形にした名前で探索します。たとえば、Bookというモデルクラスがある場合、これに対応するデータベースのテーブルは複数形の「books」になります。Railsの複数形化メカニズムは非常に強力で、不規則な語でも複数形/単数形に変換できます(person <-> peopleなど)。これにはActive Supportの pluralizeメソッドが使われています。

ここ気になっていたので、Active Supportの pluralizeメソッドを見ていく。

Active Support コア拡張機能 - Railsガイド

ここにあるようにレシーバを「複数形」にしたものを返す。

If the optional parameter +locale+ is specified, the word will be pluralized as a word of that language. By default, this parameter is set to :en. You must define your own inflection rules for languages other than English.

オプションのパラメータ +locale+ が指定された場合、 指定された言語の単語として複数形化されます。 デフォルトでは、このパラメータは :en に設定されています。 英語以外の言語については、独自の活用規則を定義する必要があります。

らしく、locale によって変えられるんだ。初めて知った。

  def pluralize(count = nil, locale = :en)
    locale = count if count.is_a?(Symbol)
    if count == 1
      dup
    else
      ActiveSupport::Inflector.pluralize(self, locale)
    end
  end

count if count.is_a?(Symbol) となっているけど、count って symbol が渡されるんだ。数値かと思った。

しかも count の結果を locale に入れている?

あー、count:ja とかが入っている場合は、else' に落ちて ActiveSupport::Inflector.pluralize(self, locale)locale を渡しているのかー。なるほど。

dup ってなんだっけ。「自身と同じクラスのオブジェクトを作成後、自身のインスタンス変数を全て新たに作成したオブジェクトにコピーします」らしい。なるほど。

Rake::Cloneable#dup (Ruby 4.0 リファレンスマニュアル)

ActiveSupport::Inflector.pluralize(self, locale) って何してるんだ?

rails/activesupport/lib/active_support/inflector/methods.rb at 4f328b0a7d57b0213eeea9dd251769b5e7648456 · rails/rails · GitHub

  def pluralize(word, locale = :en)
    apply_inflections(word, inflections(locale).plurals, locale)
  end

apply_inflections を呼んでいる。

rails/activesupport/lib/active_support/inflector/methods.rb at 4f328b0a7d57b0213eeea9dd251769b5e7648456 · rails/rails · GitHub

inflections(locale).plurals って何を返すの?

rails/activesupport/lib/active_support/inflector/inflections.rb at 4f328b0a7d57b0213eeea9dd251769b5e7648456 · rails/rails · GitHub

  def inflections(locale = :en)
    if block_given?
      yield Inflections.instance(locale)
    else
      Inflections.instance_or_fallback(locale)
    end
  end

なるほどー。ブロックが渡されることもあるんだね。 大体は Inflections.instance_or_fallback(locale) に落ちるのかな? じゃあ Inflections.instance_or_fallback(locale) はなんぞや

rails/activesupport/lib/active_support/inflector/inflections.rb at 4f328b0a7d57b0213eeea9dd251769b5e7648456 · rails/rails · GitHub

  def self.instance_or_fallback(locale)
    # :en であれば @__en_instance__ == :en, なければ インスタンス が入る
    return @__en_instance__ ||= new if locale == :en

    I18n.fallbacks[locale].each do |k|
      return @__en_instance__ if k == :en && @__en_instance__
      # それ以外の言語だったら それを返す
      # @__instance__ は Concurrent::Map.new
      return @__instance__[k] if @__instance__.key?(k)
    end
    instance(locale)
  end

@__en_instance__ って何? チャッピー に聞いたところ、普通のインスタンス変数とのこと。 Ruby コミュニティでよくある 慣習的な命名。らしく、メモ化用のインスタンス変数っぽい。へー。

instance というメソッドも定義されている

rails/activesupport/lib/active_support/inflector/inflections.rb at 4f328b0a7d57b0213eeea9dd251769b5e7648456 · rails/rails · GitHub

  def self.instance(locale = :en)
    return @__en_instance__ ||= new if locale == :en

    @__instance__[locale] ||= new
  end

:en か それ以外の言語の ActiveSupport::Inflector::Inflections インスタンスを返すのか

それで inflections(locale).plurals

ここで空配列が代入され、

rails/activesupport/lib/active_support/inflector/inflections.rb at 4f328b0a7d57b0213eeea9dd251769b5e7648456 · rails/rails · GitHub

ここで書かれているように inflect.plural が実行されるみたい。

rails/activesupport/lib/active_support/inflector/inflections.rb at 4f328b0a7d57b0213eeea9dd251769b5e7648456 · rails/rails · GitHub

@plurals.prepend([rule, replacement]) で配列を返している

rails/activesupport/lib/active_support/inflector/inflections.rb at 4f328b0a7d57b0213eeea9dd251769b5e7648456 · rails/rails · GitHub

  # 文字列, [[rule, replacement], [...]], locale
  def apply_inflections(word, rules, locale = :en)
    result = word.to_s.dup

    if word.empty? || inflections(locale).uncountables.uncountable?(result)
      # 不可算名詞ならそのまま 文字列を返す
      # information → information
      result
    else
      # word 一部分に最初にマッチした文字列を置換する
      # cat → cats
      # dog → dogs
      # cactus → cacti(irregular)
      rules.each { |(rule, replacement)| break if result.sub!(rule, replacement) }
      result
    end
  end

sub! ってなんだっけ。 「文字列中で pattern にマッチした最初の部分を文字列 replace で置き換えた文字列を生成して返します。」か。

String#sub (Ruby 4.0 リファレンスマニュアル)

Uncountables.uncountable? って何?

rails/activesupport/lib/active_support/inflector/inflections.rb at 4f328b0a7d57b0213eeea9dd251769b5e7648456 · rails/rails · GitHub

  def uncountable?(str)
    if @pattern.nil?
      # /(equipment|information|rice)/i みたいになるっぽい。わからん...
      members_pattern = Regexp.union(@members.map { |w| /#{Regexp.escape(w)}/i })
      @pattern = /\b#{members_pattern}\Z/i
    end
    @pattern.match?(str)
  end

不可算名詞かどうか判定しているのか。なるほど。

pluralize がの呼び出し元がどこかざっと見てみたが、結構あったのでどこから見ていくか迷い中。 次はここから呼び出し元を追ってみる。

rails/activemodel/lib/active_model/naming.rb at 4f328b0a7d57b0213eeea9dd251769b5e7648456 · rails/rails · GitHub

ActiveModel::Name が initialize されるタイミングっていつ?

というか ActiveModel::Name って何の責務を持っているクラス?

  > Book.model_name
  =>
  #<ActiveModel::Name:0x0000000122911040
   @collection="books",
   @element="book",
   @human="Book",
   @i18n_key=:book,
   @klass=Book (call 'Book.load_schema' to load schema informations),
   @name="Book",
   @param_key="book",
   @plural="books",
   @route_key="books",
   @singular="book",
   @singular_route_key="book",
   @uncountable=false>

  > Book.model_name.plural
  => "books"

なるほどー。I18npartial、ルーティングとかで使われているのか。

ちなみに ActiveModel::Naming.model_name が Book でも呼び出せるってことは、つまり Book インスタンスでは ActiveModel::Naming module が extend されているってことだろうな。

基本的に継承関係としては User < ApplicationRecord < ActiveRecord::Base になるから、ActiveRecord::Base の中で extend ActiveModel::Naming しているはず...! 見てみたけど、してないな。

rails/activerecord/lib/active_record/base.rb at 4f328b0a7d57b0213eeea9dd251769b5e7648456 · rails/rails · GitHub

include している中でさらに extend しているのかなー。 include ActiveModel::API が怪しいよね。

見つけた。 included しているから、ActiveRecord::BaseActiveModel::APIinclude されたタイミングで ActiveModel::Namingextend して特異メソッド(クラスメソッド)にしているのかー。

rails/activemodel/lib/active_model/api.rb at 4f328b0a7d57b0213eeea9dd251769b5e7648456 · rails/rails · GitHub

だから、ActiveRecord::Base クラス自体で model_name は呼び出せる状態になっているから、サブクラスの User でも呼び出せるってわけか。 流れは把握できた。

図にしてみる

Book.model_name.plural の流れ

flowchart TD
  A["Book.model_name.plural"] --> B["Book.model_name"]
  B --> C["ActiveModel::Naming#model_name"]
  C --> D{"@_model_nameがキャッシュされているか"}
  D -- "Yes" --> E["キャッシュされたActiveModel::Name を返す"]
  D -- "No" --> F["ActiveModel::Name.new(Book, namespace=nil)"]
  F --> G["ActiveModel::Name#initialize @singular, @plural ... を計算"]
  G --> H["@_model_name に代入"]
  H --> I["ActiveModel::Name を返す"]
  E --> J[".plural を呼ぶ"]
  I --> J
  J --> K["'books'"]

ActiveSupport::Inflector.pluralize(@singular, locale) の流れ

flowchart TD
  B["ActiveSupport::Inflector.pluralize('book', :en)"]
  B --> C["apply_inflections(word, inflections(locale).plurals, locale)"]
  C --> D{"uncountable? or empty?"}
  D -- "Yes" --> E["'book'"]
  D -- "No" --> F["rules を順に試して\nsub! で最初にマッチした変換を適用"]
  F --> G["'books'"]