マクロ/DSL展開 — ライブラリ別調査
日付: 2026-05-15。2026-05-15にdry-rbトリオ(dry-types, dry-schema, dry-struct)を追加するためリビジョン。
ステータス: 調査メモであり、設計上のコミットメントではない。
ROADMAPのO2作業項目(「マクロテンプレート+ヒアドキュメントRuby展開」)の前提条件であり、 ADR-16(マクロ展開基盤)の根拠となる証拠。
このドキュメントはライブラリ別の調査結果のみである。集約された評価(rigorに対する決定の形)はADR-16にあり、本メモはその決定の入力となる。
各セクションは、対象サブシステムについて以下の5つの問いに答える。
- ユーザー向けのDSL表面(短いスニペット1つ)。
- 実装メカニズム — どのRubyメタプログラミングプリミティブがそれを担うか。
- 呼び出し箇所で何が生成されるか(メソッド名、アクセサ、コールバック)。
- 静的展開可能性 — 静的解析器はソースのみから生成された表面を復元できるか?
- もっとも近い類似物 — Lispの
defmacro、PHPStanのトレイトインライン展開、ランタイム登録、それ以外のいずれか。
ソースコードは/tmp/<lib>-research/内の浅いクローンで読んだ。これらのクローンはコミットされておらず、サブモジュールとしても参照されていない。引用箇所はクローンのファイルパスを指しており、将来の読者は再クローンして検証できる。
ActiveSupport::Concern(Rails 8-0-stable)
Section titled “ActiveSupport::Concern(Rails 8-0-stable)”-
DSL
module Mextend ActiveSupport::Concernincluded { scope :active, -> { where(active: true) } }class_methods dodef foo; endendendclass Host; include M; end -
メカニズム —
ConcernはModule#append_features/Module#prepend_featuresをオーバーライドする(activesupport/lib/active_support/concern.rb:129-153)。 最初のinclude時に次を行う: (a)キューに入った依存関係を実行する、(b)superを呼ぶ(本物のinclude)、(c)定義されていればbase.extend const_get(:ClassMethods)を実行(:137)、(d)base.class_eval(&@_included_block)を実行(:138)。class_methods(&blk)はconst_set+module_evalによってClassMethodsサブモジュールを遅延的に作成する(:209-215)。included(&blk)/prepended(&blk)はブロックを保存するだけである(:158-187)。 -
生成されるもの — ホストは
Mのインスタンスメソッドを得て、ホストのシングルトンクラスはM::ClassMethodsを得て、included do … endブロックはselfをホストとして実行される(したがってその中のDSL形式の任意の呼び出しはホストに着地する)。 推移的:@_dependencies内のすべてのモジュールがsuperの前に再帰的にインクルードされる(:135, :148)。 -
静的展開可能性 — 部分的に展開可能:
- 自明に展開可能 —
ClassMethodsのextendとインスタンスメソッドのincludeの半分。ウォーカーはinclude Mを「M::ClassMethodsをextend+MのdefをHostにミックスインする」と扱える。 - 再帰的に展開可能 —
@_dependenciesチェーンは純粋にレキシカルである(モジュール本体内のinclude OtherConcern)。 - ブロック展開はブロック本体に依存する。
included do … endは遅延class_evalである: ブロックASTを逐語的にインライン展開し、インクルードする側に再バインドする。他のrigorウォーカーがすでに理解しているDSL呼び出し(例:has_one_attached、has_many、scope)は、インクルードする側でそれらのウォーカーを再トリガーする。 - 困難なケース:
included do内でのRails.env/defined?(…)による分岐、補間されたヒアドキュメントのclass_eval、ランタイムに具体的なbaseに対して実行される4引数のincluded(base)本体。
- 自明に展開可能 —
-
もっとも近い類似物 — PHPStanのトレイトインライン展開。内容は静的なテキストでホストクラスに再バインドされる。Rustのderiveマクロと異なり、展開のターゲットはConcern自身ではなくインクルードする側である。展開はinclude箇所で発火し、concernの定義箇所では発火しない。
ActiveStorageのattachedマクロ(Rails 8-0-stable)
Section titled “ActiveStorageのattachedマクロ(Rails 8-0-stable)”-
DSL
class User < ApplicationRecordhas_one_attached :avatar, service: :s3, strict_loading: trueendclass Gallery < ApplicationRecordhas_many_attached :photosend -
メカニズム — 両者は
activestorage/lib/active_storage/attached/model.rbに存在し、ActiveSupport::Concernを拡張するモジュール(:10)内のclass_methods do … end(:54)を通じて公開される。アクセサのペアはgenerated_association_methodsに対するヒアドキュメントclass_evalによって生成される(has_one_attachedは:111-126、has_many_attachedは:213-230)。リフレクションの帳簿管理はadd_attachment_reflectionを通じて行われる(:146-154/:250-258)。サポート関連付け(has_one、has_many、scope、after_save、after_commit)は追加のインラインDSL呼び出しである。 -
生成されるもの
has_one_attached :avatarの場合(:photosの複数形サフィックス名についてはミラー):def avatarはActiveStorage::Attached::Oneを返す(:113-116)。def avatar=(attachable)(:118-125)。has_one :avatar_attachment(リーダー+ライター+ARリフレクション)。has_one :avatar_blob, through: :avatar_attachment。scope :with_attached_avatar(クラスレベルのリレーション)。after_save+after_commitコールバック。User.reflect_on_attachment(:avatar)はリフレクションを返す。has_many_attachedの場合: 同じ5名前パターンが複数形化される。 主要アクセサはActiveStorage::Attached::Manyを返す。
-
静的展開可能性 — 高度に展開可能:
- メソッド名はシンボルリテラル引数に対する純粋な文字列補間である。
:113, :115, :118, :128-131はすべて"#{name}"を使う。シンボルリテラルが可視ならば、生成されるすべての名前は計算可能である。既存のrigor-activestorageプラグインはすでにこれを活用している。 - 戻り値の型は安定しており、レキシカルに決定される。
- 条件付き生成は浅い —
if ActiveStorage.track_variantsはwith_attached_*の本体のみを分岐させ、表面のシグネチャは分岐させない。 - 困難なケース —
has_one_attached(some_method)のように非リテラルな名前。現在のプラグインは諦める。AST展開器も同じ制限を継承する。 - Concernとの合成 — ユーザー側の
extend ActiveSupport::Concernのincludedブロック内にhas_one_attachedが現れる場合、Concernウォーカーはまずそのブロックをインクルードする側に再ターゲットしなければならない。そうすればattachedマクロ展開器がそこで発火する。2つのパスはかみ合う。
- メソッド名はシンボルリテラル引数に対する純粋な文字列補間である。
-
もっとも近い類似物 — PHPStanのトレイトインライン展開。リテラルなシンボル/文字列引数によってパラメータ化された固定的なテキストテンプレートで、使用箇所で1回展開される。Lispの
defmacroと異なり、テンプレートは不透明なテキスト(ヒアドキュメント)なので、展開器は汎用のマクロ評価器ではなくマクロごとのテンプレートテーブルを必要とする。
-
DSL
class Jobinclude AASMaasm dostate :sleeping, initial: truestate :running, :cleaningevent :run dotransitions from: :sleeping, to: :runningendendend複数マシンバリアント:
aasm(:work_status) do … end(lib/aasm/aasm.rb:28-37)。 -
メカニズム —
include AASMはAASM::ClassMethodsで拡張し、ステートマシンスロットを登録する(lib/aasm/aasm.rb:8-17)。 クラスレベルのaasm(*args, &block)はstate_machine_nameをキーとしてAASM::Baseを構築する(あるいは再利用する)。そして@aasm[state_machine_name].instance_eval(&block)によってブロックを実行する(:28-64)。 ブロック内ではstate/eventはAASM::Baseのインスタンスメソッドであり、剥き出しのマクロではない。state :pendingは状態を登録し、safely_define_methodによってホストクラスにpending?を注入する (lib/aasm/base.rb:90-108)。event :submit do … endはイベントを登録し、may_submit?、submit!、submit、submit_without_validation!を注入する (base.rb:111-143)。 -
生成されるものホストクラス上で:
- state
:fooごと(base.rb:99-106):foo?、定数STATE_FOO、そしてcreate_scopesと永続化アダプタがaasm_create_scopeに応答するならARスコープfooが生成される(persistence/base.rb:60-86)。 - event
:barごと(base.rb:120-141):may_bar?(*args)、bar!(*args, &blk)、bar(*args, &blk)、bar_without_validation!。namespace:を指定すると、追加のエイリアスmay_bar_NS?などが生成される。状態述語はNS_foo?となる。 - クラスレベル:
.aasm、.aasm(:name)はstates、events、state_machine、human_event_nameなどを公開するAASM::Baseを返す。
- state
-
静的展開可能性 — おおむね扱える:
- メソッド名は
state/eventに渡されるシンボルリテラルから決定論的に決まる。 - クラスごとに複数のステートマシンが
aasm(:column_name) do … endで存在しうる — それでもソース上は可視である。 namespace:オプションはオプション値を読む必要がある。値がリテラルのSymbol/Stringまたはtrueリテラル(aasm(:name) doからステートマシン名のシンボルを読む)の場合は復元できる。state :a, :bのアリティオーバーロード: Hashでない先頭引数すべてを状態名として扱う。- 継承: 親クラスの定義はサブクラスより前にリプレイされる必要がある(
aasm.rb:21-25)。 - 真に動的かつまれなもの: 状態名が非リテラル、
Proc値のinitial_state、ランタイムのrespond_to?(:aasm_create_scope)にゲートされたARスコープ生成。述語/イベントメソッド生成はアダプタチェックに依存しない。
- メソッド名は
-
もっとも近い類似物 — PHPStanのトレイトインライン展開/Lispマクロスタイルの展開。
plugins/rigor-statesman/がすでにstate_machine_class.state :fooを歩いているのと精神的に同一である。AASMはstatesmanと同じプラグインアプローチの射程内にある。
Devise
Section titled “Devise”-
DSL — 3つの箇所:
- モデル:
class User < ApplicationRecord; devise :database_authenticatable, :recoverable, …; end(lib/devise/models.rb:79)。 - ルーティング:
Rails.application.routes.draw内のdevise_for :users(lib/devise/rails/routes.rb:226)。 - コントローラー(暗黙的):
current_user、user_signed_in?、authenticate_user!、user_sessionがDevise::Controllers::Helpers.define_helpersにより合成される (lib/devise/controllers/helpers.rb:113)。
- モデル:
-
メカニズム — gemロード時の
Devise.add_module(:database_authenticatable, …)(lib/devise/modules.rb:9、Devise.with_options model: trueでラップ)。add_module(lib/devise.rb:397-440)はALLに追加し(:400)、STRATEGIES/CONTROLLERS/ROUTES/URL_HELPERSを登録し、model: trueのときDevise::Models::DatabaseAuthenticatableをオートロードする(:436)。そしてDevise::Mapping.add_module module_nameを呼ぶ(:439)。これは述語def #{m}?; modules.include?(:#{m}); endをclass_evalする (lib/devise/mapping.rb:113-119)。devise(*modules)(lib/devise/models.rb:79-112)はシンボルをDevise::ALL.index(s)でソートする(:83)。各mについてmod = Devise::Models.const_get(m.to_s.classify)を実行する(:91)。mod::ClassMethodsが存在すればextendする(:93-95)。マッチするavailable_configsセッターを適用する(:97-103)。include modを呼ぶ(:106)。devise_for :usersはDevise.add_mapping(:users, options)を呼ぶ (lib/devise/rails/routes.rb:242)→Devise::Mapping.new(:users, …)が@singular = :userで生成される(mapping.rb:56)→登録された各ヘルパーホストがdefine_helpers(mapping)を得る(lib/devise.rb:368)。define_helpersは<<-METHODSヒアドキュメントをDevise::Controllers::Helpersに対してclass_evalする(helpers.rb:116-134)。 -
生成されるもの —
Userに対するdevise :database_authenticatable, :recoverableの場合:include Devise::Models::Authenticatable(常に)+ Devise::Models::DatabaseAuthenticatable + Devise::Models::Recoverableに加えて、各モジュールのincluded do本体(例:attr_reader :password、after_update :send_email_changed_notification、lib/devise/models/database_authenticatable.rb:34-40)、インスタンスメソッド(password=、valid_password?、…)、ClassMethods(例:Recoverable.reset_password_by_token)。devise_for :usersの場合: すべてのコントローラーにmapping.nameによってパラメータ化された4つのメソッドが着地する:authenticate_user!、user_signed_in?、current_user、user_session。 -
静的展開可能性 — 4つの正準な障害物すべてが現実である:
- シンボル→
String#classify経由の定数は機械的である(復元可能)。 class_eval <<-METHODS文字列は#{mapping}/#{m}を補間する — マッピング名がわかれば決定論的。Devise.mappingsはdevise_for :usersが実行されたときのみ追加される。ヘルパー名の集合を知るためにはconfig/routes.rbを嗅ぎ取る必要がある。- インクルード順序は
Devise::ALL.index(s)でソートされる — 安定しており、lib/devise/modules.rbをミラーするプラグインテーブルに焼き込める。 ActiveSupport::Concern.included doブロックはターゲットクラスでリプレイされなければならない。extend ClassMethodsはクラスレベルメソッドを追加する。send(:"#{config}=", value)はオプションからクラス状態をさらに変異させる。
- シンボル→
-
もっとも近い類似物 — PHPStanのトレイトインライン展開+同梱レジストリ。Lispマクロではない — 呼び出し箇所(
devise :database_authenticatable, …)はテーブル駆動のincludeシーケンスであり、その入力(シンボルリスト、Devise::ALLの順序)と出力(具体的なModule定数 +included doの副作用+ClassMethods拡張)はlib/devise/modules.rbをミラーするレジストリから静的に解決可能である。rigor-deviseプラグインは以下を必要とする: (1)その同梱レジストリ、(2)devise :a, :b, …のモデル側ウォーカー、(3)mapping.singularによってパラメータ化された4つのメソッド定義を合成するdevise_for :fooのルート側ウォーカー、(4)ユニオンリソースヘルパーの場合のためのdevise_groupウォーカー。マクロ入力がリテラルなシンボルである場合、Ruby実行は不要。ユーザーのイニシャライザからのサードパーティのDevise.add_module呼び出しは同梱レジストリの外にある — イニシャライザウォーカーまたは手動の拡張APIが必要。
GraphQL-Ruby
Section titled “GraphQL-Ruby”-
DSL
class Types::User < GraphQL::Schema::Objectfield :name, String, null: falsefield :display_name, String, null: false doargument :upcase, Boolean, required: falseenddef display_name(upcase: false); … endendclass Types::Status < GraphQL::Schema::Enumvalue "ACTIVE"value "DISABLED", value: :offend -
メカニズム —
HasFields#fieldは純粋なメタデータレコーダーである。Schema::Fieldを構築し(lib/graphql/schema/member/has_fields.rb:89)、own_fieldsに格納する(:124-135)。Field#initializeは@resolver_method = (resolver_method || name_s).to_symを記録する (lib/graphql/schema/field.rb:270)。field.rbにはdefine_method/class_eval/module_eval呼び出しは1つもない。解決は完全にランタイム動的である。
Field#resolveは次のように振る舞う:if obj.respond_to?(resolver_method) … obj.public_send(resolver_method, **ruby_kwargs)(field.rb:757-765)。これに該当しない場合はHashルックアップまたは@fallback_valueにフォールスルーする。 -
生成されるもの — graphql-rubyは
Types::Userにdefine_method :display_nameをしない。ユーザーが手動で定義する(ドキュメントはconflict_field_name_warning、has_fields.rb:318で警告する)。自動定義されるメソッドは次のみ:HasFields#global_id_field→define_method(field_name)(has_fields.rb:154)。Enum.generate_value_method→define_singleton_method(enum.rb:259)、value_methods(true)/value_method:によってオプトイン。InputObjectの引数リーダー(input_object.rb:304)。BuildFromDefinition(SDL→クラス)→owner.define_method(build_from_definition.rb:538)。
-
静的展開可能性 — 重い障害:
- 型表現は多態的なブラックボックスである。
Member::BuildType.parse_type(build_type.rb:12-97)はString(“User”、“[User]!”)、Array、Class、Module、LateBoundType、NonNull/Listラッパー、そしてProcを受け付ける(:75-76はprocを呼ぶ)。procと文字列定数化はスキーマを実行しない限り静的解決を打ち破る。 - インラインするためのメソッド発行はない。DSLは型にRubyメソッドを追加しない。追加するのはランタイムにしか存在しないGraphQLスキーマグラフである。
resolver:/mutation:は別のクラスにディスパッチをリルートする (has_fields.rb:62-65)。- コネクションラッピングは名前駆動の遅延バインディングである
(
field.rb:127、:124)。 - ブロック形式のフィールドは
Fieldインスタンス上でinstance_execされる(field.rb:380-388)。
- 型表現は多態的なブラックボックスである。
-
もっとも近い類似物 — Lispマクロでもなく、PHPStanトレイトでもない。 展開すべきRubyメソッドが存在しない — DSLはスキーマグラフレコーダーである。 フィールドリーダー/呼び出し箇所に有用な型を与えるために、rigorはスキーマ解決パスを必要とする(
Schema::Member走査を再実装するか、あるいは実際にスキーマをrequireしてSchema.typesを読む)。もっとも近い比較対象はGraphQL自身のスキーマロードフェーズである。純粋なASTレベルのマクロ展開は実行不能である。なぜならProcの遅延型とStringの定数化参照が第一級だからである。リゾルバメソッド自体は普通のRubyである。スキーマグラフが存在すれば、戻り値型のアサーションは@rbsオーバーライドとして表現できるが、どのメソッドがどの戻り値型を持つかの発見は完全なスキーマ評価であり、マクロ展開ではない。
factory_bot
Section titled “factory_bot”-
DSL
FactoryBot.define dofactory :user doname { "Alice" }sequence(:email) { |n| "user#{n}@example.com" }association :accounttrait :admin dorole { "admin" }endendfactory :admin_user, class: User, parent: :user dorole { "admin" }endendFactoryBot.create(:user, :admin, name: "Bob") # => UserFactoryBot.build(:user) # => UserFactoryBot.build_stubbed(:user) # => UserFactoryBot.attributes_for(:user) # => HashFactoryBot.create_list(:user, 3) # => Array[User] -
メカニズム —
FactoryBot.define { … }(lib/factory_bot/syntax/default.rb:6-8)はブロックをDSL.runに渡す。これは新しいDSL上でinstance_evalする(syntax/default.rb:36-38)。factory(name, opts, &block)(:15-26)はFactory.new(name, options)を構築し (factory.rb:9-18)、それをDefinitionProxyでラップし、プロキシ上でブロックをinstance_evalしてからInternal.register_factory経由で登録する (internal.rb:79-84)。属性行(name { "Alice" })はDefinitionProxy#method_missingに到達し(definition_proxy.rb:91-104)、__declare_attribute__にルーティングされる(:247-254)。FactoryBot.create / build / build_stubbed / attributes_forはSyntax::Methods内に静的に定義されていない — ロード時にdefine_methodによってインストールされる。Internal.register_default_strategies(internal.rb:99-105)はStrategySyntaxMethodRegistrarを呼ぶ。これはFactoryBot::Syntax::Methodsに対してdefine_methodをmodule_execする (strategy_syntax_method_registrar.rb:55-63)。_list/_pairバリアントは単数形をArray.new(amount) { … }でラップする(:35-52)。 -
生成されるもの — ユーザーのモデルクラスには何も生成されない。生成されるのは2つ:
- シンボルでキー付けされたレジストリ内のファクトリー定義。
Factory#build_classはclass_name.to_s.camelize.constantizeを介してターゲットクラスを遅延解決する。class:が指定されない場合はnameをデフォルトとする(factory.rb:24-32, 109-111)。 FactoryBot::Syntax::Methods上のトップレベルストラテジメソッド:build、create、attributes_for、build_stubbed、加えて_list/_pairバリアント — 合計12のデフォルトメソッド。戻り値の階梯:Strategy::Build#result/Create#result/Stub#result→build_classのインスタンス。Strategy::AttributesFor#result→Hash。_list→Array[T]、_pair→サイズ2のArray[T]。
- シンボルでキー付けされたレジストリ内のファクトリー定義。
-
静的展開可能性 —
create(:user)に型を付けるには3つの材料で十分:- ファクトリー名→モデルクラスのマップ。
FactoryBot.define { factory :foo[, class: Bar][, parent: :baz] … }ブロックを歩く。戻り値型の問いに対してブロック本体の評価は不要。class:はリテラル。指定がなければname.to_s.camelize.constantize。parent:はクラス継承を連鎖させる。 - ストラテジメソッド→戻り値の形のテーブル。ハードコード:
build/create/build_stubbed→モデルクラス、attributes_for→Hash、*_list→Array[T]、*_pair→サイズ2のArray[T]。 - トレイト名は戻り値型に無関係。呼び出し箇所での属性カバレッジをゲートするだけで、クラスはゲートしない。
ランタイムを必要とするもの:
FactoryBot.modify、defineの外での動的登録、ユーザー登録のカスタムストラテジ、インスタンス化セマンティクスを変えるto_create/initialize_withブロック(factory.rb:140-146)。 - ファクトリー名→モデルクラスのマップ。
-
もっとも近い類似物 — PHPStanスタイルの「リテラルなシンボル引数から戻り値型が計算される汎用メソッド」。Railsの
find_by_*ファミリーやPHPStanのDynamicMethodReturnTypeExtensionと同じ形である。Lisp/PHPStanトレイトの意味でのマクロ展開ではない — factory_botはUser上にメソッドを生成しないので、ユーザークラスの呼び出し箇所でインラインするものがない。モデルは「ブート時に1回のレジストリウォーク+ストラテジメソッドごとに1つの戻り値型ルール(最初のシンボル引数をキーとする)」である。 rigorはすでにplugins/rigor-factorybot/に正しいフックを持っている。マクロ機構なしで到達可能な拡張には、*_list/*_pairのラッピング、parent:チェーンの解決、aliases:登録、トレイト名のバリデーションが含まれる。
Sinatra
Section titled “Sinatra”-
DSL
# classicrequire 'sinatra'get '/hello' do"Hello #{params['name']}"end# modularclass MyApp < Sinatra::Baseget '/hello' do"Hello #{params['name']}"endend -
メカニズム —
get、put、post、delete、head、options、patch、link、unlinkはSinatra::Baseのクラスメソッドである (lib/sinatra/base.rb:1531-1553)。それぞれroute(verb, path, options, &block)(:1776-1782)に転送する。これはcompile!を呼ぶ(:1795-1813)。compile!の内部で、ブロックはgenerate_methodによって本物のメソッドに変換される(:1788-1793):def generate_method(method_name, &block)define_method(method_name, &block)method = instance_method(method_name)remove_method method_namemethodend合成名は
"#{verb} #{path}"、例えば"GET /hello"である。メソッドは定義されてすぐ削除される。UnboundMethodのみが保持され、後でリクエストごとに再バインドされる:unbound_method.bind(a).call(...)(:1808-1810)。classicモードのトップレベルのgetはSinatra::Delegatorであり、Sinatra::Applicationに転送する(:2101-2127、lib/sinatra/main.rb:52, 55でミックスインされる)。 -
生成されるもの —
get '/hello' do … endの場合: アプリクラスへの一時的なdefine_method("GET /hello", &block)が、UnboundMethodとしてキャプチャされ、その後remove_methodされる。unboundメソッドはラッパーprocの中に保存され、@routes['GET']に[pattern, conditions, wrapper]として追加される。ブロックはリクエストごとに新しいアプリインスタンスにバインドされて実行される。params、request、response、env、appはSinatra::Baseインスタンスのattr_accessorである(:978)。ヘルパー (erb、redirect、halt、session)は普通のSinatra::Baseのインスタンスメソッドである。 -
静的展開可能性 — 非常に扱いやすい。ブロック本体は手を加えていないRubyである。内部の
selfはアプリクラスのインスタンスである。機械的な展開:class X < Sinatra::Base内の任意の<verb>(path, opts = {}, &block)を、ブロック本体をインライン化しselfをXとして型付けした、X上の合成プライベートインスタンスメソッドとして扱う。Sinatra::Baseのインスタンスメソッド表面をスコープに注入する。classicモードのトップレベルでは、Sinatra::Delegatorを認識し、剥き出しのgetをSinatra::Application.getとして扱う必要もある。 -
もっとも近い類似物 — RubyのDSL動物園でもっともきれいなケース: ブロックがすでにメソッド本体である、バイトごとに。Lispマクロ(構文を書き換える)やPHPStanのトレイトインライン(ASTをコピーする)と異なり、Sinatraは書き換えを必要としない —
generate_methodは文字通りdefine_method(name, &block)である。 アナライザーが「このブロックはSinatra::Baseのインスタンスメソッドとして実行される」と受け入れれば、展開は不要である。
Sequel
Section titled “Sequel”-
DSL
class User < Sequel::Model # implicit table :users + DB schema lookupone_to_many :postsmany_to_one :accountmany_to_many :groupsendUser.plugin :timestamps, update_on_create: trueUser.plugin :validation_helpersuser = User[1]user.name # column accessor — synthesised from DB schemauser.posts # association accessoruser.add_post(p) -
メカニズム — ライブデータベース駆動のカラムアクセサ。
Sequel::Model.inheritedはsubclass.set_dataset(subclass.implicit_table_name)をパース時に呼ぶ (lib/sequel/model/base.rb:987)。set_datasetはその後@db_schema = get_db_schemaを呼ぶ(:634)。get_db_schemaは本物のスキーマパースクエリを発行し(:901、“if db.supports_schema_parsing?”)、結果のカラムをdef_column_accessor(*schema_hash.keys)に供給する(:918)。 各カラムはoverridable_methods_module.module_eval("def #{column}; self[:#{column}] end", …)によって発行される(:797-801)。識別子でないカラム名のための低速パスのブロックベースのフォールバックもある(def_bad_column_accessor、:772-785)。ActiveRecordとの違い。ARはアクセサを最初のアクセス時に
method_missing/define_attribute_methodsで遅延的に定義し、db/schema.rb(マイグレーション時に書かれるRubyソースファイル)にスキーマをキャッシュする。Sequelには素のSequelにディスクにコミットされる同等のスキーマアーティファクトはない — クラスロード時にDDLプローブを行い、def_column_accessorを同期的に実行する。 -
関連付けに対して生成されるもの(
lib/sequel/model/associations.rb:2238-2285):one_to_many :posts(またはmany_to_many):returns_array?がtrueなので、メソッド名テーブル(:2243-2249)はposts、posts_dataset、_add_post、add_post、_remove_post、remove_post、_remove_all_posts、remove_all_postsを生成する。:2243でのsingularize(opts[:name])による単数形化。many_to_one :account: 単数分岐(:2250-2253)は_account=/account=+accountとaccount_datasetを追加する。writableでない限りadd/remove/clearはない。
すべてのメソッドは
association_module_defによってインストールされる。これはmod.send(:define_method, name, &block); mod.send(:alias_method, name, name)を呼ぶ (:2198-2202)。受け取るモジュールはデフォルトでoverridable_methods_moduleである。 -
静的展開可能性:
- DBスキーマ依存。カラムアクセサはライブの
get_db_schema_array呼び出しの後にのみ存在する。静的アナライザーはユーザー供給のDDL、データベース発行のDESCRIBE、あるいはマイグレーションから推論されたソースのいずれかを消費しなければならない。素のSequelには正準な静的スキーマソースはない。 - プラグインのシーケンシングは順序依存かつ副作用的である。
Model.plugin(base.rb:496-520)はapplyを1回実行し、その後extend ClassMethods、include InstanceMethods、dataset_extend(DatasetMethods)を行い、呼び出しごとにconfigureを実行する。プラグインはapply/configureの内部からさらにdefine_method/class_evalを追加することがある。静的展開には、単純な名前→メソッドテーブルではなく、プラグインリプレイエンジンが必要。 - 関連付け名は動的でありうる。オプションがメソッドをリネームできる。
:methods_moduleはメソッドが着地する場所をオーバーライドする(:2192)。単数/複数形の名前はsingularizeから派生する。 - 計算された本体を持つ
class_eval文字列がdef_initialize_nil_instance_variables(base.rb:858-865)、def_model_dataset_method(:825-828)、Plugins.def_dataset_methods(plugins.rb:31-37)で使われている。これらを発行するにはSequelと同じロジックを実行する必要がある。 Plugins.def_sequel_methodは一意のメソッド名を作り出す (plugins.rb:73-75, 92-138) — 展開時のカウンタ状態は観察不能だが、メソッドはプライベートな呼び出し先のみ。
- DBスキーマ依存。カラムアクセサはライブの
-
もっとも近い類似物 — ハイブリッド: 関連付けレイヤーはPHPStanのトレイトスタイル+カラムレイヤーはスキーマソース依存+プラグインリプレイエンジン。
one_to_many/many_to_one/many_to_manyはトレイトインライン展開スタイル: 固定テーブルからの名前駆動メソッド合成、機械的に展開可能。- カラムアクセサは純粋なマクロではない — スキーマオラクルを必要とする(PHPStanのPDOリフレクション拡張、または
kphpの.sql取り込みにもっとも近い)。ADR-10スタイルのオプトイン推論は役に立たない。カラムはRubyの外、no-RBS依存の中ではなく、Rubyの外に存在するからである。 - プラグインシステムは「副作用的なapplyフックを持つトレイトインライン展開」にもっとも近い — モジュールは静的に既知だが、
apply/configureブロックはアナライザーがシンボリックに実行するか、宣言的にモデル化する必要のあるランタイムデータを運ぶ。
小さな「Sequel関連付け」プラグインは関連付けDSLをきれいに展開できる。 完全なSequelチェッカーには次が必要: (a)スキーマソースアダプタ — おそらく
Sequel::Database#schemaダンプ、ユーザーが指定するschema.rb相当物、あるいはマイグレーション走査 — そして(b)プラグインリプレイサブシステム。
Redmine(ERBで構築されたRuby)
Section titled “Redmine(ERBで構築されたRuby)”1. 具体的な箇所
Section titled “1. 具体的な箇所”| # | 箇所(path:line) | パターン |
|---|---|---|
| A | lib/redmine/views/labelled_form_builder.rb:25-33 | ヒアドキュメント+補間されるリテラルリスト(field_helpers - blocklist + %w(date_selec)) |
| B | lib/plugins/acts_as_event/lib/acts_as_event.rb:48-62 | ヒアドキュメント+リテラル%w(datetime title description author type) |
| C | app/models/setting.rb:333-350 | 設定名でパラメータ化されたヒアドキュメント。名前はconfig/settings.yml+Redmine::Plugin.allから来る |
| D | app/models/user.rb:30, 293-303 | eval('"' + f[:string] + '"')。f[:string]はUSER_FORMATS内のリテラル値 |
| E | app/models/webhook_payload.rb:71 | instance_eval(File.read(Rails.root.join(path)), path, 1) |
| F | lib/redmine/plugin.rb:69-77 | def_field — リテラルシンボルリストに対するブロック形式のclass_eval do … define_method |
| G | lib/redmine/nested_set/traversing.rb:23-28 | includerのスコープを再オープンするブロック形式のclass_eval(文字列ヒアドキュメントなし) |
代表的な例 — 箇所C:
def self.define_setting(name, options={}) available_settings[name.to_s] = options src = <<~END_SRC def self.#{name} self[:#{name}] end def self.#{name}? self[:#{name}].to_i > 0 end def self.#{name}=(value) self[:#{name}] = value end END_SRC class_eval src, __FILE__, __LINE__end# … later …load_available_settings # iterates YAML.load_file('config/settings.yml')load_plugin_settings # iterates Redmine::Plugin.all2. パターン分類
Section titled “2. パターン分類”| パターン | 箇所 | 説明 |
|---|---|---|
| (a)静的テキストヒアドキュメント、補間なし | なし — Redmineのすべてのヒアドキュメントは少なくともメソッド名を補間する | |
| (b)ソース可視リテラルを補間するヒアドキュメント | A, B | |
| (b′)ファイル駆動リテラルを補間するヒアドキュメント | C | |
(b″)リテラルシンボルリストのブロック形式class_eval+define_method | F | |
(b‴)ブロック形式class_eval(文字列なし)、includerを再オープン | G | |
| (c)外部ファイルからロードされたコードのeval | E | |
| (d)ランタイムデータと連結された文字列のeval | 厳密な意味では存在しない — Dのテンプレート文字列はソース可視リテラルであり、ランタイムキーによって選択されるだけ |
3. プラグインローダー
Section titled “3. プラグインローダー”Redmine::PluginLoader(lib/redmine/plugin_loader.rb:30-32)は
<plugin>/init.rbをloadでロードする。instance_evalではない — したがってプラグインのinit.rb内のselfはmainであり、requireされたスクリプトと同じ。
instance_evalはもう1段階深いところ、Redmine::Plugin.registerの内部に存在する
(lib/redmine/plugin.rb:93-95):
def self.register(id, &) p = new(id) p.instance_eval(&) …endRedmine::Plugin.register :foo do … endの内部では、selfは新しい
Redmine::Pluginインスタンスである。barewords(name 'X'、author 'Y'、
settings :default => {…})はdef_field生成のアクセサに到達する(箇所F)。
:381-385にある2番目のinstance_evalは
project_module name do … endの下にネストする — selfはプラグインインスタンスのまま、@project_moduleはブロック内のpermission呼び出しのためのサイドチャネルとしてセットされる。
4. 箇所ごとの静的展開可能性
Section titled “4. 箇所ごとの静的展開可能性”- A — 展開可能。ソース可視の
%w(...)集合演算。ただしfield_helpersはActionView::Helpers::FormBuilder.field_helpers(上流の定数)から来る。 そのリスト(Railsバージョンごとに安定)を解決し、約12個のdef text_field/email_field/…スタブを発行する。 - B — 完全に展開可能。5つのリテラルシンボル→5つの
def event_datetime / event_title / event_description / event_author / event_typeが同じ式を返す。 - C — アナライザーが
config/settings.yml(約70の設定キー)を読み、Redmine::Plugin.register :id do settings :default => {...} endブロックを歩く場合にのみ展開可能。 各nameは3つのクラスメソッド(name、name?、name=)を生成する。 形は静的に既知だが、キーの集合はYAMLとプラグインのinit.rbファイルを読む必要がある。これはユーザーの 「activesupport-core-ext+プロジェクト側のmonkey-patch事前評価」メモが記述するケースである。 - D — 見かけ上扱えないが、実際は(b′)である:
USER_FORMATSはソース可視の定数で、その:string/:initials値は静的なテンプレート文字列である。マクロ展開器はeval('"' + f[:string] + '"')を9個のテンプレートに渡るStringの∨に変えられる。その特例なしでは、Dynamic[String]を発行する。 - E — パターン(c)。設定されたパスでwebhook payloadの
.rbテンプレートを読む必要がある。selfは呼び出し側によって@ivarが注入されたWebhookPayloadインスタンスである。アナライザーがpayloadテンプレートディレクトリを追加のソースルートとして扱う場合、扱える(PHPStanのスタブファイルパターン)。 - F — リテラルシンボル引数でのブロック形式
class_eval+define_method。自明に展開可能: 各名前にgetter/setterのペアを発行する(セマンティクスはattr_accessorと異なる)。 - G — includerのスコープを再オープンするブロック形式
class_eval。文字列evalではない。標準のincludedフック。
5. もっとも近い類似物
Section titled “5. もっとも近い類似物”- A, B, F, G→ソース可視のリテラルリストに対するLispの
defmacro。展開器にとって無料の勝利。 - C→PHPStanのクラスごとの生成スタブ(レジストリをスキャンしメソッド宣言を発行する拡張)。展開器コードLOCあたりの最大のペイオフ: 約70+N(プラグイン)のアクセサトリプレットをカバーする。
- D→
USER_FORMATSがマクロテーブルとして認識されれば、これもdefmacro風。 - E→PHPStanのスタブファイルパターン(
instance_evalセマンティクス下で呼び出し箇所にペーストされたかのように外部の.rbをパースする)。
Redmineのどの箇所も真に扱えないパターン(d)に達していない。すべての
class_eval / eval箇所は、ソース可視リテラルか、1つの間接参照(YAML、プラグインレジストリ、兄弟定数)で到達可能な値を補間する。既存のメモにあるマクロテンプレート+ヒアドキュメントRubyの方向性を裏付ける: 「リテラルリスト反復ヒアドキュメント」+「リテラルキーHashテーブルeval」+「宣言されたselfコンテキストを持つ外部Rubyファイルeval」を扱うRedmine向け展開器は、コードベース内のメタプログラム化されたメソッドのほぼすべてを回収する。
dry-types
Section titled “dry-types”-
DSL
module Typesinclude Dry.Types() # imports Types::String, Types::Integer, ...endStrippedString = Dry::Types['string'].constructor { |s| s.strip }Email = Dry::Types['string'].constrained(format: /@/)NilableInt = Dry::Types['integer'].optional # Sum of nil | integer -
メカニズム —
Dry.Types(...)は新しいDry::Types::Moduleインスタンスを返す(lib/dry/types.rb:252-254)。Dry::Types::Module < ::Moduleなので、includeは普通のRubyのincludeである。Module#initialize(lib/dry/types/module.rb:20-37)はDry::Types.containerのフラットレジストリキー("strict.string"、"coercible.hash"、…)をregistry_treeによってネストした定数ツリーに投影する (module.rb:74-84)。その後ツリーを歩いてdefine_constantsが各リーフに対してmod.const_set(name, value)を呼ぶ (module.rb:109-124)。レジストリはlib/dry/types/core.rbによってgemロード時に1回追加される: 4つのループがALL_PRIMITIVES/KERNEL_COERCIBLE/METHOD_COERCIBLE/NON_NIL(core.rb:6-46のリテラル凍結Hash)を反復し、register("nominal.string", …)などを呼ぶ(core.rb:49-99)。Dry::Types[name](lib/dry/types.rb:114-141)は呼び出し箇所でパースされる1回の再帰を持つランタイムルックアップである:"array<string>"文字列はTYPE_SPEC_REGEXで分割され、container[type_id].of(self[member_id])を介して再ルーティングされる。合成演算子はDry::Types::Builderに存在する(lib/dry/types/builder.rb):|→Sum(:28)、&→Intersection(:37)、>→Implication(:46)、.optional→nil | self(:53-62)、.constrained→Constrained.new(:71-73)。メソッド呼び出しの強制変換関数自体はlib/dry/types/constructor/function.rb:63-75内のmodule_eval(<<~RUBY, …)で構築される — リテラルな強制変換メソッド名を補間するヒアドキュメント。 -
生成されるもの — デフォルトの
:strictでのinclude Dry.Types()の場合:- includer上の定数:
String、Integer、Float、Decimal、Array、Hash、Symbol、Nil、Class、True、False、Bool、Date、DateTime、Time、Range、Any。 それぞれDry::Types::Constrainedキャリアである(core.rb:55-59)。 - レジストリのドット区切りプレフィックスをミラーする名前空間サブモジュール:
Strict::Integer、Nominal::Integer、Coercible::Integer、Optional::Strict::Integer、Params::Integer、JSON::Integer、 など —container.keysの正確な投影(module.rb:74-84)。 BuilderMethodsがincluderにextendされる(module.rb:25+builder_methods.rb:11-14): モジュールメソッドArray(type)、Hash(type_map)、Instance(klass)、Strict(klass)、Value(v)、Constant(v)、Constructor(klass, &)、Nominal(klass)、Map(k, v)、Interface(*methods)。- ユーザークラス上のインスタンスメソッドはない。すべては定数形状のデータである。
- includer上の定数:
-
静的展開可能性 — 扱える:
- 定数集合はレジストリのリテラルキーリストの純粋関数であり、レジストリの内容は
core.rbの凍結Hashからgemロード時に計算される。プラグインはそれらのHashをミラーする同梱レジストリを出荷できる(rigor-deviseがlib/devise/modules.rbをミラーするのと同じ形)。 - 各定数は既知のキャリア形状にバインドされる
(
Types::Integerの場合はConstrained<Nominal<Integer>>など)。 Dry.Types(:strict, default: :coercible)のチェリーピッキングとDry.Types(coercible: :Kernel)のエイリアスkwargsはAST可視である。Dry::Types["integer"]は動的戻り値型拡張にフィットする (ADR-2、factory_botの形) — 基盤ではない。Dry::Types[String](Class引数)とDry::Types["array<string>"](正規表現パースされたネスト形式)は追加のリゾルバルールを必要とする。- エッジケース:
.constructor { |x| … }は出力型が本体に依存するProcを受け付ける — フォールバックはDynamic[T]。 .optional/|/&/>/.constrainedはキャリアを代数的に合成する — 静的評価器は格子代数を実装しなければならない(すでにrigorのロードマップ上)。.define_builder(:or_nil)(lib/dry/types.rb:196-200)はBuilder.define_method(method, …)をランタイムに行う — ソース可視のリテラルシンボル、追跡可能。
- 定数集合はレジストリのリテラルキーリストの純粋関数であり、レジストリの内容は
-
もっとも近い類似物 —
Dry::Types[...]に対するPHPStanのDynamicMethodReturnTypeExtension(レジストリ+シンボル→型ルックアップ)、加えてDry.Types()定数のためのTier C風合成定数発行。後者はヒアドキュメントテンプレートではない(includer上でclass_eval文字列は実行されない —const_setが直接使われる)が、プラグイン作者の視点からは形は同一である: 名前の固定レジストリが固定キャリア形状にマップする。(constant_name, namespace, primitive, carrier_shape)の行を列挙するTier-C-as-const_set宣言がそれを捉える。
dry-schema
Section titled “dry-schema”-
DSL
UserSchema = Dry::Schema.Params dorequired(:email).filled(:string)required(:age).value(:integer, gt?: 0)optional(:tags).value(:array).each(:string)endUserSchema.(email: "a@b", age: 21) # => Dry::Schema::Result -
メカニズム —
Dry::Schema.Params(&)はdefine(processor_type: Params, &)である(lib/dry/schema.rb:86-89)。define(...)は文字通りDSL.new(...).callである(lib/dry/schema.rb:67-69)。DSL.new(lib/dry/schema/dsl.rb:81-86)はsuperを呼び (Dry::Initializer)、その後dsl.instance_eval(&)を呼ぶ。ブロックはDry::Schema::DSLインスタンス上のインスタンスメソッドとして実行される —required/optional/key/before/after/arrayはDSLの本物のインスタンスメソッドである(dsl.rb:144-188)。required(:email)→key(:email, macro: Macros::Required)(dsl.rb:144-146)はMacros::Requiredを構築し、set_typeで:emailのプレースホルダー型としてTypes::Anyを保存し (dsl.rb:176, 316-321)、マクロを@macrosにプッシュする (dsl.rb:186-187)。.filled(:string)はMacros::DSL#filled(lib/dry/schema/macros/dsl.rb:80-86)である。これはextract_type_spec(args)(macros/dsl.rb:222-257)→schema_dsl.resolve_type(:string)(dsl.rb:339-346)→type_registry[:string]→TypeRegistry#[](type_registry.rb:37-41) →Dry::Types["strict.string"]を呼ぶ。解決された型はschema_dsl.set_type(name, resolved_type)で記録され、Macros::Filledがappend_macroで追加される (macros/dsl.rb:205-216)。dsl.call(dsl.rb:195-207)は終端ビルダーである:parents.stepsを収集し、key_validator、key_coercer、value_coercer、rule_applierを構築し、processor_type.new(schema_dsl: self, steps: result_steps)を生成する。返されるオブジェクトがスキーマである —#call(input)を持つDry::Schema::Processorインスタンス(processor.rb:77-80)。Macros::Value#method_missing(macros/value.rb:115-121)は?で終わる任意のシンボル(例:min_size?)をキャッチしてtraceに転送する — 述語はユーザーソース内の剥き出しのbareword symbolである。 -
生成されるもの — ユーザーのクラス上には
define_methodされたものは何もない。UserSchemaはDry::Schema::Paramsインスタンスへのローカルバインディングである。最終的な成果物:@steps、@schema_dsl、内部key_map、key_coercer、value_coercer、rule_applierを保持するDry::Schema::Processorインスタンス(processor.rb:15)。schema.(input)として呼び出し可能で、Dry::Schema::Resultを返す。@typesから派生した[:email, :age, :tags]を列挙するKeyMap(dsl.rb:408-417)。- キーごとの型割り当てを運ぶ
Dry::Types::Schema(strict_type_schema、dsl.rb:295-297)。 - クラス形式(
class UserSchema < Dry::Schema::Params; define do … end; end)は@definitionをクラスに保存し(processor.rb:46-50)、UserSchema.newは構築されたプロセッサを返す(processor.rb:57-67)。
-
静的展開可能性 — 混合:
- 述語名はリテラルなSymbolである。
required(:email).filled(:string)は完全にリテラルである。:stringはTypeRegistry→Dry::Types[…]を介して解決される。レジストリ名前空間(defineの場合は:strict、Paramsの場合は:params、JSONの場合は:json)はDry::Schema上の呼び出し箇所のメソッド名によって決定される。すべてソース可視。 - ブロックの戻り値は
Dry::Schema::Processorである(あるいはそのサブクラス)。内容に関わらず — 自明に型付け可能。 schema.(input)はDry::Schema::Resultを返す。その#to_hの形はキーマップである。キー集合は静的にrequired(:key)/optional(:key)の位置から抽出可能。キーごとの型は対応する.value(:type)/.filled(:type)/.maybe(:type)引数から。 これはrigor-factorybotが定義側で使っている「ASTレコーダー」と同じ形である。- ブロックは
DSLインスタンス上でinstance_evalされる (dsl.rb:83)。基盤のTier Aが適用される:self : Dry::Schema::DSLとbareword表面 (required、optional、key、before、after、array)を宣言する。 .filled { … }/.value { array? | str? }はDry::Logic::Operatorsとmethod_missingを使う (macros/value.rb:115-121)。ブロック本体は再度、新しいマクロCore上でinstance_execされる — Tier Aのネスト。- 真に動的なもの:
required(:name).value(SomeCustomType)であって、SomeCustomTypeがDry::Types::Typeインスタンスの場合(同じresolve_typeパス)、array(:string).filled(min_size?: 2)の述語演算子チェーン、ブロック内のDry::Logic述語合成(array? | str?)は演算子オーバーロード追跡を必要とする。
- 述語名はリテラルなSymbolである。
-
もっとも近い類似物 — ハイブリッド: ルールトレースレベルでのスキーマグラフレコーダー(graphql-ruby風)+
Dry::Schema.Params { … }.call(input)に対するPHPStanの動的戻り値型拡張。graphql-rubyと異なり、ブロック内容自体はキー集合+キーごと型テーブルに静的に展開可能である。rigor-dry-schemaプラグインは次を合成できる: 「スキーマプロセッサの#call(input)はResult[T]を返し、その#to_hは形{email: String, age: Integer, …}を持つ」。これはTier A(ブロックをDry::Schema::DSL上のinstance_evalとして宣言)プラス、ブロック内のrequired/optional/filled/value/maybe/each/arrayを歩いてキーマップを構築するカスタム呼び出しレコーダーを必要とする。ユーザーのクラスはどのtierもインライン展開しない — 価値はスキーマ呼び出しの戻り値の形を型付けすることにある。
dry-struct
Section titled “dry-struct”-
DSL
class Address < Dry::Structattribute :city, Types::Stringattribute :country, Types::String.optionalattribute? :postcode, Types::String # omittableattribute :details do # nested structattribute :building, Types::Stringendenda = Address.new(city: "Tokyo", country: "JP",details: { building: "X" })a.city # "Tokyo"a.details.building # "X"a[:city] # "Tokyo"a.to_h # {city: "Tokyo", country: "JP",# details: {building: "X"}} -
メカニズム —
Dry::StructはClassInterfaceを拡張する (lib/dry/struct.rb:87)。継承するクラスはattribute/attribute?/attributes/transform_types/transform_keysをクラスメソッドとして継承する。attribute(:city, Types::String)(lib/dry/struct/class_interface.rb:86-88)はattributes(city: build_type(:city, Types::String))に委譲する。attributes(new_schema)(class_interface.rb:173-189)は3つのことを行う: (a)スキーマ更新 —schema schema.schema(new_schema)は新しいキー/型ペアで補強された新しいスキーマでクラスレベルのschema(Dry::Types::Hash::Schema)を置き換える(:177)、(b)アクセサ合成 —define_accessors(keys)(:179→:452-464)、(c)継承の伝播 — 各サブクラスについて、再帰的にd.attributes(inherited_attrs)を呼ぶ(:183-186)。define_accessors(keys)はキーごとにgetterを発行する:class_eval(<<-RUBY, __FILE__, __LINE__ + 1)def #{key} # def city@attributes[#{key.inspect}] # @attributes[:city]endRUBY識別子の形をしていない名前については
define_method(key) { @attributes[key] }にフォールバックする(:468のvalid_method_name?正規表現チェック)。.new(city: "Tokyo", …)(class_interface.rb:239-258)はクラスレベルのschema.call_unsafe(attributes)で強制変換し、load(attributes)→allocate+initialize(attributes)を行う (:279-283)。ネストブロック形式(attribute :details do …)はStructBuilder#callを経由する (lib/dry/struct/struct_builder.rb:18-42)。これはClass.new(parent_struct)+class_exec(&block)+親クラスへのconst_set(:Details, new_type)を行う。 -
生成されるもの —
class Address < Dry::Struct; attribute :city, Types::String; endの場合:- クラスレベルの状態:
Address.schemaはDry::Types::Hash::Schemaであり、その:cityキーはTypes::Stringにマップする。Address.attribute_namesは[:city]を返す (class_interface.rb:357-359)。 - インスタンスメソッド
Address#city:class_evalヒアドキュメントによって合成される(class_interface.rb:455-459) —@attributes[:city]を返す。戻り値型は属性のTypes::Stringキャリアに従う⇒String。 - ライターなし —
Dry::Structはイミュータブルである。リーダーのみ存在する(コピー&オーバーライドのための#new(changeset)は:202-211にある)。 Address#[](key)とAddress#to_hはDry::Structから継承される(lib/dry/struct.rb:147-178)。形はクラススキーマから派生した{city: String}である。Address.new(city:)は型付きコンストラクタ (class_interface.rb:239)。attribute? :postcode, …の場合:required: falseの同じパス。アクセサ名は依然postcode(?は:174で取り除かれる)。- ネストブロック形式:
Address::Detailsはconst_setされ (struct_builder.rb:33)、それ自身がDry::Structサブクラスである。親はAddress::Detailsを返すdetailsリーダーを得る。
- クラスレベルの状態:
-
静的展開可能性 — 3つの中でもっともきれい:
- 属性名はリテラルなSymbolである。
class_interface.rb:455-459のclass_evalヒアドキュメントは#{key}と#{key.inspect}を補間する。ADR-16のTier Cに直接フィットする。 - 属性型は
Dry::Types::Typeインスタンス (例:Types::String)またはString("integer")であり、後者はDry::Types[...]にルーティングされる(class_interface.rb:430-441)。String形式はdry-schemaの:stringと同じレジストリルックアップである。 - 合成演算子(
Types::String.optional、Types::Integer | Types::Nil、Types::String.constrained(…))は dry-typesのBuilder代数に従う。attribute :x, Types::Integer.optionalに対するTier Cの発行は、リーダーがInteger | nilを返すことを知っている。 - ネストブロック形式
attribute :details do …は定数Address::Detailsを作り出し、その本体自体がさらにattribute呼び出しを含む — Tier Cは再帰しなければならない(あるいはTier Aのinstance-evalフレーミングをTier Cの発行と合成する)。定数名はInflector.camelize(attr_name)に従う(struct_builder.rb:64-73)。Symbolから決定論的。 attributes_from(SomeStruct)(:114-123)は別のstructのスキーマからキーをコピーする — ソースstruct自体が歩ける場合は復元可能。transform_keys(&:to_sym)/transform_types(:204-223) — 表面のメソッド名を変えないメタレベルの変更。アクセサ合成のために安全に無視できる。Dry.Struct(name: Types::String, age: Types::Integer)(lib/dry/struct.rb:30-35) — proc形式コンストラクタ。キーワード引数Hashは通常の呼び出し形でソース可視リテラルである。継承形式のattribute :…と同じTier Cの到達範囲。- エッジケース:
attribute :name, SomeCustomTypeであってSomeCustomTypeがプロジェクトローカル変数でTypeを保持する場合 — キャリアをバインドするためにフロー解析が必要(通常のrigor作業であり、基盤ではない)。
- 属性名はリテラルなSymbolである。
-
もっとも近い類似物 — ADR-16のTier C(リテラルシンボルパラメータでのヒアドキュメントテンプレート展開)、教科書通りの形。形はActiveStorageの
has_one_attached :avatarと同一である: クラスレベルのDSL呼び出しがリテラルなSymbol引数を列挙する。フレームワークはそのSymbolを補間するヒアドキュメントをclass_evalする。発行テーブルは固定である。dry-structのattribute :city, Tに対する発行テーブルは:合成物 戻り値 def cityインスタンスメソッドT#primitiveschemaキー:cityTto_hの行city: …T[:city]アクセスT.new(city: …)キーワード引数T(強制変換済み)ネストブロック形式はTier A+Tier Cの合成を追加する(ブロックは新しい
StructBuilderバインドのClass.new(parent)上でinstance_evalスタイルで実行される。外側の呼び出しは親クラス上に新しい定数をconst_setする — 定数名はSymbolから計算可能)。
合成(dry-types⇒dry-schema / dry-struct)
Section titled “合成(dry-types⇒dry-schema / dry-struct)”3つのgemは依存関係の順序dry-types→{dry-schema, dry-struct}で合成する。
- dry-schemaはdry-typesを必要とする(
lib/dry/schema.rb:8—require "dry/types")。ルール述語推論器は::Dry::Types::PredicateInferrerをサブクラス化する (lib/dry/schema/predicate_inferrer.rb:6)。スキーマモジュール自身のTypes名前空間はinclude ::Dry.Typesである (lib/dry/schema/types.rb:11)。TypeRegistryはDry::Typesの直接的なラッパーである(lib/dry/schema/type_registry.rb:21-29)。required(:x).filled(:string)内のすべての:string/:integer/:arrayはDry::Types["strict.string"](あるいはプロセッサ名前空間ごとに"params.string"/"json.string"—type_registry.rb:37-41)を介して解決される。 - dry-structはdry-typesを必要とする(
lib/dry/struct.rb:6)。Dry::Struct::ClassInterfaceはTypes::TypeとTypes::Builderをincludeする(class_interface.rb:11-12)。Dry::Struct.schema自体がDry::Types::Hash::Schemaである(struct.rb:115-116)。attribute :name, "integer"(String形式)はDry::Types["integer"]を介してルーティングされる(class_interface.rb:432-433)。 - dry-structはdry-schemaを必要としない。dry-schemaもdry-structを必要としない — dry-typesを共有する兄弟。
rigor-*プラグインのシーケンシングへの含意。最初に書かれるrigor-dry-typesプラグインは下流の両方のgemを部分的にアンロックするが、それらを包含しない:
rigor-dry-typesは次を出荷する: (a)core.rbのALL_PRIMITIVES / KERNEL_COERCIBLE / METHOD_COERCIBLE / NON_NIL Hashをミラーする同梱の名前レジストリ、(b)include Dry.Types(…)のためのTier C形式の定数発行、(c)Dry::Types[<literal>]のための動的戻り値型ルール、(d)|、&、>、.optional、.constrained、.constructorのためのキャリア代数処理。これがあれば、Dry::Types::Typeキャリアを生成する任意の式は、dry-schema、dry-struct、あるいは独立したコードのいずれに現れても、型付けされる。rigor-dry-schemaは依然自身のプラグインを必要とする:Dry::Schema.Params { … }内のスキーマグラフ記録ウォーク(Tier Aのスコープ注釈+required/optional/value/filled/maybe/each/arrayレコーダー)、加えて 記録されたキーマップからTが派生するProcessor#call(input) -> Result[T]の型付け。これはキーごとのキャリア解決のためにrigor-dry-typesを消費する(:string→Types["strict.string"])。rigor-dry-structは依然自身のプラグインを必要とする: 各attribute :name, T呼び出しのためのTier Cヒアドキュメント発行、加えてConst_nameサブクラスを作るネストブロック合成。これは属性ごとのTキャリアのためにrigor-dry-typesを消費する。
したがってrigor-dry-typesは他の2つの共有依存であり、gem依存グラフを1対1でミラーする。それなしでは、下流gem内の属性ごと/キーごとの型表現は
Dynamic[T]に劣化する。それがあれば、下流プラグインは薄いASTレコーダーである。
dry-rbファミリーはマクロ基盤への強い適合である:
dry-structはTier Cにきれいにスロットインする。dry-schemaはTier A+Tier A駆動のレコーダーにスロットインする。dry-typesは同梱定数レジストリ(Tier-C-as-method-definitionよりもTier-C-as-const_setに近い)+Dry::Types[…]のためのdynamic_return_type拡張としてもっともよくモデル化される。3つのどれにもGraphQL-Ruby形式の扱えなさや、Sequel-column-accessor形式のスキーマオラクル要件は現れない。
ライブラリ横断のサマリー
Section titled “ライブラリ横断のサマリー”静的展開/リプレイの扱いやすさの降順でソート。「shape」列はexpanderが扱う必要のあるメタプログラミングプリミティブを命名する。「rigor today」列は現在のプラグイン/処理状態を記録する。
| ライブラリ/サブシステム | 展開の形 | もっとも近い類似物 | rigor today |
|---|---|---|---|
| Sinatraルート | define_method(&block)によりブロック=メソッド | 「selfが型付けされれば展開不要」 | プラグインなし |
| ActiveStorage attached | リテラルシンボルでパラメータ化されたヒアドキュメント | PHPStanトレイトインライン展開 | rigor-activestorageがすでに展開 |
dry-struct attribute :name, T | リテラルシンボルでパラメータ化されたヒアドキュメント | PHPStanトレイトインライン展開(教科書通りのTier C) | プラグインなし |
| AASM | DSLブロック+リテラルシンボルstate / event | トレイトインライン展開/statesmanの形 | プラグインなし(statesmanが先例) |
| Redmine A/B/F/G | ブロック形式class_eval+リテラルシンボルリストdefine_method | Lispのdefmacro | 処理なし |
| Redmine C | YAML/プラグインレジストリの名前でパラメータ化されたヒアドキュメント | PHPStanのクラスごとスタブ(YAMLリーダーが必要) | 処理なし |
dry-types include Dry.Types() | 同梱名前レジストリ+const_set発行 | トレイトインライン展開(Tier-C-as-const_set)+Dry::Types[…]の動的戻り値型 | プラグインなし |
| factory_bot | レジストリ+リテラルシンボル引数から計算される戻り値型 | PHPStanのDynamicMethodReturnTypeExtension | rigor-factorybot |
| Deviseモデル側 | 同梱レジストリ駆動のtrait includeシーケンス | PHPStanトレイトインライン展開+レジストリ | プラグインなし |
| Deviseルート/コントローラー | mapping.singularでパラメータ化されたヒアドキュメント | トレイトインライン展開(ルートウォーカーが必要) | プラグインなし |
| Sequelの関連付け | リテラルシンボルからの名前テーブル発行 | トレイトインライン展開(statesman風) | プラグインなし |
dry-schema Dry::Schema.Params do … end | ブロックinstance_eval+required / optional / 型仕様のリテラルシンボルレコーダー | Tier A+ASTレコーダー+Processor#callの動的戻り値型 | プラグインなし |
| ActiveSupport::Concern | ブロックの遅延class_eval、ターゲット=includer | トレイトインライン展開+DSL再ターゲット | 部分的(再ターゲット後に下流ウォーカーが発火) |
| Redmine E | instance_eval(File.read(path)) | PHPStanスタブファイルパターン | 処理なし |
| Redmine D | eval('"' + literal_template + '"') | defmacro+テンプレートテーブル | 処理なし |
| Sequelのカラムアクセサ | DBスキーマオラクルが必要 | PHPStanのPDOリフレクション拡張 | 処理なし |
| GraphQL-Ruby | スキーマグラフレコーダー、メソッド発行なし | スキーマ解決パス(マクロ展開ではない) | 延期(需要なし) |
持ち越される観察(まだ決定ではない)
Section titled “持ち越される観察(まだ決定ではない)”これらは合成が浮かび上がらせる観察である。ユーザーはこれらをどうするか指示する。
-
既存のプラグイン契約の範囲内にあるクリーンなPHPStanトレイトインライン展開のターゲットが2つすでにある: AASM(statesmanの形)とDeviseのモデル側(同梱レジストリ+モジュールinclude)。両方ともマクロ評価機構を導入せずに到達可能。
-
Sinatraは「ブロック→メソッド」展開器の最小実用ターゲットである。rigorが「このブロックはクラスX上のインスタンスメソッドとして実行される」という契約を受け入れれば、Sinatraは書き換えを必要としない。この形を共有する隣接DSL(RSpecのネストコンテキスト、factory_botの
evaluator、ActiveRecordのクラスレベルマクロ)も同じフックから到達可能になる。 -
Redmineは「マクロテンプレート+ヒアドキュメントRuby」の方向性を検証する。 箇所A、B、F、Gはリテラルリストに対する純粋な
defmacroである — ランタイム評価なしのASTレベル展開器で扱える。箇所Cはペイオフが最大だが、config/settings.yml+プラグインのinit.rbを読む必要がある。「プロジェクト側のmonkey-patch事前評価」メモに合う。箇所Eは宣言されたselfコンテキストを持つinstance_eval(File.read(...))である — PHPStanスタブファイルの形。Redmineにはランタイムデータでの真のパターン(d)の文字列evalは存在しない。 -
GraphQL-Rubyはマクロフレームに合わない唯一のライブラリである。 これはスキーマグラフレコーダーである — 型クラスレベルで展開すべきRubyメソッドが存在しない。
rigor-graphqlはSchema::Member走査を再実装する(あるいはリプレイする)スキーマ解決パスを必要とする。これは具体的なユーザー需要が現れるまでrigor-graphqlを延期するrigorの既存の立場に合致する。 -
Sequelは2つのレイヤーに分かれる: 関連付けはトレイトインライン展開スタイル(扱えるプラグイン)、カラムアクセサはスキーマオラクルを必要とする(スキーマソースなしには扱えない)。プラグインレイヤーは「副作用的なapplyフックを持つトレイトインライン展開」である。
-
ActiveSupport::Concernの
included do … endはほとんどのRails形式のDSLプラグインを接続する蝶番である。ブロックの内容をincluderのクラスに再ターゲットすることで、すべての下流DSLウォーカー(has_one_attached、has_many、scope、Concern経由のAASM、Concern経由のDevise、…)が正しいコンテキストで発火できる。マクロ評価は不要。単なるウォーカー再ターゲット。 -
dry-rbトリオ(dry-types、dry-schema、dry-struct)は基盤への強い適合である。 dry-structは教科書通りのTier C(リテラルSymbol
attribute :name, T→class_evalヒアドキュメント発行)である。dry-typesは同梱レジストリの形(Tier-C-as-const_set+Dry::Types[…]のためのdyn-return-typeルール)である。dry-schemaはTier A(Dry::Schema::DSL上のinstance_eval)+ブロックを歩いてkey → typeマップを構築するレコーダーである。それらはgem依存を介して1対1で合成する:rigor-dry-typesは他の2つのプラグインの共有依存であるが、それらを包含しない。rigor-dry-typesなしでは、下流の属性ごと/キーごとの型はDynamic[T]に劣化する。それがあれば、下流プラグインは薄いASTレコーダーである。ADR-12(dry-rbパッケージ決定)は3つのプラグインがどう出荷されるかを統治するのであり、それらが適用されるかどうかではない。
このメモにないもの
Section titled “このメモにないもの”マクロ評価設計議論に意図的に延期されている事項:
- rigorが汎用のマクロ評価器(Lispスタイル)、パターンごとのテンプレートテーブル(PHPStanトレイトスタイル)、あるいはその両方を備えるべきか。
- 展開がパース/推論/新しい「ASTリライト」フェーズのいずれで発火するか。
- マクロ衛生がrigorの
name_scopeリゾルバとどう相互作用するか。 - Redmine E形式の外部Rubyファイルに対する
instance_evalバインドのselfとname_scopeがどう相互作用するか。 - 同梱レジストリ(Deviseモジュール、AS core_ext)がプラグイン契約のどこに住むか。
- ADR-15 Ractor共有下でのマクロ展開ASTのキャッシュ挙動。
参照したクローン(/tmp/配下、コミットなし、サブモジュールなし):
/tmp/rails-research/(rails/railsの8-0-stable)/tmp/aasm-research//tmp/devise-research//tmp/graphql-ruby-research//tmp/factory_bot-research//tmp/sinatra-research//tmp/sequel-research//tmp/redmine-research//tmp/dry-types-research//tmp/dry-schema-research//tmp/dry-struct-research/
© 2026 TypedDuck. Licensed under CC BY-SA 4.0.