😮‍💨

Active Model の attribute はどのように書かれているのか? ①

に公開

前提知識

Active Modelについて

まずはRailsガイドを見てみます。
https://railsguides.jp/active_model_basics.html

Active Recordは「データベースのテーブルに対応するモデル」を定義するインターフェイスを提供するものであり、Active Modelは「必ずしもデータベースを必要としない、モデル風のRubyクラス」を構築するための機能を提供するものです。

なるほど、Active Recordはテーブルのモデルで、Active Modelはモデル風のクラスなのか。
というか、「Active Recordはテーブルのモデル」ではなく、
「データベースのテーブルに対応するモデル」を定義するインターフェイスなんだ・・・難しい。

インターフェイスって言葉なんかとても抽象的に感じてしまうのでやめてほしい。

Active Modelは、Active Recordとは独立して利用できます。

PORO とは

Active Recordのそうした機能の一部が抽象化されてActive Modelに移転しました。

Active Modelは、モデルのような機能を必要としているが、
データベース内のテーブルには関連付けないプレーンなRubyオブジェクト(PORO)で
利用できるさまざまなモジュールを含むライブラリです。

なるほど〜、「データベース内のテーブルには関連付かないプレーンなRubyオブジェクト」をPOROと呼ぶみたいですね。呼び方は「ポロ」であってるのかな?

「データベース内のテーブルには関連付かないプレーンなRubyオブジェクト」と書いてあり、
勘違いしていましたが、POROは本当にただのRubyのClassのことなんですね。

まさに、これだけ。

class Hoge

end

さて、肝心のattributeの使用を可能にしているモジュールは以下のようです。

Attributesモジュール

ActiveModel::Attributesモジュールを利用することで、データ型の定義、デフォルト値の設定、PORO(プレーンなRubyオブジェクト)のキャストやシリアライズの処理が可能になります。
これは、フォームデータで通常のオブジェクトの日付やブーリアン値などに対してActive Recordと同じような変換を行うときに便利です。

えっと、正直よく分かりません💦 データ型の定義?POROのキャスト???
一旦置いておきます。

Attributesを利用するには、以下のようにモデルクラスにモジュールをincludeしてから、attributeマクロで属性を定義します。

attributeで定義する方法を、attributeマクロと呼ぶみたいです。

マクロとは何か?

マクロとは、「クラスメソッドを完結に書けるもの」のようです。
例えば、has_many, belongs_to, has_one(ActiveRecordの関連)のようなものもマクロのようです。

class User < ApplicationRecord
  has_many :posts
  has_one :profile
end

では、今回のattributeマクロもどこかに定義(コードとして)があるはずなので、探してみます。
ActiveModel::Attributesモジュールをincludeしたら使える、ということは、その中にあるのかな・・・

https://github.com/rails/rails/blob/main/activemodel/lib/active_model/attribute.rb

それっぽいファイルを見てみましたが、ここには'attribute'という定義はなさそうです・・・
恐らくこれはAttibute moduleのコードなのかな?

コードで見る

次にそれっぽいファイルを見つけました、attributeは、activemodel/lib/active_model/attributes.rbにありそうです。

https://github.com/rails/rails/blob/main/activemodel/lib/active_model/attributes.rb

単純に定義から見ていきたいと思います。

activemodel/lib/active_model/attributes.rb
      ##
      # :call-seq: attribute(name, cast_type = nil, default: nil, **options)
      #
      # Defines a model attribute. In addition to the attribute name, a cast
      # type and default value may be specified, as well as any options
      # supported by the given cast type.
      #
      #   class Person
      #     include ActiveModel::Attributes
      #
      #     attribute :name, :string
      #     attribute :active, :boolean, default: true
      #   end
      #
      #   person = Person.new
      #   person.name = "Volmer"
      #
      #   person.name   # => "Volmer"
      #   person.active # => true
      def attribute(name, ...)
        super
        define_attribute_method(name)
      end

ちょっとコメントの最初の、:call-seqが何か気になってしまいます。
GPTに聞いてみたら、

つまり、RDoc によって生成されたドキュメントでは def 行ではなく、この :call-seq: の内容が優先的に使われるということです。

と教えてもらいました。え、RDocってものがあるの・・・?

Rdoc :call-seq

:call-seq:
ここにありました!
https://docs.ruby-lang.org/ja/latest/library/rdoc.html

デフォルトではメソッドの引数や yield の引数をパースして出力しますが、これを指定した次の行から次の空行までをメソッド呼び出し列と解釈し、出力をそこに書かれたように変更します。

なるほど、内容の上書きとして使うもののようですね。

さて、本体を見ていこうと思います。
まず、

def attribute(name, ...)

この、(name, ...)ってなんでしょう?
これは、ChatGPTの回答が良さそうだったので、そのまま貼り付けます🙇

可変引数移譲

(name, ...) という構文は、Ruby 2.7 以降で導入された構文で、... は「他のすべての引数をそのまま受け取る」という意味の 可変引数委譲(argument forwarding)です。

🔍 ... の意味:可変引数の委譲
✅ 具体的には:

def attribute(name, ...)
  super
end

このように書くと、

attribute メソッドは、name 引数だけ明示的に受け取り、
それ以外の すべての引数(位置引数、キーワード引数、ブロック)を super にそのまま渡す

という意味になります。


なるほど、このnameはデフォルトであるということなのかな?
どのModelにもnameがあるということ・・・?

いや、単純に引数の名前か😅

ではこのsuperを見なければならないですよね

attributeのsuper

こちらにありました。
https://github.com/rails/rails/blob/2994a6cd4a4c0bc017ec39d23a58be7fc52c9f79/activemodel/lib/active_model/attribute_registration.rb#L12-L21

今回は、勉強なので一行一行何をしているか丁寧にみて行こうと思います。

一行目

1行目
name = resolve_attribute_name(name)

resolve_attribute_nameは、このクラスのprivate methodにありました。

    def resolve_attribute_name(name)
      name.to_s
    end

流石に簡単です!引数を受け取り、String型に変換していますね。
それをnameに入れておくと。

この程度の処理でもこのようにprivate methodに分離することに、感動しました。

2行目

2行目
type = resolve_type_name(type, **options) if type.is_a?(Symbol)

まず、if type.is_a?(Symbol)とあるように、もしtypeがSymbolならこの処理をすると。
このtypeにあたるのは、:stringとかですよね。これがあったら何をするのか、以下を見ていきます。

    def resolve_type_name(name, **options)
      Type.lookup(name, **options)
    end

さらに、Type.lookupを探して見てみましょう。
https://github.com/rails/rails/blob/2994a6cd4a4c0bc017ec39d23a58be7fc52c9f79/activemodel/lib/active_model/type/registry.rb#L23-L31
うーん、難しい。ただ、エラーとregistrations[symbol]から見るに、symbolを受け取り、それに対応する型クラスを返していそうです。

でも全然わかりませんね。
registrations[symbol]としているので、registrationsを定義しているところを探してみます。

https://github.com/rails/rails/blob/36601bbb02bc3570f5609db2127c77afca575d6c/activemodel/lib/active_model/type.rb#L21-L55

おそらくこちらでしょう。
43行目 ~ 53行目で register(:big_integer, Type::BigInteger)とありますが、これはこのmoduleをrequireしたときに実行されるようです。

そしてregisterは何をしているのかもみます。
23行目で@registry = Registry.newとしているので、Registryクラスをみます。

https://github.com/rails/rails/blob/36601bbb02bc3570f5609db2127c77afca575d6c/activemodel/lib/active_model/type/registry.rb#L15-L21

blockが渡されていない時は、klassからインスタンスを生成するブロックを生成していますね。
引数のところ難しそうに見えますが、|_, *args|は、 最初の引数は無視し、残りの引数をすべて受け取るという意味です。
ここで、なぜ_としているかは、こちらのclaudeさんの説明が納得いきました。

_の理由

_ の謎を解く

呼び出しの流れを追ってみる
こんな風に呼び出します
user = lookup(:user, name: "田中", age: 30)

2. lookup内部では

registration.call(:user, name: "田中", age: 30)

3. procのパラメータに対応

proc { |_, **args| klass.new(**args) }
↑ ↑
| └─ {name: "田中", age: 30}
└─ :user (シンボル)
理由:最初の引数は不要だから
ruby# User.new を呼ぶときに必要なのは
User.new(name: "田中", age: 30)

これだけ。:user は不要

もし _ を使わないと

proc { |symbol, **args| klass.new(symbol, **args) }

User.new(:user, name: "田中", age: 30) # エラー!
:user は「レジストリから探すためのキー」であり、実際のオブジェクト作成には不要

registerは、require時に、型名のsymbolとTypeクラスを登録しておく、ということがわかりましたね。

で、lookupは以下のように、その登録されたproc[]を取り出し、callするような処理になっていると。

 def lookup(symbol, ...)
        registration = registrations[symbol]

        if registration
          registration.call(symbol, ...)
        else
          raise ArgumentError, "Unknown type #{symbol.inspect}"
        end
      end

では寄り道してしまったので戻りましょう。

Type.lookup(name, **options)は、上記の通り、そのsymbolに対応したTypeクラスをnewするprocをcallするメソッドでしたので、これのreturnとしては、

callされたので、インスタンスObjectでしょう。

3行目

type = hook_attribute_type(name, type) if type

でもし、そのtypeがあれば、hook_attribute_typeというのをしていますね。
定義を見てみます。

https://github.com/rails/rails/blob/36601bbb02bc3570f5609db2127c77afca575d6c/activemodel/lib/active_model/attribute_registration.rb#L109-L114

え何これー!!attibuteという引数を無視して、ただtypeを返しているw

コメントがあるので翻訳してみます。

他のモジュールがオーバーライドするためのhookです。
このattribute typeはそれが解決したらすぐに、型の装飾が適用される前に、このmethodに渡されます。

ちょっと何言っているか分からなかったので、すみません、ここもclaudeさんの説明がなるほど〜!となりましたのでおいておきます😭

フックパターン

対象コード

# Hook for other modules to override. The attribute type is passed
# through this method immediately after it is resolved, before any type
# decorations are applied.
def hook_attribute_type(attribute, type)
  type
end

フックパターンとは

他のモジュールが処理に割り込んで独自の処理を追加できる仕組み

フレームワークやライブラリが提供する「拡張ポイント」として機能します。

処理の流れ

1. 属性の型を解決 (type resolution)
      ↓
2. hook_attribute_type を通す ← 拡張ポイント!
      ↓  
3. 型の装飾を適用 (type decorations)

重要: フックは型解決の直後、型装飾の直前に実行される

基本的な実装

フレームワーク側(基本クラス)

class AttributeProcessor
  def process_attribute(attribute, raw_type)
    # 1. 型を解決
    resolved_type = resolve_type(raw_type)
    
    # 2. フックを通す(拡張ポイント)
    hooked_type = hook_attribute_type(attribute, resolved_type)
    
    # 3. 型の装飾を適用
    decorate_type(hooked_type)
  end
  
  # デフォルト実装:何もしない
  def hook_attribute_type(attribute, type)
    type  # そのまま返すだけ
  end
end

ユーザー側(オーバーライド例)

class ValidationModule < AttributeProcessor
  def hook_attribute_type(attribute, type)
    # バリデーション処理を追加
    case attribute.name
    when :email
      type.add_validation(:email_format)
    when :age
      type.add_validation(:positive_integer)
    end
    
    super  # 元の処理も実行
  end
end

フックパターンの利点

  • 拡張性: フレームワークを変更せずに機能追加
  • モジュール性: 各機能を独立したモジュールとして実装
  • テスト容易性: 各フックを個別にテスト可能
  • 保守性: 責務の分離、変更の影響範囲を限定

ベストプラクティス

必ず super を呼ぶ

def hook_attribute_type(attribute, type)
  # 独自処理
  my_custom_logic(attribute, type)
  
  # 必ず親クラスの処理も実行
  super
end

エラーハンドリング

def hook_attribute_type(attribute, type)
  begin
    enhanced_type = enhance_type(attribute, type)
    super(attribute, enhanced_type)
  rescue StandardError => e
    Rails.logger.error "Hook failed: #{e.message}"
    super  # フォールバック
  end
end

まとめ

フックパターンは:

  • 拡張ポイントを提供する設計パターン
  • フレームワークの柔軟性拡張性を実現
  • ユーザーが独自の処理を安全に追加可能
  • **「処理の流れに割り込む」**仕組み

コメントの意味: 「他のモジュールがオーバーライドして、型解決直後・型装飾直前に独自処理を追加できる拡張ポイントです」

まとめると、「他のモジュールがオーバーライドして、型解決直後・型装飾直前に独自処理を追加できる拡張ポイントです」とのことです。勉強になりました。こういう実装もあるのですね。

長くなってしまったので、次回はこの続きから・・・
うーん。読むのって大変だ・・・

Discussion