ADR-26 — ActiveRecord relationの型付け
ステータス: Accepted、2026-05-22 — implemented。
rigor-activerecordにおけるActiveRecord::Relationを返す呼び出しサイト(has_manyアクセサ、Model.where、scope)の型付けの設計を記録する — 最初の実装試行がプロジェクトの偽陽性に関する規律をリグレッションさせ、リバートされた後のものである。この決定は4部構成の設計であり、エンジン側の変更はプラグインが宣言する「オープン」なレシーバークラスに対する狭いcall.undefined-methodの免除のみである。5つの実装スライス(slice)すべてが投入され、Mastodonのapp/models(237モデル)に対してリレーションへのscope呼び出しの偽陽性ゼロで再検証された。
コンテキスト
Section titled “コンテキスト”rigor-activerecordは、クラス側のファインダー(Model.find → Nominal[Model]、find_by → Nominal[Model] | nil)、単数アソシエーション(post.user → Nominal[User])、インスタンス側のカラムアクセサ(user.name → Nominal[String])を型付けする。ひとつの大きなサーフェス(surface)がまだ型付けされていない:ActiveRecord::Relationを返すすべての呼び出し — has_many / has_and_belongs_to_manyアクセサ(user.posts)、Model.where(...) / Model.all / Model.order(...)、そしてユーザー宣言のscope(Post.published)である。これらの呼び出しサイトは、RBSによって消去されたuntypedエンベロープに縮退する:連鎖したクエリメソッド、ファインダー、そしてリレーションに対するブロックイテレーションは、要素型を持たない。
リバートされた最初の試行
Section titled “リバートされた最初の試行”最初の実装(コミット82dc9e0、c2b5d8fによってリバート)は、ジェネリックなActiveRecord::Relation[Elem] RBSをプラグインに同梱し(ADR-25のsignature_paths:経由)、リレーションの呼び出しサイトに対してNominal[ActiveRecord::Relation, [Nominal[Model]]]を提供した。
これは、プラグインを有効にした状態でMastodonのapp/models(237モデルファイル)に対してrigor checkを実行することで検証された。結果は偽陽性のリグレッションだった:20件のcall.undefined-method診断のうち17件が、型付けされたリレーションに対して呼び出されたユーザー定義のscopeだった — User.approved.confirmed、relation.with_domain、relation.by_rank、relation.newest_first、relation.unresolvedなどである。
根本原因は構造的なものである。ActiveRecord::Relationの実際のメソッドサーフェスは境界がない:リレーションは、そのモデルに宣言されたすべてのscope(scope DSL経由、concernのincluded do … endブロック内、または素のdef self.…クラスメソッドとして)に応答する。なぜなら、ActiveRecordは未知のリレーション呼び出しをモデルクラスに委譲するからである。静的なRBSではその集合を列挙できない。リレーションを閉じたNominal[ActiveRecord::Relation]として型付けすると、そのクラスがcall.undefined-methodルールにとって「既知」になるため、リレーションに対するすべてのscope呼び出し — 正統で、動作しているRailsコード — がエラーとして表面化する。それは動作しているコードを脅かすものであり、プロジェクトの第一の価値が禁じていることである。
インターセクションの調査
Section titled “インターセクションの調査”リレーションをIntersection[Relation[Model], untyped]として型付けすることが修正案として調査された。undefined-methodルール(Analysis::CheckRules)はconcrete_class_name(receiver)をキーにしており、これはUnion / Dynamic / Intersection / Top / Botに対してnilを返す — したがってIntersectionレシーバーは偽陽性を確かに抑制する。しかしメソッドディスパッチャーはIntersectionのメンバーをRBSティアを通じてフルディスパッチしない(ShapeDispatch#dispatch_intersectionはシェイプ(shape)キャリアのみを射影し、素のNominalメンバーには到達しない)。経験的に — 統合テストresolves the block element type through the relation end-to-endによって確認された — relation.each { |p| … }はその後pをモデルとして型付けすることに失敗し、連鎖したクエリメソッドは要素型を失う。Intersection単独では、偽陽性をすべてのリレーション精度の喪失と引き換えにし、この機能をRBSによって消去されたエンベロープと変わらないものにしてしまう。
正しいリレーション型付けは、次の4つすべてを満たさなければならない:
- G1 — 連鎖クエリの精度。
User.where(x).order(y).limit(n)はRelation[User]のままである。 - G2 — ファインダーの抽出。
relation.first→Model?、relation.find(id)→Model。 - G3 — ブロック要素の型付け。
user.posts.each { |p| … }はpをModelとして型付けする;これはカラムアクセサの型付けと合成され、user.posts.each { |p| p.title }はp.titleをカラムの値型に解決する。 - G4 — 偽陽性ゼロ。型付けされたリレーションに対するいかなるユーザー定義のscope / クラスメソッドの呼び出しも、
call.undefined-methodを生成してはならない(MUST NOT)。
G1〜G3は、リレーションレシーバーが、エンジンがディスパッチしブロックフォールドできる具体的なキャリア(carrier) — すなわち素のNominal — であることを要求する。G4は、undefined-methodルールがスキップするように、リレーションレシーバーが非具体的であることを要求する。単一のキャリアでは、両者は逆方向に引き合う;インターセクションの試行は、その衝突を型キャリアの上で解決し、エンジンの制限によってG1〜G3を失った。
衝突を型キャリアではなく診断レイヤーで解決する。リレーションは素のNominal[ActiveRecord::Relation, [Nominal[Model]]]のままである — したがってメソッドディスパッチとブロックフォールドは完全に通常どおりであり、完全に精密である(G1〜G3) — そしてcall.undefined-methodルールがリレーションクラスを免除するように教えられる(G4)。4つの部分からなる:
第1部 — エンジン:open_receivers免除
Section titled “第1部 — エンジン:open_receivers免除”クラスはオープンであると宣言できる — RBSで宣言されたメソッドサーフェスを超えて応答することが静的に既知である、という意味である。Analysis::CheckRulesのundefined_method_diagnosticは、クラスがオープンであるレシーバーをスキップする:既存のdiscovered_method? / rbs_class_known? / definition_available? / lookup_methodガードの後、build_undefined_method_diagnosticの前で、クラスがオープンならreturn nilする。
オープンなクラスの集合は、新しいオプショナルなopen_receivers: [String]プラグインマニフェストフィールド(完全修飾クラス名の配列)を通じてプラグインによって提供される。CheckRulesはプラグインレジストリ(scope.environment.plugin_registryとして到達可能であり、MethodDispatcher#plugin_owns_receiver?がすでに使っているのと同じハンドル)を参照し、ロード済みのいずれかのプラグインが列挙しているレシーバークラスをオープンとして扱う。
これがエンジン変更のすべてである。リレーション型は素のNominalのままなので、ディスパッチャー、RBSティア、ブロックフォールドは手つかずである — ディスパッチについて何も変わらないためG1〜G3は機能し、診断のチェックのみが免除される。
第2部 — プラグイン:同梱されるリレーションRBS
Section titled “第2部 — プラグイン:同梱されるリレーションRBS”rigor-activerecordはsig/active_record/relation.rbsを同梱する — Enumerable[Elem]をincludeし、クエリビルダー(selfを返す)、ファインダー(Elem / Elem?を返す)、集約、永続化、インスタンス化ビルダーのサーフェスを宣言する、ジェネリックなclass ActiveRecord::Relation[Elem]である。これはマニフェストのsignature_paths: ["sig"]を通じて提供される(ADR-25)。マニフェストはまたopen_receivers: ["ActiveRecord::Relation"]を宣言する。
このRBSは意図的に寛大である — 本当にRelationにあるがファイルから欠けているメソッドは、第1部の免除がなければ偽陽性になる;免除があれば、漏れは単に精度を犠牲にするだけである(呼び出しがuntypedに解決される)。このRBSはG1〜G3の精度のために存在するのであって、G4の健全性(soundness)のためではない。
第3部 — プラグイン:リレーション型付けされた提供
Section titled “第3部 — プラグイン:リレーション型付けされた提供”flow_contribution_forは次に対してNominal[ActiveRecord::Relation, [Nominal[Model]]]を提供する:
has_many/has_and_belongs_to_manyアソシエーションアクセサ;- クラス側の
Model.where/all/order/limit/none; - 名前がモデルの発見済み
scopeであるクラス側の呼び出し。
第4部 — プラグイン:リレーションへのscope呼び出しのインターセプト
Section titled “第4部 — プラグイン:リレーションへのscope呼び出しのインターセプト”すでに型付けされたリレーションに対するscope呼び出し(User.where(active: true).published)は、チェーンを通じてリレーション型を保たなければならない。flow_contribution_for — RBSティアより前に参照される — はNominal[ActiveRecord::Relation, [Nominal[Model]]]と型付けされたレシーバーを認識し、メソッド名がModelの発見済みscopeであるとき、再びRelation[Model]を提供する。scope以外のメソッドは辞退するため、RBSティアは引き続きwhere / each / firstを精密に解決する。
作業上の決定
Section titled “作業上の決定”-
WD1 — 免除メカニズムは専用の
open_receivers:マニフェストフィールドである。却下された案:(a)既存のowns_receivers:フィールドを拡張してundefined-methodも抑制する — 最小の変更だが、現在の意味が「プラグインがこのレシーバーのディスパッチルーティングを処理する」であるフィールドに、無関係な診断抑制の関心をオーバーロードしてしまう;(b)RBSクラスアノテーション(%a{rigor:v1:open})、または宣言されたmethod_missingからオープン性を推論する — 最も汎用的(プラグインなしで機能する)だが最大の変更であり、method_missing推論にはハザードがある:BasicObject#method_missingはコアRBSにあり、すべてのクラスの祖先を通じて解決されるため、素朴な「method_missingを宣言する ⇒ オープン」ルールは、BasicObjectのデフォルトを慎重に除外しない限りすべてのクラスを免除してしまう。専用フィールドは明示的であり、プラグインスコープを持ち(免除はリレーションRBSも同時にロードされているときちょうど有効である)、新しい文法を必要とせず、曖昧さを伴わない。 -
WD2 — リレーション型は素の
Nominalであり、決してIntersectionやDynamicではない。G1〜G3とG4の衝突は、型を弱めることによってではなく、診断を免除することによって解決される。非具体的なキャリアは、ディスパッチとブロックフォールドの精度を失わせることが示された。 -
WD3 — リレーションに対する
first/last/find_byはElem?を返す。これは誠実である — これらのメソッドは空のリレーションに対して本当にnilを返す。これは、リレーション型付けが新たにcall.possible-nil-receiverを発生させうる唯一の箇所である(ガードなしのrelation.first.fooに対して)。較正ポイント:Mastodonのapp/modelsの実行はすでに、237モデルにわたる既存のfind_by提供から5件のpossible-nil-receiverを示している — 低い割合である。リレーションのファインダーがそれを実質的に大きく押し上げるなら、カラムアクセサの先例(カラムアクセサの作業におけるWD-non-nullable)に照らして再考する。それはnilの氾濫よりも寛容さを選んだものである。v1の決定:Elem?を維持する;実測されたエビデンスに基づいて見直す。 -
WD4 — scopeのインターセプトは
scopeDSLのみをカバーする。concernのincluded do … endブロック内で宣言されたscope、およびscopeとして使われるクラスメソッド(def self.recent)は発見されないため、それらを通じたチェーンはその呼び出しの後でリレーション型を失う。これは精度のギャップであって偽陽性ではない — 第1部の免除はいずれにせよG4を無傷に保つ。concern内scopeの発見とdef self.…-as-scopeの発見は、需要駆動のフォローアップである。 -
WD5 — 同梱RBSの衝突はADR-25のフェイラーメモを通じて縮退する。自身のActiveRecord RBSを供給する(たとえば
rbs collection経由)プロジェクトは、同梱のActiveRecord::Relationと衝突する;その衝突は既存のプラグインRBSフェイラーメモ(ADR-25 WD4)によって処理される — より豊かな上流の定義が勝ち、open_receiversは依然としてそのクラスを免除する。 -
WD6 —
open_receiversは1.0以前のプラグイン契約(contract)に加法的である。新しいオプショナルなマニフェストフィールドであり、既存のプラグインは何も壊れない;v0.1.x / v0.2.x内で安全である。
却下された代替案
Section titled “却下された代替案”-
Array[Model]。リレーションを配列として型付けすると、連鎖した.where/.orderがすべて偽のcall.undefined-methodになる(それらのメソッドはArrayにはない)。G4に違反する。 -
Dynamic[Relation[Model]]。偽陽性を抑制する(Dynamicレシーバーは非具体的)が、G1〜G3を失う —DynamicレシーバーはDynamicにディスパッチする。経験的に型付けなしと等価である。 -
Intersection[Relation[Model], untyped]。偽陽性を抑制するが、ディスパッチャーはIntersectionのメンバーをRBSを通じてフルディスパッチしないため、ブロック要素の型付けと連鎖クエリの精度が失われる(G1〜G3)。経験的に検証済み。 -
Intersectionのメンバーをすべてのディスパッチティア(およびブロックフォールド)を通じてフルディスパッチするためのエンジン変更。これはインターセクションアプローチを機能させるものであり、擁護できる汎用的な改善であるが、ディスパッチャーとブロックフォールドに触れ、第1部の免除より実質的に大きい。open_receivers免除は、厳密により小さく、よりよく封じ込められた変更でG1〜G4を達成する;Intersectionディスパッチの一般化は、独立した将来の改善として残される。
実装のスライス
Section titled “実装のスライス”-
エンジン —
open_receivers。オプショナルなopen_receivers:フィールドをPlugin::Manifestに追加する(owns_receiversを踏襲したバリデーション付き);Plugin::Registry#open_receiver?ヘルパーを追加する;Analysis::CheckRules#undefined_method_diagnosticがそれを参照し、オープンなレシーバーをスキップする。spec:マニフェストフィールドの受理 / デフォルト / バリデーション /to_h;オープンなクラスが免除され非オープンなクラスは依然としてフラグされることを証明するCheckRulesの例。 -
プラグイン — 同梱RBS。
sig/active_record/relation.rbsとマニフェストのsignature_paths: ["sig"]+open_receivers: ["ActiveRecord::Relation"]を再投入する;sig/**/*.rbsをgemspecのfilesに追加する。 -
プラグイン — リレーション提供。
where/all/order/limit/none、has_many/has_and_belongs_to_manyアクセサ、クラス側のscopeに対するflow_contribution_forのリレーション型付け提供を再投入する。 -
プラグイン — リレーションへのscope呼び出しのインターセプト。
flow_contribution_forがNominal[ActiveRecord::Relation, [Model]]レシーバーを認識し、発見済みscopeメソッドに対してリレーション型を再提供する。 -
Mastodonの
app/modelsに対する再検証。リレーションへのscope呼び出しの偽陽性がゼロであること、およびブロック要素の精度が存在すること(user.posts.each { |p| p.<column> }が解決される)を確認する。その実行を既存のサーベイノートと並べて記録する。
このADRによってスケジュールされるスライスはない。
- コミット
82dc9e0(リバートされた最初の試行)とc2b5d8f(リバート。そのメッセージに検証で得られた発見とインターセクションの調査が記録されている)。 - ADR-25 — 第2部が依拠する、プラグインによるRBS提供のメカニズム。
- ADR-10 § 5a —
owns_receiversマニフェストフィールドとplugin_owns_receiver?。WD1の却下案(a)の先例であり、第1部が再利用するレジストリハンドル。 - ADR-5 — 第1部の免除を健全にする寛容性の原則:境界のない動的なメソッドサーフェスを持つクラスは、寛容な(偽陰性を許容する)読み方に値する。
lib/rigor/analysis/check_rules.rbの#undefined_method_diagnostic— 第1部が修正するルール。plugins/rigor-activerecord/README.md§「Future direction」 — このADRが設計するリレーション型付けのトラック。
© 2026 TypedDuck. Licensed under CC BY-SA 4.0.