依存解決の仕組み: トポロジカルソート + alias 解決
この記事の対象読者: ApexBlueprint の 内部実装 が気になる方。「シンプルな API の裏側で何が起きているか」を、フェーズ単位の擬似コードと実装ファイル (
SOrchestrator.cls/SBlueprintAnalyzer.cls/SBlueprintRealizer.cls) の参照付きで追いたい開発者向け。設計判断の背景や哲学に焦点を当てた姉妹記事は Declarative Data Specification: なぜ blueprint 形式か を参照してください。
ApexBlueprint の API は of / set / template / alias / use / times / withChildren / parentIdField の わずか 8 種類 です。しかしこれだけで、親子のネスト、量産、兄弟参照、「自分の真の親」の階層的解決、複数 lookup の曖昧解消といった、結合テストデータでよく出会う困難をひと通り扱えます。
このギャップは、SOrchestrator の .create() 内部で 複数の独立した問題が一手に解かれている ことから生まれます。このページでは、利用者の視点からは見えない裏側で何が起きているのかを、フェーズごとに掘り下げます。
利用者の視点 vs 内部の処理
利用者の視点から見ると、.create() は「追加した全ての blueprint を、正しい順番でデータベースに挿入する」という 1 つの動作に見えます。
しかし内部ではこれを実現するために、4 つの独立した問題が解かれています:
| 問題 | 解決の中心 |
|---|---|
| 1. どんな順序で insert すれば lookup が解決するか | 依存解析 + トポロジカルソート |
| 2. 「親 1 の下の子 1」と「親 2 の下の子 1」をどう識別するか | alias 解決 + 自動 alias 払い出し |
3. .times(...) で量産されたレコードを階層的にどう関連付けるか | 親ごとの繰り返し + 親 Id 自動転記 |
4. {P0} / {P1} で「自分の真の親」をどう特定するか | 階層スタックの追跡 + 親参照解決 |
これらは普通バラバラに頭を悩ます類の問題ですが、ApexBlueprint はこれらを 同じパイプラインの中で連続的に解いて います。
内部処理のフェーズ
.create() を呼んだとき、内部では大きく分けて次の流れが進行します。
フェーズ 1: 設計図の収集
SOrchestrator.start().add(...).add(...) で渡された SBlueprint 群は、まず 追加順のまま SOrchestrator 内部のリストに保持されます。この時点では何の検証も実行もされません。
withChildren でネストされた子の blueprint は、親 blueprint の中で 木構造 として保持されています。つまりルート blueprint をたどれば、そこから下の子・孫・ひ孫がすべて取り出せる状態です。
フェーズ 2: 依存グラフの構築
.create() が呼ばれたタイミングで、SOrchestrator はまず 依存グラフを構築 します。各 blueprint をノードとして、次の関係を有向辺として登録します:
withChildrenの親子関係: 親 → 子 への辺 (子は親の Id に依存する).use(alias, ...)の兄弟参照: alias 元の blueprint → 自 blueprint への辺 (自分は alias 元の値に依存する).after(alias)の順序指定: 同じく alias 元 → 自 blueprint への辺。ただし 値を運ばない辺 で、順序の制約だけを表す{P0}/{P1}等の親参照: 該当する祖先 → 自 blueprint への辺 (構築段階で対応する祖先が特定される)
なお、alias の重複 や 存在しない alias の参照 の検出は、ここで構築された依存情報をもとに後段の フェーズ 5 (realize 時) に 遅延検出 されます。利用者から見れば「.create() 時に失敗する」種類のエラーに変わりはありませんが、実装上は「グラフ構築」と「整合性検証」が分離されている点を補足しておきます。
フェーズ 3: トポロジカルソート
依存グラフが完成したら、SOrchestrator は トポロジカルソート を実行して、依存される側 (親) から先に来るように blueprint の順序を並べ替えます。
- 依存関係に 循環 が見つかると、「
Circular or invalid reference detected」で失敗 - 一度ソートに成功すれば、後段の挿入処理は insert 順を考えなくてよい ことが保証される
利用者が .add(...) を「読みやすい順」で並べて書けるのは、ここで順序がリセットされるためです。
フェーズ 4: alias 解決と自動 alias 払い出し
ソート済みの blueprint を順番に処理する過程で、各 blueprint には alias が割り当てられます。
.alias(...)で明示された alias は そのまま使われる ({#}プレースホルダは展開後に確定).alias(...)を呼ばなかった blueprint には、自動 alias が割り当てられる
自動 alias のフォーマットは内部的に __{SObjectName}_{グローバルカウンター}_{#}__ の形 (例: __Account_0_1__ / __Contact_1_2__)。ここでの中段の数値は 階層深度ではなく blueprint 全体で振られるグローバルカウンター です (テストの assertion で見える __Contact_1_1__ の 1 は深度ではなく、解析の登場順で振られた序列)。階層構造はこの後説明する 親プレフィックス の方で表現されます。
withChildren でネストした blueprint には、さらに親の alias がプレフィックスされて __Account_0_1____Contact_1_1__ のような複合 alias が払われます。これにより「親 1 の下の子 1」と「親 2 の下の子 1」が 別レコードとして識別可能な名前空間 を持てるようになります。
フェーズ 5: 階層的な realize と親 Id 転記
ここからが Realizer の出番です。ソート済みの blueprint を順番に realize (= SObject インスタンスに変換) していきます。
ネスト + .times(...) の組み合わせがあるとき、realize はこう振る舞います:
親 blueprint を times(N) 回ループ
各親インスタンスごとに:
親を SObject 化
子の blueprint を times(M) 回ループ
各子インスタンスごとに:
子を SObject 化
子の lookup フィールドに親の Id をコピー
※ Id はこの時点ではまだ仮の値 (insert 前)
孫があれば再帰的に同じ処理
ここで重要なのは、親の各インスタンスごとに子の完全セットが再生成される という性質です。これが「Multiplication: 上位階層の times が下位に伝播する」仕様の源泉になっています (親子・量産・参照のパターン 参照)。
{P0} / {P1} の解決はどこで行われるか
{Pn} の解決は、まさにこの「親ごとに子を realize する」ループの中で行われます。Realizer は再帰の過程で 親位置マップ (parentPositionToAlias という Map) を動的に組み立てており、子 blueprint の .use('{P1}', 'LastName', 'Subject') のような宣言は、「いまこの瞬間に親位置マップ上で階層 1 (= 自分にとっての真の親) を担当している blueprint の alias」として解決されます。
つまり {Pn} は 静的な alias 文字列ではなく、動的に組み立てられた親位置マップへの参照 です。階層が深くなった分だけマップにエントリが追加されていくため、{P0} / {P1} / {P2} の数値は ルートからの絶対深度 として安定して指定できます。
alias で {P0}_child_{#} のように親 alias を埋め込んで同じことをする方法もありますが (これも実装としてサポートされている)、利用者が .use(...) 側で alias 文字列を頭で組み立てる必要が出るため、{Pn} の方が 構造的に書きやすい 設計になっています。
ここで説明した realize は、後述の フェーズ 6 と組み合わさって 1 レイヤーずつ 実行される点に注意してください。「全 blueprint を一気に realize してから一括 insert」ではありません (詳細は次の フェーズ 6 で)。
フェーズ 6: レイヤーごとの一括 DML 挿入 (フェーズ 5 と交互ループ)
実装上、フェーズ 5 (realize) と フェーズ 6 (DML insert) は 一気通貫ではなく、フェーズ 3 で算出された「レイヤー」単位で交互に繰り返されます。ここでの「レイヤー」とは、依存解析の結果 同じ深さに位置する blueprint の集合 のことで、ルートに近い blueprint がレイヤー 0、その子がレイヤー 1、という形で割り当てられます (SOrchestrator.BuildLayers)。
.create() 実行時の擬似コードは次のようになります:
for each layer from 0 to maxLayer:
// フェーズ 5: このレイヤー内の全 blueprint を realize
// 上位レイヤーが既に insert 済みなので、 親 Id が確定した状態で .use(parentAlias, 'Id', ...) を解決できる
layerSObjects = realize all blueprints in this layer
// フェーズ 6: このレイヤーの SObject を一括 insert
dmlOperator.doInsert(layerSObjects)
// マスターマップ aliasToSObject にこのレイヤーの結果を格納
// 次のレイヤーの realize 時に参照される
aliasToSObject.putAll(thisLayerResults)
なぜレイヤー間で同期するかというと、親レイヤーが insert されてはじめて Id が確定し、次レイヤーの子が .use(parentAlias, 'Id', 'AccountId') で参照する本物の Id が手に入る からです。全てを先に realize してしまうと、親の Id が仮置きのまま子に転記されてしまい、insert 後に親子のリレーションが破綻します。
このレイヤー間の同期があるからこそ、利用者は「.use(...) で親 Id を指定するだけで、実 DML 挿入後の本物の Id が子に転記されている」という挙動を素直に享受できています。
IDmlOperator の差し替え位置
各レイヤー末尾の dmlOperator.doInsert(layerSObjects) の dmlOperator が、本番 / Mock の差し替え対象です:
- 本番は
DmlOperator(実insertを実行) - テストでは
MockDmlOperator(実 DML を発火せず、仮 Id を払い出すのみ)
ApexBlueprint 自身のテスト (SOrchestratorTest) で SOrchestrator.start(new MockDmlOperator()) を使うのは、ここを差し替えて 実 DML を発火させずに挙動を検証 するためです。
alias 重複検出のタイミング
実装上、alias 重複は次の 2 タイミングで検出されます:
- 同一レイヤー内:
realizeLayerでlayerAliasToSObjectに詰める段階 - 異なるレイヤー間: insert 後にマスターマップ
aliasToSObjectへ詰める段階
いずれも実行時に Duplicate alias detected 例外が投げられます。これは フェーズ 2 で書いた「alias 重複は遅延検出される」の具体的な発生位置です。
なぜこれらが一手に解けているか
ApexBlueprint の内部処理を振り返ると、「依存グラフ」「トポロジカルソート」「alias 解決」「親ごとの再帰 realize」「親参照解決」「一括 DML」という独立した問題が、同じパイプラインの中で連続的に解かれています。
これらが利用者から見ると「依存を宣言したら勝手に動く」という 1 つの操作に縮退している理由は、ApexBlueprint が 「データの最終状態の宣言」という 1 つの抽象 にすべての操作を寄せているからです。利用者は「最終的にどんなレコード群があってほしいか」だけを書き、「そこに到達するための機械的な手続き」はフレームワークがまとめて引き受けています。
設計の妙としては、次の 2 点が際立っています:
- API の表面は 11 メソッドに収まり、学習コストが線形にしか増えない。機能追加のたびにメソッド数が指数的に増えるタイプの API ではない
- 裏側の各問題に対する解 (トポロジカルソート / 自動 alias /
{Pn}の階層スタック解決) がそれぞれ独立して交換可能 な構造になっており、内部実装の改善余地が温存されている
「シンプルな表面と、強力な内部」という ApexBlueprint の性格は、こうした分離された解の積み上げによって成立しています。
実例: v2.0.0 の 2 機能は、新しい機構を足していない
上記 1 の主張は抽象論に見えますが、v2.0.0 の追加がそのまま実例になっています。after と sharedWith は、どちらも依存解決に新しい仕組みを一切足していません。
| 追加 API | 内部でやっていること |
|---|---|
.after(alias) | fromField / toField を持たない依存を、.use() と同じ依存リストに積む。トポロジカルソートから見れば「値を運ばない辺」が 1 本増えただけ |
.sharedWith(user, level) | __Share の兄弟 blueprint を組み立て、.use(自分のalias, 'Id', 'ParentId') で自分に繋ぐ。普通の子ノードが 1 つ増えただけ |
sharedWith が「親より必ず 1 レイヤー後に insert される」のも、共有のための特別な順序制御があるからではなく、use() の辺が張られた結果としてフェーズ 3 のソートが自然にそう並べるからです。同じ理由で、times による量産にも {Pn} の解決にも自動的に追随します (共有のために量産の仕組みを書き直した箇所は存在しません)。
新機能が「フェーズ 2 でどんな辺を張るか」の表現に落ちる限り、フェーズ 3 以降には手を入れずに済みます。API 表面が線形にしか増えないのは、この構造の帰結です。
関連ドキュメント
- Declarative Data Specification: なぜ blueprint 形式か: なぜこの設計に至ったかの哲学を扱う姉妹 Deep Dive
- 親子・量産・参照のパターン: 利用者から見える表面の API パターン集
- API リファレンス: SOrchestrator: start / add / create / getByAlias と各例外
- API リファレンス: SBlueprint: メソッドチェーンの全 API
- ApexBlueprint ガイドへ戻る