# KrileWorks — ドキュメント全文 (全文) > Salesforce の Apex 開発を支える OSS スタック **Apex Stem** (ApexEloquent / ApexBlueprint / > ApexTrace / ApexTools) のドキュメント全文です。目次ではなく本文そのものが入っています。 このファイル 1 つで API シグネチャとコード例まで到達できます。 人間向けのページは https://krileworks.com/ja/apex-stem/docs/{slug} にあります。 目次だけが必要なら https://krileworks.com/llms-ja.txt を参照してください。 ライブラリ単位に分けたファイルもあります (1 回の取得で読み切れる粒度): - ApexEloquent: https://krileworks.com/llms/apexeloquent-ja.txt (13 本) - ApexBlueprint: https://krileworks.com/llms/apexblueprint-ja.txt (10 本) - ApexTrace: https://krileworks.com/llms/apextrace-ja.txt (4 本) - ApexTools: https://krileworks.com/llms/apextools-ja.txt (2 本) - Apex Stem: https://krileworks.com/llms/apex-stem-ja.txt (2 本) - Architecture & Concepts: https://krileworks.com/llms/concepts-ja.txt (3 本) 本文はサイトと同じソース Markdown です (記事内リンクだけ絶対 URL に展開しています)。 ============================================================================== Source: https://krileworks.com/document/ja/apex-eloquent-prerequisites.md Page: https://krileworks.com/ja/apex-stem/docs/apex-eloquent-prerequisites ============================================================================== # 🛠 事前準備 ApexEloquent を導入する前に、以下のツールと環境が正しくセットアップされていることを確認してください。 ## ✅ Salesforce CLI のセットアップ ApexEloquent のデプロイには Salesforce CLI を使います。未インストールの場合は公式サイトからインストールしてください。 インストール確認: ```bash $ sf -v ``` バージョン情報が表示されれば準備 OK です。 ## ✅ 対象組織の確認 プロジェクトのルートディレクトリで以下のコマンドを実行し、接続中の組織を確認します。 ```bash $ sf org list ``` ターゲットにしたい組織に 🍁 `Default Org` の印が付いていることを確認してください。 例: ``` ┌────┬─────────┬────────────┬────────────────────────────────────────┬────────────────────┬───────────┐ │ │ Type │ Alias │ Username │ Org Id │ Status │ ├────┼─────────┼────────────┼────────────────────────────────────────┼────────────────────┼───────────┤ │ 🌳 │ DevHub │ devhub │ example-user@example.com │ 00DxxxxxxxxxxxxXXX │ Connected │ │ 🍁 │ Sandbox │ sandbox │ example-user@example.com.sandbox │ 00DyyyyyyyyyyyyYYY │ Connected │ └────┴─────────┴────────────┴────────────────────────────────────────┴────────────────────┴───────────┘ ``` ## ⚙️ デフォルト組織の設定 🍁 の印が付いていない場合は、ログインして以下のように設定します。 ```bash # 組織にログイン (alias と URL は環境に合わせて差し替え) $ sf org login web --alias my-sandbox --instance-url https://orgfarm-xxxxxxx-dev-ed.develop.my.salesforce.com # デフォルト組織として設定 $ sf config set target-org=my-sandbox ``` 設定後、もう一度 `sf org list` を実行して 🍁 が付いていることを確認してください。 ## 🛠️ make コマンドのセットアップ ApexEloquent はデプロイ操作の一部で `Makefile` を使うため、`make` コマンドが使えることを確認してください。 ```bash $ make -v ``` 以下のような出力が表示されれば準備 OK です。 ``` GNU Make 4.3 ``` ## 🔗 ガイドへ戻る ← [ApexEloquent ガイドへ戻る](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-guide) ============================================================================== Source: https://krileworks.com/document/ja/apex-eloquent-installation-guide.md Page: https://krileworks.com/ja/apex-stem/docs/apex-eloquent-installation-guide ============================================================================== # 🚀 インストールガイド このガイドでは、Salesforce プロジェクトに ApexEloquent をインストールする一連の手順を解説します。 ## 📥 パッケージの取得 (初回のみ) Git Submodule で `ApexEloquent` を取得します。 ```bash $ cd force-app/main/default/classes $ git submodule add https://github.com/krile136/ApexEloquent.git ApexEloquent ``` これでリポジトリに `ApexEloquent` ディレクトリが追加され、ソース管理に組み込まれます。既にあるリポジトリをクローンした側は、`git submodule update --init --recursive` を忘れないでください。 ### v2 系を使う場合 上のコマンドは既定ブランチ (`main`) を取るので、**v3 系**が入ります。v3 系は SOQL / DML の既定が **ユーザーモード**なので、既存組織にそのまま入れると項目権限の不足で落ちることがあります。 先に v2 系で入れておきたい場合は、保守ブランチを指定します。 ```bash $ git submodule add -b v2.2.x https://github.com/krile136/ApexEloquent.git ApexEloquent ``` 機能は v3 系と同等で、違いは実行モードだけです。判断材料は [ApexEloquent ガイド](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-guide) の冒頭を参照してください。 ## 🚀 組織へのデプロイ 取得したクラスを以下のコマンドでデプロイします。 ```bash $ make install ``` `make install` は内部で Salesforce CLI のデプロイコマンドを呼び出します。 ApexEloquent には自身のテストクラスが同梱されており、**カバレッジ要件 75% に寄与する形**で構成されています。ただし、**組織側の設定や無効化された標準項目が原因でテストが失敗する場合**は、`*_T.cls` テストクラスを適宜調整してください。 ## 🔄 ApexEloquent の更新 ApexEloquent を更新するには、プロジェクトのルートから以下のコマンドを実行します。 ```bash $ cd force-app/main/default/classes/ApexEloquent $ git pull $ make install ``` :::warning `git pull` は追従しているブランチの先頭を取ります。**v2 系 (`v2.2.x`) を使っている場合、`main` に切り替えてから pull すると v3 系に飛びます**。実行モードの既定が変わる破壊的変更なので、意図せず跨がないよう、いま何を追従しているかを `git branch --show-current` で確認してから更新してください。 ::: :::warning Submodule は親リポジトリがどのコミットを参照しているかという情報 (ポインタ) を保持しています。本番環境で一貫性を保つには、`git submodule update --remote` で参照を最新版に自動更新し、親リポジトリ側でもコミットすることをおすすめします。 ::: ## 🔗 ガイドへ戻る ← [ApexEloquent ガイドへ戻る](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-guide) ============================================================================== Source: https://krileworks.com/document/ja/apex-eloquent-scribe-guide.md Page: https://krileworks.com/ja/apex-stem/docs/apex-eloquent-scribe-guide ============================================================================== # Scribe でクエリを組み立てる このドキュメントは、ApexEloquent のクエリビルダー `Scribe` を **プロダクションコードでどう書くか** に焦点を当てた使い方ガイドです。全 API のシグネチャ一覧は [API リファレンス: Scribe](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-scribe) を参照してください。他の ApexEloquent トピックの一覧は [ApexEloquent ガイド](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-guide) から確認できます。 ## Scribe とは `Scribe` は、SOQL を型付きのメソッドチェーンで組み立てるクエリビルダーです。`Scribe.of(Account.class)` から始めて `field`、`whereEqual`、`orderBy` などを積み重ねるだけで、SOQL 文字列が完成します。 ```apex Scribe accountScribe = Scribe.of(Account.class) .field('Id') .field('Name') .field('Industry') .whereEqual('Industry', 'Technology') .orderBy('Name', 'ASC') .take(10); // → SELECT id, name, industry FROM Account WHERE Industry = 'Technology' ORDER BY Name ASC LIMIT 10 List accounts = new Eloquent().get(accountScribe); ``` ポイントは 3 つです。 - **不変 (immutable)**: 各メソッドは新しい `Scribe` インスタンスを返します。途中で分岐させて条件違いのクエリを派生させても、元の `Scribe` は変わりません。 - **クエリ実行は `IEloquent` に委譲**: 完成した `Scribe` を `IEloquent.get(scribe)` に渡すと、内部で SOQL が発行されてデータが取得されます。この **クエリ組み立てと実行の分離** が ApexEloquent の中核で、詳しくは [Query Delegation Pattern](https://krileworks.com/ja/apex-stem/docs/query-delegation-pattern) で解説しています。 - **DI で Mock に差し替え可能**: `IEloquent` はインターフェースで、本番では `Eloquent` (実 SOQL を発行)、テストでは `MockEloquent` (DB を介さず、注入された `IEntry` をそのまま返す) を Layered Constructor Pattern で差し替えられます。Usecase 側のコードは `IEloquent` 型のフィールドに依存するので、本番もテストも呼び方は同じまま、テスト時だけ DB を介さずロジックを検証できます。詳しくは [データ取得と DML、IEntry、Mock](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-data-access) を参照してください。 フィールド名は `SObjectField` ではなく **文字列** で渡します (`'Id'` / `'Industry__c'` など)。これにより、ビルダーは Apex の型システムに縛られず、実行時に動的に組み立てられます。 ## SELECT する項目を決める ### 単一フィールドと複数フィールド `field(String fieldName)` で 1 つずつ追加するか、`fields(List)` でまとめて渡します。 ```apex Scribe scribe = Scribe.of(Opportunity.class) .field('Id') .field('Name') .field('StageName'); // → SELECT id, name, stagename FROM Opportunity ``` `fields(List)` には、あらかじめ宣言した `List` をそのまま渡すこともできます。 ```apex List opportunityFields = new List{ 'Id', 'Name', 'StageName', 'CloseDate', 'Amount' }; Scribe scribe = Scribe.of(Opportunity.class) .fields(opportunityFields) .whereEqual('StageName', 'Prospecting'); // → SELECT id, name, stagename, closedate, amount FROM Opportunity WHERE StageName = 'Prospecting' ``` ### parentField で親項目を取り込む `Scribe.asParent('AccountId').field(...)` を `parentField` に渡すと、親オブジェクトのフィールドが SELECT 句に取り込まれます。 ```apex Scribe scribe = Scribe.of(Opportunity.class) .field('Id') .field('Name') .parentField(Scribe.asParent('AccountId').field('Name').field('Industry')) .whereEqual('StageName', 'Closed Won'); // → SELECT id, name, Account.name, Account.industry FROM Opportunity WHERE StageName = 'Closed Won' ``` 子サブクエリ (`withChildren`) や多対多 (`through`) は [親項目・子サブクエリ・多対多](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-relations) で詳しく扱います。 ### allFields で全件 SELECT `allFields()` を使うと、対象オブジェクトのアクセス可能な全フィールドを SELECT します。プロダクションでは必要なフィールドを明示するほうが安全ですが、テストデータの構築や調査用には便利です。 ## WHERE で絞り込む ### 基本の WHERE 等価 (`whereEqual` / `whereNotEqual`)、比較 (`whereGreaterThan` 系 / `whereLessThan` 系)、パターン (`whereLike` / `whereNotLike`)、リスト (`whereIn` / `whereNotIn`)、多値選択 (`whereIncludes` / `whereExcludes`)、null チェック (`whereNull` / `whereNotNull`) があります。シグネチャと挙動の詳細は [API リファレンス: Scribe](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-scribe) を参照。 ```apex Scribe scribe = Scribe.of(Opportunity.class) .field('Id') .whereEqual('StageName', 'Prospecting') .whereGreaterThan('Amount', 1000) .whereIn('OwnerId', ownerIds); // Set をそのまま渡せる // → SELECT id FROM Opportunity WHERE StageName = 'Prospecting' AND Amount > 1000 AND OwnerId IN (...) ``` 連続した `whereXxx` はデフォルトで **AND** で結合されます。 ### AND と OR を混ぜる OR を入れたい場合は `orCondition()` を **次の where の前に** 挟みます。 ```apex Scribe scribe = Scribe.of(Account.class) .field('Id') .whereEqual('Name', 'A') .orCondition() .whereEqual('Name', 'B') .orCondition() .whereEqual('Name', 'C'); // → SELECT id FROM Account WHERE Name = 'A' OR Name = 'B' OR Name = 'C' ``` **OR を一度入れたら、以降の where はすべて OR で結合する必要があります**。途中で AND に戻すことはできません。OR と AND を混ぜたい時は、**AND 条件を前に集めて、OR 条件を後ろに置く** か、`whereGroup` + `Scribe.asGroup()` で片側を括弧でまとめます。 ```apex Scribe scribe = Scribe.of(Account.class) .field('Id') .whereGroup( Scribe.asGroup() .whereEqual('Industry', 'Tech') .whereEqual('Name', 'X') ) .orCondition() .whereEqual('BillingCity', 'Tokyo'); // → SELECT id FROM Account WHERE (Industry = 'Tech' AND Name = 'X') OR BillingCity = 'Tokyo' ``` なお、`whereIn` に **空のリスト** を渡すと、SOQL では `Id = null` (= 必ず偽の条件) に変換されます。「絞り込み対象が 0 件のときは結果も 0 件」という挙動を、呼び出し側で `isEmpty()` チェックせずにそのまま書ける仕様です。 ⚠️ `whereNotIn` は逆で、**空を渡すと条件ごと無視**されます (絞り込みなし = 全件側)。「空なら絞り込まない」つもりで `whereIn` を書くと 0 件になるので、意図する側を明示したいときは後述の `ignoreWhen` を使ってください。 ### サブクエリで IN する `whereIn` の第 2 引数には **別の `Scribe`** を渡せます。「先に ID を取ってきてから次のクエリで使う」という二段 SOQL を 1 本にまとめられます。 ```apex // 「フォローしている取引先」に紐づく商談だけを取る Scribe followedAccountIds = Scribe.of(AccountShare.class) .field('AccountId') .whereEqual('UserOrGroupId', UserInfo.getUserId()); Scribe oppScribe = Scribe.of(Opportunity.class) .field('Id') .field('Name') .whereIn('AccountId', followedAccountIds); // → SELECT id, name FROM Opportunity WHERE AccountId IN (SELECT accountid FROM AccountShare WHERE UserOrGroupId = '...') ``` SOQL の `IN (SELECT ...)` 構文がそのまま使えるイメージです。`whereNotIn` でも同じく `Scribe` を渡せます。 ### whereLike は SQL インジェクションを自動エスケープ `whereLike(field, pattern)` に渡した `pattern` 内のシングルクォート (`'`) は自動でエスケープされます。ユーザー入力をそのまま渡しても SOQL インジェクションは起きません。 ```apex String userInput = "O'Brien"; // 一見危なそうな入力 Scribe scribe = Scribe.of(Contact.class) .field('Id') .whereLike('LastName', '%' + userInput + '%'); // → ...WHERE LastName LIKE '%O\'Brien%' ``` ## 並び替え・LIMIT・FOR UPDATE `orderBy` / `take` / `offset` / `forUpdate` で並び替え・件数制限・ロックが書けます。 ```apex Scribe scribe = Scribe.of(Opportunity.class) .field('Id') .field('CloseDate') .whereEqual('StageName', 'Prospecting') .orderBy('CloseDate', 'DESC') .take(20); // → SELECT id, closedate FROM Opportunity WHERE StageName = 'Prospecting' ORDER BY CloseDate DESC LIMIT 20 ``` `forUpdate` を `orderBy` / `offset` と併用すると例外、`offset` は最大 2000 など、SOQL の制約をそのまま反映する組み立て時チェックがあります。詳細は [API リファレンス: Scribe](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-scribe) を参照。 ## 集計クエリ (Aggregate) 「親 ID ごとに子レコードの件数を集計」のようなケースでは、Apex 側の SOQL 行数とヒープ消費を抑えるため、集計クエリを使います。 ```apex Scribe eventScribe = Scribe.of(Event.class) .field('WhatId') // GROUP BY する field は SELECT にも明示 .count('Id', 'eventCount') // alias 付きで COUNT .whereIn('WhatId', opportunityIds) .groupByField('WhatId'); // → SELECT whatid, COUNT(Id) eventCount FROM Event WHERE WhatId IN (...) GROUP BY WhatId List aggregateEntries = new Eloquent().get(eventScribe); for (IEntry aggregateEntry : aggregateEntries) { Id whatId = (Id) aggregateEntry.get('WhatId'); Integer cnt = ((Decimal) aggregateEntry.get('eventCount')).intValue(); } ``` 集計関数は `count` / `countDistinct` / `sum` / `average` / `max` / `min` の 6 種類。GROUP BY 系は `groupByField` / `groupByFields` / `groupByParent`、HAVING 句は `havingCondition(Scribe.asHaving()...)` で組み立てます。それぞれのシグネチャは [API リファレンス: Scribe](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-scribe) を参照。 **エイリアスの付与は必須** です。各集計関数は `count(field, alias)` のように `alias` を省略できない 2 引数で、戻り値は `aggregateEntry.get('alias 名')` で取り出します。Salesforce 標準の `AggregateResult` はデフォルトで `expr0` / `expr1` / ... のフィールド名 (宣言順) でアクセスする必要があり初学者の罠になりがちですが、ApexEloquent はメソッドシグネチャでエイリアスを強制することでこのつまずきを避けています。 ### 親フィールドの集計とグループ化 「親オブジェクトのフィールド」を集計や GROUP BY の対象にしたい場合、**直接 `field('Account.Industry')` のような書き方はできません**。親に関するものは `Scribe.asParent(...)` を経由する必要があります。 例: 商品 (`Product2`) ごとの注文明細合計と、その明細が属する商談 (`Opportunity`) の最高額を、商品 × 商談単位で集計する。 ```apex Scribe scribe = Scribe.of(OpportunityLineItem.class) .field('Product2Id') .sum('TotalPrice', 'totalPrice') .parentField( Scribe.asParent('OpportunityId') .field('Id') .max('Amount', 'maxAmount') ) .groupByField('Product2Id') .groupByParent( Scribe.asParent('OpportunityId').groupByField('Id') ); // → SELECT product2id, SUM(TotalPrice) totalPrice, Opportunity.id, MAX(Opportunity.Amount) maxAmount FROM OpportunityLineItem GROUP BY Product2Id, Opportunity.Id ``` ポイント: - 親の SELECT 項目と集計関数は `parentField(Scribe.asParent('OpportunityId').field(...).max(...))` の中にまとめる - 親フィールドでの GROUP BY は `groupByParent(Scribe.asParent('OpportunityId').groupByField('Id'))` で表現する - HAVING 句で親由来 alias (`maxAmount`) を参照するときも、`Scribe.asHaving().whereGreaterThan('maxAmount', 1000)` のように同じ alias で書ける ### 集計クエリの注意点 - **GROUP BY する field は SELECT にも明示する** (`field('Product2Id')` を忘れない) - **集計結果は `Decimal` 型** で返る。`Integer` に入れる時は `((Decimal) aggregateEntry.get('alias')).intValue()` でキャスト - 取得 API は通常の `get(scribe)` のままで OK。**集計クエリか通常クエリかはフレームワークが自動判別** します - 同じ alias を複数の集計関数で使うと例外になります (`sum('A','total')` と `max('B','total')` のように `total` を重複させない) - 子サブクエリ (`withChildren`) と集計関数の併用は SOQL の制約により不可 ## 動的にクエリを組み立てる 検索フィルタのように「入力があるときだけ条件を付ける」クエリは、`ignoreWhen` で**分岐なしの 1 本のチェーン**として書けます。 ```apex Scribe scribe = Scribe.of(Opportunity.class) .field('Id') .field('Name') .whereEqual('Industry', industry).ignoreWhen(industry == null) .whereIn('StageName', stages).ignoreWhen(stages.isEmpty()) .whereGreaterThan('CloseDate', closeAfter).ignoreWhen(closeAfter == null); List opps = this.fetchEloquent.get(scribe); // 3 つ全部が渡された時: // → SELECT id, name FROM Opportunity WHERE Industry = 'Technology' AND StageName IN (...) AND CloseDate > 2026-01-01 // 何も渡されなかった時: // → SELECT id, name FROM Opportunity ``` `ignoreWhen(true)` は**直前の条件を取り下げます**。空の `whereIn` が `0 件` になってしまう罠も、これで避けられます (詳細は [API リファレンス: Scribe](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-scribe))。 > ⚠️ **値が `null` のケースは v3.5.0 以上が必要です。** `whereGreaterThan` / `whereIn` など **null を受け付けない 12 メソッド**は、v3.4.x までチェーンした瞬間に例外を投げていたため、後続の `ignoreWhen` に到達できませんでした。v3.5.0 でエラーが**組み立て時まで遅延**するようになり、`ignoreWhen` で取り下げられます。上の `closeAfter == null` の行はまさにこのケースです。 ### if で積み上げる書き方 `ignoreWhen` が無いバージョンでは、条件分岐で積み上げます。`Scribe` は不変なので **再代入** (`scribe = scribe.whereXxx(...)`) が必要です。 ```apex Scribe scribe = Scribe.of(Opportunity.class) .field('Id') .field('Name'); if (industry != null) { scribe = scribe.whereEqual('Industry', industry); } if (stages != null && !stages.isEmpty()) { scribe = scribe.whereIn('StageName', stages); } ``` この形は今も動きますが、条件が増えるほど「クエリの形」が分岐の中に散らばって読みにくくなります。新しく書くなら `ignoreWhen` に寄せてください。 ### 派生クエリを作る 各メソッドが **新しい `Scribe` インスタンス** を返すので、1 本のクエリから派生を作れます。元の `Scribe` は変わりません。たとえば「同じ商談集合に対して、件数取得用と一覧取得用の 2 本を組む」ようなパターンです。 ```apex Scribe baseScribe = Scribe.of(Opportunity.class) .whereEqual('StageName', 'Prospecting') .whereGreaterThan('Amount', 1000); // 件数だけ取りたい Scribe countScribe = baseScribe.count('Id', 'cnt'); // → SELECT COUNT(Id) cnt FROM Opportunity WHERE StageName = 'Prospecting' AND Amount > 1000 // 一覧を取りたい Scribe listScribe = baseScribe .field('Id') .field('Name') .orderBy('CloseDate', 'ASC') .take(50); // → SELECT id, name FROM Opportunity WHERE StageName = 'Prospecting' AND Amount > 1000 ORDER BY CloseDate ASC LIMIT 50 ``` `baseScribe` は両方の派生元として変わらず再利用できます。 ## 次に読む - [データ取得と DML、IEntry、Mock](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-data-access): 組み立てた `Scribe` を `IEloquent` で実行し、`IEntry` を扱う - [親項目・子サブクエリ・多対多](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-relations): リレーションを使ったクエリと、`MockEntry` での親子モック - [ApexEloquent ガイド](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-guide): ApexEloquent ガイドの目次に戻る ============================================================================== Source: https://krileworks.com/document/ja/apex-eloquent-data-access.md Page: https://krileworks.com/ja/apex-stem/docs/apex-eloquent-data-access ============================================================================== # データ取得と DML、IEntry、Mock このドキュメントは、ApexEloquent のデータアクセス層を **プロダクションコードでどう書くか** に焦点を当てた使い方ガイドです。全 API のシグネチャ一覧は [API リファレンス: IEloquent / Eloquent / MockEloquent](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-eloquent) と [API リファレンス: IEntry / Entry / MockEntry](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-entry) を参照してください。 クエリの組み立て方は [Scribe でクエリを組み立てる](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-scribe-guide) を、他の ApexEloquent トピックは [ApexEloquent ガイド](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-guide) を参照してください。 ## IEloquent とは `IEloquent` は、データアクセス (SOQL / DML) を抽象化したインターフェースです。本番では `Eloquent` (標準 SOQL / DML のラッパー)、テストでは `MockEloquent` (DB を介さない振る舞いモック) を、同じ呼び出し側コードのまま差し替えられます。 ```apex public with sharing class FindActiveOpportunitiesUsecase { private final IEloquent fetchEloquent; public FindActiveOpportunitiesUsecase() { this(null); } @TestVisible private FindActiveOpportunitiesUsecase(IEloquent fetchEloquent) { this.fetchEloquent = fetchEloquent ?? new Eloquent(); } public List invoke() { Scribe scribe = Scribe.of(Opportunity.class) .field('Id') .field('Name') .whereEqual('IsClosed', false); return this.fetchEloquent.get(scribe); } } ``` 「2 つのコンストラクタで本番デフォルトとテスト DI を両立する」やり方は [Layered Constructor Pattern](https://krileworks.com/ja/apex-stem/docs/layered-constructor-pattern) を参照してください。 ## データ取得 ### 取得系メソッド ビジネスロジックでは **`get(scribe)` で `List` を受け取る** のが基本です。0 件なら空リストが返ります。1 件だけ取りたい時は `first` (0 件は `null`) / `firstOrFail` (0 件で例外) が便利です。 `getAsSObject` への変換は、外部 API (`Messaging.SingleEmailMessage` など) に `SObject` インスタンスを直接渡したいなど、`SObject` でないと困る具体的な理由がある時だけの最終手段です。同様に `rawSoql(soql)` は `Scribe` で表現できない特殊な SOQL を投げる時の最終手段で、`MockEntry` の SELECT 漏れ検知が無効になります。 シグネチャの一覧は [API リファレンス: IEloquent](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-eloquent) を参照。 ### IEntry のまま扱う利点 `IEntry` は `SObject` のラッパーで、ApexEloquent の 4 つの仕組みの恩恵を受けられます。 1. **未 SELECT フィールドアクセスの偽陽性検知**: `Scribe` で `field()` し忘れたフィールドに本番コードがアクセスすると、テスト実行時 (`MockEntry` 経由) で例外が出ます。`SObject` に早期変換するとこの保護が外れて、「テストは通るが本番で値が空だった」事故が起きます。 2. **モックデータ構築の自由度**: `MockEntry.set()` はリレーション項目・数式項目・ロールアップ・auto-number など、本来書き込めない non-writable フィールドにも値を書けます。ロジックがそれらに依存する場合でも、`SObject` のままでは作れないテストデータが `IEntry` なら作れます。 3. **取得 → 編集 → 更新が IEntry のまま完結**: `entry.put('Industry__c', value)` でミューテートし、そのまま `eloquent.doUpdate(List)` に渡せます。`SObject` への変換は不要です。 4. **SObject と AggregateResult を区別しなくてよい**: 通常クエリの結果も集計クエリ (`AggregateResult`) の結果も、どちらも `IEntry` として返ります。`entry.get('Industry')` で **オブジェクトのフィールド** を、`entry.get('totalAmount')` で **集計クエリの alias** を、同じ書き方で取り出せます。受け手側は SObject か AggregateResult かを意識する必要がありません。 ## IEntry を扱う ### フィールドの読み書き ```apex List accountEntries = eloquent.get(accountScribe); for (IEntry accountEntry : accountEntries) { // Id と Name は専用 getter (キャスト不要) Id accountId = accountEntry.getId(); String name = accountEntry.getName(); // その他のフィールドはキャスト必須 String industry = (String) accountEntry.get('Industry'); Boolean isActive = (Boolean) accountEntry.get('Active__c'); // 書き込み accountEntry.put('Industry', 'Technology'); } ``` `getId` / `getName` だけ専用 getter があってキャスト不要。それ以外のフィールドは `get(fieldName)` で取り、戻り値をキャストします。書き込みは `put(fieldName, value)`。親や子レコードへのアクセスは [親項目・子サブクエリ・多対多](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-relations) を、シグネチャの一覧は [API リファレンス: IEntry](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-entry) を参照してください。 ### 変数命名のコツ 抽象化された型 (`IEntry` / `Scribe` / `IEloquent`) を使う時は、**変数名で中身の SObject を明示する** と読みやすくなります。 ```apex // ❌ r や e のような略は、後で読む人が型を辿らないとわからない for (IEntry e : eloquent.get(scribe)) { ... } // ✅ {SObject名}Entry の形に揃える for (IEntry accountEntry : eloquent.get(accountScribe)) { ... } ``` ## DML を実行する `IEloquent` は標準 DML のラッパーも提供します。`doInsert` / `doUpdate` / `doUpsert` / `doDelete` の 4 種類で、それぞれ `SObject` / `IEntry` / `List` / `List` のオーバーロードがあります。 ```apex // Scribe で取得した IEntry をそのまま更新できる List oppEntries = this.eloquent.label(LBL_FETCH).get(oppScribe); for (IEntry oppEntry : oppEntries) { oppEntry.put('Industry__c', 'Technology'); } this.eloquent.label(LBL_UPDATE).doUpdate(oppEntries); ``` `List<>` 版を基本とし、bulk 処理に揃えます。単件版は処理対象が常に 1 件と確定している時だけ使います。各オーバーロードのシグネチャは [API リファレンス: IEloquent](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-eloquent) を参照。 ### 実行モード (v3 系) v3 系では、SOQL / DML の既定が **ユーザーモード** (実行ユーザーの項目・オブジェクト権限を尊重) です。集計・焼き付け・データ移行のように「誰が起こしても完遂すべき処理」だけ、`systemMode()` で明示的にオプトアウトします。 ```apex this.eloquent.systemMode().label(LBL_UPDATE).doUpdate(entries); ``` 一度呼ぶとそのインスタンスの以降すべての操作に効きます (sticky)。`MockEloquent` では no-op なので、**単体テストはモードを意識せずに書けます**。 ⚠️ `systemMode()` が外すのは **項目・オブジェクト権限だけ**です。**共有 (レコードの可視性) は別軸**で、外すには呼び出し元クラスを `without sharing` にする必要があります。 ## MockEloquent でテストする `MockEloquent` は `IEloquent` のテスト用実装です。Layered Constructor Pattern で Usecase に DI することで、DB に触れずに振る舞いを検証できます。 ### コンストラクタ `new MockEloquent()` で空、`new MockEloquent(entry)` で 1 件、`new MockEloquent(List)` で複数件返す Mock を作れます。 ```apex MockEntry oppEntry = MockEntry.of(Opportunity.class) .autoId(1) .set('Name', 'Test Opp') .set('Amount', 1000); IEloquent fetchEloquent = new MockEloquent(new List{ oppEntry }); ``` ### Spy で DML を検証する `MockEloquent` は本物の `doInsert` / `doUpdate` / `doUpsert` / `doDelete` を実行する代わりに、**渡されたレコードを記録** します。テストで「想定通りの DML が実行されたか」を後から検証できます。 | メソッド | 中身 | |---|---| | `upsertedRecordsAt(String label)` | そのラベルで `doInsert` / `doUpdate` / `doUpsert` に渡されたレコード (`List`) | | `deletedCountAt(String label)` | そのラベルでの `doDelete` の件数 (`Integer`) | > ラベルを使わない (lenient) テストでは `'default'` を渡します。`upsertedRecords` / `deletedCount` という**フィールド**も後方互換で残っていますが `@deprecated` なので、新規コードでは `*At(...)` を使ってください。 ```apex MockEloquent mock = (new MockEloquent()) .attach(CopyAccountIndustryToOpportunityUsecase.LBL_FETCH, new List{ oppEntry }); (new CopyAccountIndustryToOpportunityUsecase(oppIds, mock)).invoke(); // 想定通り 1 件 update された? List updated = mock.upsertedRecordsAt(CopyAccountIndustryToOpportunityUsecase.LBL_UPDATE); Assert.areEqual(1, updated.size()); // 想定通りの値が入っている? Assert.areEqual('Technology', ((Opportunity) updated[0]).Industry__c); ``` ### 異常系をモックする Salesforce では、**失敗を再現するコストが成功を再現するコストより桁違いに高い**という事情があります。実 DML を本当に失敗させようとすると、入力規則を足す、項目権限を落とす、行ロックを競合させる、といった org 側の細工が要ります。遅いうえに、org の状態に依存するので壊れやすい。 `MockEloquent` は、その失敗を**名指しで宣言**できるようにしています。 #### 失敗の指定は 4 つの軸でできている `failOn*` 系は一見メソッドが多く見えますが、実際には次の 4 軸の組み合わせです。ここを押さえると読み書きが一気に楽になります。 | 軸 | 指定するもの | 既定 | |---|---|---| | **何が**失敗するか | `failOnDoUpdate()` / `failOnGet()` / … メソッドごとに 1 本 | — | | **何で**失敗するか | 引数に渡す `Exception` | 汎用のテスト用例外 | | **どこで**失敗するか | `.whenLabel(ラベル)` | ラベルなしの呼び出し (`'default'`) 宛て | | **何回**失敗するか | `failOn*` を積む / `.repeat()` | 次の 1 回だけ | 4 つは直交していて、必要な分だけ足せます。 ```apex MockEloquent mock = (new MockEloquent()) .failOnDoUpdate(new DmlException('Simulated failure')) // 何が + 何で .whenLabel(YourUsecase.LBL_UPDATE); // どこで try { (new YourUsecase(input, mock)).invoke(); Assert.fail('例外が投げられるはず'); } catch (DmlException e) { Assert.isTrue(TraceFlow.isLastAbort()); } ``` #### 「どこで」を絞る意味 `whenLabel` を付けると、**その 1 箇所だけ**を落として、残りは正常に流せます。「集計は成功したが、最後の保存だけ失敗した」といったシナリオがそのまま書けます。DML を複数撃つ Usecase では、これが無いと「どれが落ちたのか」をテストから読み取れません。 `whenLabel` を付けなかった設定は、**ラベルなしの呼び出し (`'default'`) 宛て**になります。本番コードがラベルを使っているなら、`whenLabel` は実質必須です (付け忘れは検出されます。後述)。 なお、ラベルが一致しない呼び出しでは**設定が消費されません**。あとから来る一致した呼び出しが受け取るので、順序を気にせず仕掛けられます。 #### 「何回」は積める `failOn*` は**キューに積まれます**。同じメソッドに複数回仕掛ければ、その回数だけ順に失敗します。 ```apex // 1 回目と 2 回目の doUpdate は失敗し、3 回目は成功する MockEloquent mock = (new MockEloquent()) .failOnDoUpdate(new DmlException('1 回目')) .failOnDoUpdate(new DmlException('2 回目')); ``` 「2 回失敗して 3 回目に成功する」というリトライの成功パスは、この形で書けます。キューはラベルごとに独立しているので、`whenLabel` を付けた設定でも同じように積めます。 一方 `.repeat()` は、直前に仕掛けた設定を**以降ずっと**繰り返します (回数は指定できません)。 ```apex // 何度呼ばれてもずっと失敗する MockEloquent mock = (new MockEloquent()) .failOnDoUpdate(new DmlException('Always fails')) .whenLabel(YourUsecase.LBL_UPDATE) .repeat(); ``` 「最大 3 回までリトライ、それでもダメなら中止」というロジックの、**中止側のパス**を確かめたいときに使います。回数を数える必要がないぶん、こちらのほうが意図が明確です。 #### ラベル単位のキューになっている 失敗の設定は **ラベルごとの FIFO キュー** に積まれます (`whenLabel` を付けなかった設定は、ラベルなしの呼び出し = `'default'` 宛てになります)。 そのため、同じメソッドに対して**別々のラベルへ別々の失敗を仕掛けられます**。順番も気にする必要はありません。 ```apex // 取得は 'fetch' で、保存は 'update' で、それぞれ別の理由で落とす MockEloquent mock = (new MockEloquent()) .failOnGet(new QueryException('取得に失敗')).whenLabel(YourUsecase.LBL_FETCH) .failOnDoUpdate(new DmlException('保存に失敗')).whenLabel(YourUsecase.LBL_UPDATE); ``` #### リトライは同じラベルのまま書ける ラベルの「1 度だけ」制約が数えるのは、**成功した操作**です。**失敗した操作はラベルを解放する**ので、本番コードが例外を catch して同じラベルでやり直す実装も、そのままテストできます。 ```apex // 1 回目は失敗し、2 回目で成功する — 同じラベルのまま MockEloquent mock = (new MockEloquent()) .attach(YourUsecase.LBL_UPDATE, entries) .failOnDoUpdate(new DmlException('1 回目')).whenLabel(YourUsecase.LBL_UPDATE); ``` `repeat()` を付ければ、何度リトライしても失敗し続けるので「上限まで試して中止」のパスを確かめられます。 #### 落とし穴: 戻り値を受け取る テスト設定のビルダー (`attach` / `failOn*` / `whenLabel` / `failSave` / `repeat`) は、いずれも **`this` を変更せず新しいインスタンスを返します**。戻り値を捨てると設定が消えます。 ```apex // ❌ 効かない。mock 自身には何も仕掛かっていない MockEloquent mock = new MockEloquent(); mock.failOnDoUpdate(new DmlException('...')); // ✅ チェーンでつなぐか、戻り値を受け直す MockEloquent mock = (new MockEloquent()) .attach(YourUsecase.LBL_FETCH, entries) .failOnDoUpdate(new DmlException('...')) .whenLabel(YourUsecase.LBL_UPDATE); ``` #### 落とし穴: `whenLabel` の付け忘れは検出される ラベルを使っている (strict mode の) テストで `whenLabel` を付け忘れると、その設定はラベルなしの呼び出し宛てになります。ところが strict mode ではすべての操作にラベルが要るので、**その設定は永久に発火しません**。 黙って無視されると「例外が飛ばないので `Assert.fail` が発火する」という分かりにくい落ち方をするため、その場で診断メッセージ付きの例外になります。付けるべきラベル名もメッセージに出ます。 #### failSave: 例外ではなく「一部だけ保存されない」 `failOn*` が「呼ぶと例外が飛ぶ」なのに対し、`failSave` は**呼び出し自体は成功するが、指定したレコードだけ保存されない**という別種の失敗です。`allOrNone` 付き DML の部分失敗を再現します。 ```apex Id badId = MockEntry.of(Account.class).autoId(2).getId(); MockEloquent mock = (new MockEloquent()) .failSave(badId, '入力規則で拒否されました'); ``` - `allOrNone = false` — そのレコードの `SaveResult` が `success = false` になり、**Spy には積まれません** (保存されていないため) - `allOrNone = true` — 実際の all-or-nothing DML と同じく、何も記録される前に**操作全体が例外**になります 対象を index ではなく **レコード Id で指名**するので、`MockEntry.autoId()` と対で使います。リストの順序に依存しないため、あとから件数が増えても壊れません。 「失敗したレコードだけログに残して続行する」ような ETL / 増分同期のパスを、org を細工せずに検証できます。 > 各 `failOn*` の全一覧は [API リファレンス: MockEloquent](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-eloquent) を参照してください。 ### 同じ IEloquent で複数のクエリを区別する: ラベル多重化 (重要) `MockEloquent` は `Scribe` の WHERE 条件を **評価しません**。渡された `IEntry` リストをそのまま返します。これは「`先月のクエリ` と `今期のクエリ` のように、同じ `IEloquent` で複数のクエリを区別したい」ケースを **そのままでは扱えない** ことを意味します。 v2.1 で追加された **`IEloquent.label(String)`** が、これを解決します。1 本の `IEloquent` を **ラベルで多重化** することで、用途別に `IEloquent` を分割 DI する代わりに、同じインスタンスをラベルで仕分けて使えます。 ```apex public with sharing class AggregateAccountActivityUsecase { @TestVisible static final String LBL_LAST_MONTH = 'lastMonthEvent'; @TestVisible static final String LBL_THIS_YEAR = 'thisYearEvent'; @TestVisible static final String LBL_ACCOUNT_UPDATE = 'accountUpdate'; private final Set accountIds; private final IEloquent eloquent; public AggregateAccountActivityUsecase(Set accountIds) { this(accountIds, null); } @TestVisible private AggregateAccountActivityUsecase( Set accountIds, IEloquent eloquent ) { this.accountIds = accountIds; this.eloquent = eloquent ?? new Eloquent(); } public void invoke() { List lastMonthEvents = this.eloquent.label(LBL_LAST_MONTH).get(lastMonthScribe); List thisYearEvents = this.eloquent.label(LBL_THIS_YEAR).get(thisYearScribe); // ... 集計 ... this.eloquent.label(LBL_ACCOUNT_UPDATE).doUpdate(updatedAccounts); } } ``` テストでは 1 本の `MockEloquent` に対して `.attach(LBL_LAST_MONTH, ...)` / `.attach(LBL_THIS_YEAR, ...)` で各ラベルにクエリ結果を **個別にプリロード** し、DML の検証は `mock.upsertedRecordsAt(LBL_ACCOUNT_UPDATE)` で取り出します。用途別 DI と同じ独立性を保ったまま、コンストラクタの引数を 1 つに圧縮できます。 なお、`.label()` が初めて呼ばれた瞬間に **opt-in strict mode** が有効になり、以降のすべての操作にラベル付けが必須となります (ラベル忘れの footgun が実行時に塞がれます)。詳しくは [API リファレンス: IEloquent](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-eloquent) を参照してください。 ## MockEntry を構築する `MockEntry` はテスト用の `IEntry` 実装で、`SObject` では作れないテストデータを作れます。 ### 基本 ```apex MockEntry oppEntry = MockEntry.of(Opportunity.class) .autoId(1) .set('Name', 'Test Opp') .set('Amount', 1000); ``` `MockEntry.of(Type)` でエントリ生成、`.set` でフィールドへの書き込み (書き込み不可項目も OK)、`.autoId` で 18 桁の ID 自動生成、`.alias` で後から ID を取り出すための名前付け。シグネチャ一覧は [API リファレンス: MockEntry](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-entry) を参照。 ### fetchedBy で SELECT 契約を付ける `MockEloquent` の `attach` 経由で返すエントリには、`get(scribe)` に渡した `Scribe` から **SELECT 契約が自動的に付きます**。SELECT していない項目に触れば例外になる、あの仕組みです。 一方、**エントリを SUT に直接渡す場合は契約がありません**。`Scribe` を通っていないので、どの項目が SELECT 済みかを知りようがないからです。そこに契約を後付けするのが `fetchedBy` です。 ```apex MockEntry card = MockEntry.of(BusinessCard__c.class) .autoId(1) .set('CompanyName__c', 'Acme') .fetchedBy(RematchCompanyCardsHandler.scope()); ``` 効くのは主に**バッチ**です。`execute(bc, scope)` のレコードは `IEloquent` を通らないため、本番は実クエリの結果としてプラットフォームが検査してくれますが、テストは自前で組んだエントリなので無検査になります。クエリの組み立てを `@TestVisible` なメソッドに切り出しておけば、本番と同じ `Scribe` をテストから渡せます (詳細は [API リファレンス: Scribe](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-scribe))。 ### alias で生成 ID を取り出す `autoId` で生成された ID を、テストのアサーションで使いたい場合があります。 ```apex MockEntry oppEntry = MockEntry.of(Opportunity.class) .alias('opp') .autoId(1); Id oppId = oppEntry.getAliasId('opp'); // 生成された ID を取り出し // テストで: Opportunity updated = (Opportunity) mock.upsertedRecordsAt(MyUsecase.LBL_UPDATE)[0]; Assert.areEqual(oppId, updated.Id); ``` ### times() で複数件を量産 ```apex List contactEntries = MockEntry.of(Contact.class) .autoId('{#}') .set('LastName', 'Contact-{#}') .alias('con_{#}') .times(3); // → con_1 / con_2 / con_3 で 3 件、それぞれ Id と LastName が自動展開 ``` ### template で複数フィールドをまとめてセット 「複数テストで再利用するデフォルト値」を Map にまとめて渡すと、テストで `.set` を繰り返さずに済みます。 ```apex Map defaults = new Map{ 'StageName' => 'Prospecting', 'CloseDate' => Date.today().addDays(30), 'Amount' => 1000 }; MockEntry oppEntry = MockEntry.of(Opportunity.class) .template(defaults) .set('Name', 'Specific name for this test'); // 上書き・個別フィールド追加 ``` ### 集計結果のモック 集計クエリの結果をモックする時は、`MockEntry.asAggregateResult()` を使います。これは「集計結果は SObject 型に紐づかない」という性質を反映していて、`MockEntry.of(SomeType.class)` のように特定の SObject 型を選ぶ必要がなくなります (読み手の認知ノイズも減ります)。 ```apex MockEloquent eventEloquent = new MockEloquent( new List{ MockEntry.asAggregateResult() .set('WhatId', oppAId) .set('eventCount', 3), MockEntry.asAggregateResult() .set('WhatId', oppBId) .set('eventCount', 1) } ); ``` `set` で渡す値は、本物の SOQL 集計結果と同じく `Decimal` で渡しておくと、ロジック側の `((Decimal) entry.get('eventCount')).intValue()` キャストもそのまま動きます。 ### 親子のモック 子から親を見る場合は `setParent`、親から子を見る場合は `setChildren` で構造のまま組み立てられます。コードのインデントがそのままリレーション構造を表すので、後から読み返しても「この親にぶら下がっている子」が一目で分かります。 ```apex // 親 Account に複数の Contact / Opportunity をぶら下げる MockEntry accountEntry = MockEntry.of(Account.class) .alias('acc').autoId(1) .set('Name', 'Acme Corporation') .setChildren('Contacts', new List{ MockEntry.of(Contact.class).autoId(1).set('FirstName', 'John'), MockEntry.of(Contact.class).autoId(2).set('FirstName', 'Jane') }) .setChildren('Opportunities', new List{ MockEntry.of(Opportunity.class).autoId(1).set('Name', 'Deal 1').set('Amount', 100000), MockEntry.of(Opportunity.class).autoId(2).set('Name', 'Deal 2').set('Amount', 150000) }); ``` 逆方向 (子 Opportunity から親 Account を見る) は `setParent`: ```apex MockEntry oppEntry = MockEntry.of(Opportunity.class) .autoId(1) .set('Name', 'Major Deal') .setParent('AccountId', MockEntry.of(Account.class).set('Name', 'Acme Corporation').set('Type', 'Customer') ); ``` 親子の `Id` 連結 (例: `Contact.AccountId` に親の Id を埋める) は `MockEntry` が内部で処理するので、手で埋める必要はありません。 ⚠️ `Scribe` 側で子リレーション名を `relationName('CustomOpportunities__r')` で指定している場合は、`setChildren` の第 1 引数も同じ文字列を使ってください。リレーション操作の全体像 (多対多や Junction Object も含む) は [親項目・子サブクエリ・多対多](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-relations) を参照。 ## 次に読む - [親項目・子サブクエリ・多対多](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-relations): リレーションの取得とモック - [Scribe でクエリを組み立てる](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-scribe-guide): クエリビルダーの全体像 - [Layered Constructor Pattern](https://krileworks.com/ja/apex-stem/docs/layered-constructor-pattern): `IEloquent` を Usecase に DI する設計 (差し替えの継ぎ目をどこに置くか) - [テスト戦略](https://krileworks.com/ja/apex-stem/docs/test-strategy): `MockEloquent` を中心に据えた Usecase 単体テストの位置づけ - [ApexEloquent ガイド](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-guide): ApexEloquent ガイドの目次に戻る ============================================================================== Source: https://krileworks.com/document/ja/apex-eloquent-relations.md Page: https://krileworks.com/ja/apex-stem/docs/apex-eloquent-relations ============================================================================== # 親項目・子サブクエリ・多対多 このドキュメントは、ApexEloquent でリレーションを扱う方法をまとめます。`Scribe` でのクエリ組み立ては [Scribe でクエリを組み立てる](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-scribe-guide)、`IEloquent` / `IEntry` の基本は [データ取得と DML、IEntry、Mock](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-data-access) を参照してください。 ## 親項目を取得する 「商談から親取引先の業種を取りたい」のように、親オブジェクトのフィールドを SELECT に含めるには `parentField` を使います。 ```apex Scribe oppScribe = Scribe.of(Opportunity.class) .field('Id') .field('Name') .parentField(Scribe.asParent('AccountId').field('Name').field('Industry')) .whereEqual('StageName', 'Closed Won'); // → SELECT id, name, Account.name, Account.industry FROM Opportunity WHERE StageName = 'Closed Won' List oppEntries = this.fetchEloquent.get(oppScribe); for (IEntry oppEntry : oppEntries) { IEntry accountEntry = oppEntry.getParent('AccountId'); String accountName = accountEntry.getName(); String industry = (String) accountEntry.get('Industry'); } ``` - `Scribe.asParent('AccountId')` で親リレーション用の Scribe を作り、`.field(...)` で取り込む親項目を指定 - それを `parentField(...)` に渡すと、SOQL の SELECT 句に `Account.Name` のように展開される - 取得後は `IEntry.getParent('AccountId')` で親 `IEntry` を取り出す ### parentField で取らないと例外 `Scribe` で `parentField` していない親項目に `IEntry.getParent('AccountId')` でアクセスすると、テスト時 (`MockEntry` 経由) に例外が出ます。これは「親項目の SELECT 漏れ」を本番ではなくテストで検出するための仕組みです。 ## 子サブクエリを取得する 「取引先からその子商談一覧を取りたい」のように、子レコードをサブクエリで取るには `withChildren` を使います。 ```apex Scribe accountScribe = Scribe.of(Account.class) .field('Id') .field('Name') .withChildren( Scribe.asChild(Opportunity.class) .field('Id') .field('Name') .field('StageName') ) .whereEqual('Type', 'Customer'); // → SELECT id, name, (SELECT id, name, stagename FROM Opportunities) FROM Account WHERE Type = 'Customer' List accountEntries = this.fetchEloquent.get(accountScribe); for (IEntry accountEntry : accountEntries) { List oppEntries = accountEntry.getChildren('Opportunity'); for (IEntry oppEntry : oppEntries) { // ... } } ``` - `Scribe.asChild(Opportunity.class)` で子サブクエリ用の `Scribe` を作る - それを `withChildren(...)` に渡すと、SOQL の SELECT 句にサブクエリとして展開される - 取得後は `IEntry.getChildren('Opportunity')` で子 `IEntry` のリストを取り出す 本番の `Entry` は、`getChildren` の引数を **オブジェクト名と relationship 名のどちらでも解決します** (`'Opportunity'` / `'Opportunities'`)。まずオブジェクト名として探し、見つからなければ relationship 名として解決する順です。 ただし **`MockEntry` は `Scribe` に宣言したキーで照合する** ので、テストを通したいなら `Scribe` と揃えた名前で呼んでください (後述の「子レコードのモック」を参照)。**テストが通る書き方は本番でも通ります**が、逆は保証されません。 ### 並列に複数の子サブクエリを取る 同じ親に対して **複数の子オブジェクトを並列に** 取得することもできます。`withChildren` を 2 回続ければ、それぞれが独立した子サブクエリになります。 ```apex Scribe scribe = Scribe.of(Account.class) .field('Id') .withChildren( Scribe.asChild(Opportunity.class).field('Id').whereNotNull('Name') ) .withChildren( Scribe.asChild(Contact.class).field('Id').whereNotNull('Email') ) .whereEqual('Name', 'Test Account'); // → SELECT id, (SELECT id FROM Opportunities WHERE Name != NULL), (SELECT id FROM Contacts WHERE Email != NULL) FROM Account WHERE Name = 'Test Account' ``` ### ネストした子サブクエリ (子の子) を取る `Scribe.asChild(...)` の中でさらに `withChildren(...)` を呼ぶと、子の子も含めたネストしたサブクエリになります (最大 4 レベルまで)。 ```apex Scribe scribe = Scribe.of(Account.class) .field('Id') .withChildren( Scribe.asChild(Contact.class) .field('Id') .whereNotNull('Name') .withChildren( Scribe.asChild(Opportunity.class).field('Id').whereNotNull('Email') ) ) .whereEqual('Name', 'Test Account'); // → SELECT id, (SELECT id, (SELECT id FROM Opportunities WHERE Email != NULL) FROM Contacts WHERE Name != NULL) FROM Account WHERE Name = 'Test Account' ``` 取得側は `getChildren` をネストして辿ります。 ```apex for (IEntry accountEntry : accountEntries) { for (IEntry contactEntry : accountEntry.getChildren('Contact')) { for (IEntry oppEntry : contactEntry.getChildren('Opportunity')) { // ... } } } ``` ### 子リレーションが曖昧な時は relationName を明示 同じオブジェクトを参照する Lookup フィールドが複数ある場合 (`Opportunity.AccountId` と `Opportunity.CustomAccount__c` のように、両方が `Account` を指すケース)、子サブクエリは **どのリレーションで戻るかが曖昧** になり、`Scribe` のままだとエラーになります。 このときは `relationName(...)` で明示します。 ```apex Scribe accountScribe = Scribe.of(Account.class) .field('Id') .withChildren( Scribe.asChild(Opportunity.class) .relationName('CustomOpportunities__r') // ← Custom Relationship Name .field('Id') .field('Name') ); // → SELECT id, (SELECT id, name FROM CustomOpportunities__r) FROM Account // 取得側もこのリレーション名で accountEntry.getChildren('CustomOpportunities__r'); ``` ## 親条件で絞り込む 「子オブジェクトを、親オブジェクトの条件で絞り込みたい」場合は `parentCondition` を使います。 ```apex // 親 Opportunity の Name が Test% で始まる OpportunityLineItem を取得 Scribe scribe = Scribe.of(OpportunityLineItem.class) .field('Id') .field('Quantity') .parentCondition( Scribe.asParent('OpportunityId').whereLike('Name', 'Test%') ); // → SELECT id, quantity FROM OpportunityLineItem WHERE Opportunity.Name LIKE 'Test%' ``` `Scribe.asParent('OpportunityId')` に WHERE 系メソッドを連ねて、それを `parentCondition` で組み込みます。 `parentCondition` は **SELECT には親項目を含めない、ただ条件としてだけ使う** 形になります。親項目も SELECT したい場合は `parentField` も併用してください。 ### 親条件を OR でつなぐ `parentCondition` 内の `Scribe.asParent(...)` でも `orCondition()` が使えるので、「親の Name または親の Type が一致する」のような条件が書けます。 ```apex Scribe scribe = Scribe.of(Opportunity.class) .field('Id') .parentField(Scribe.asParent('AccountId').field('Name').field('Id')) .whereEqual('Name', 'Test Opportunity') .parentCondition( Scribe.asParent('AccountId') .whereEqual('Name', 'Test Account') .orCondition() .whereEqual('Type', 'Test Type') ); // → SELECT id, Account.name, Account.id FROM Opportunity WHERE Name = 'Test Opportunity' AND (Account.Name = 'Test Account' OR Account.Type = 'Test Type') ``` 条件を 2 つ以上持つ `parentCondition` は、**ひとつの構造単位として括弧で囲まれます**。囲まないと `A AND B OR C` という「1 階層で AND と OR が混ざった」SOQL になり、SOQL 側が `unexpected token: OR` で拒否するためです。 > ⚠️ **v3.5.0 未満では括弧が付かず、実行時に落ちます。** `toSoql()` は成功するのに実クエリだけが失敗するため気づきにくいバグでした。複数条件の `parentCondition` を他の条件と組み合わせるなら v3.5.0 以上を使ってください。 `parentField` と `parentCondition` は両立し、それぞれ SELECT 句と WHERE 句に独立して反映されます。 ## 多対多 (Junction Object) Salesforce で多対多関係を表現する Junction Object を経由した取得には `asThrough` + `through` を使います。 標準オブジェクトでわかりやすい例として、注文 (`Order`) と商品 (`Product2`) の関係を考えます。両者の間には注文明細 (`OrderItem`) が Junction として挟まり、`OrderItem.Product2Id` で `Product2` を、`OrderItem.OrderId` で `Order` を参照します。 「ある注文が扱う商品を取りたい」場合: ```apex Scribe scribe = Scribe.of(Order.class) .field('Id') .through( Scribe.asThrough(OrderItem.class, 'Product2Id') .field('Name') .field('ProductCode') .whereEqual('IsActive', true) ); // → SELECT id, (SELECT product2id, Product2.name, Product2.productcode FROM OrderItems WHERE Product2.IsActive = true) FROM Order ``` ポイント: - `Scribe.asThrough(OrderItem.class, 'Product2Id')` で「`OrderItem` 経由で、`Product2Id` の先 (= `Product2`) を取りに行く」と宣言 - `.field('Name')` / `.field('ProductCode')` のように、**通過先 (`Product2`) のフィールド名で書く**。生成 SOQL では自動的に `Product2.Name` / `Product2.ProductCode` に展開される - `.whereEqual('IsActive', true)` のような WHERE も、通過先 (`Product2`) を基準に書く。SOQL では `WHERE Product2.IsActive = true` になる 取得側は `IEntry.getThrough(junctionName, relatedKey)` で取り出します。第 1 引数は `getChildren` と同じく、**Junction のオブジェクト名と relationship 名のどちらでも解決されます**。 ```apex List orderEntries = this.fetchEloquent.get(scribe); for (IEntry orderEntry : orderEntries) { List productEntries = orderEntry.getThrough('OrderItem', 'Product2Id'); for (IEntry productEntry : productEntries) { String name = (String) productEntry.get('Name'); String code = (String) productEntry.get('ProductCode'); } } ``` Junction 経由でも子サブクエリと同じく、関連が曖昧な場合は `relationName(...)` で明示できます。同じ親に複数の lookup がある等のケースで使います。 ## MockEntry で親子をモックする テストデータとして親子関係を組み立てるには、`MockEntry.setParent` と `MockEntry.setChildren` を使います。 ### 親レコードのモック ```apex MockEntry oppEntry = MockEntry.of(Opportunity.class) .alias('opp').autoId(1) .set('Name', 'Test Opp') .setParent('AccountId', MockEntry.of(Account.class) .set('Name', 'Parent Account') .set('Industry', 'Technology') ); // テストコード内で取得側のコードに渡せば、getParent でアクセスできる IEntry accountEntry = oppEntry.getParent('AccountId'); Assert.areEqual('Technology', (String) accountEntry.get('Industry')); ``` `setParent('AccountId', ...)` で「商談の `AccountId` 経由の親」として取引先 `MockEntry` をぶら下げます。 ### 子レコードのモック ```apex MockEntry accountEntry = MockEntry.of(Account.class) .alias('acc').autoId(1) .set('Name', 'Acc Co.') .setChildren('Opportunity', MockEntry.of(Opportunity.class) .autoId('{#}') .set('Name', 'Opp-{#}') .set('StageName', 'Prospecting') .times(3) ); // テストでは getChildren でアクセス List oppEntries = accountEntry.getChildren('Opportunity'); Assert.areEqual(3, oppEntries.size()); ``` `setChildren` の第 1 引数は、`Scribe` 側で `relationName` を指定したならその名前、指定していないなら **オブジェクト名** (`'Opportunity'` のようにそのまま、複数形変換も `__r` も付けない) で揃えます。 > ⚠️ **`Scribe` の登録キーと `setChildren` の key は揃えてください。** > `MockEntry.getChildren` は、`Scribe` が登録した子リレーション名 (`relationName` 未指定ならオブジェクト名) と照合します。ズレていると **`ApexEloquentException` が投げられます**。 > > ``` > The specified child Object Name `Opportunities` is not set in Scribe. parent object name: Account > ``` > > つまり、本番の `Entry` は relationship 名でも解決しますが、**モックでは `Scribe` に宣言したキーで呼ぶ必要があります**。黙って空リストが返ることはないので、キーがズレていればテストがその場で落ちます。 ### 子 MockEntry は setChildren の引数内でインライン定義する 子の `MockEntry` を事前に変数化すると、「この変数が後でどこか別の場所で使われるのか?」と読み手が予測しないといけなくなります。`getAliasId(...)` などで再利用する明確な理由がない限り、`setChildren` の引数の中で **インラインで書き下す** のが読みやすくなります。 ```apex // ✅ インライン (構造が視覚的に見える) MockEntry accountEntry = MockEntry.of(Account.class).alias('acc').autoId(1) .set('Name', 'Acc Co.') .setChildren('Opportunity', new List{ MockEntry.of(Opportunity.class).autoId(1).set('Name', 'Opp A'), MockEntry.of(Opportunity.class).autoId(2).set('Name', 'Opp B') }); ``` ## 次に読む - [Scribe でクエリを組み立てる](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-scribe-guide): クエリビルダーの全体像 - [データ取得と DML、IEntry、Mock](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-data-access): `IEloquent` と `MockEloquent` の使い方 - [ApexEloquent ガイド](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-guide): ApexEloquent ガイドの目次に戻る ============================================================================== Source: https://krileworks.com/document/ja/apex-eloquent-api-scribe.md Page: https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-scribe ============================================================================== # API リファレンス: Scribe `Scribe` は ApexEloquent のクエリビルダー。型ヒントとメソッドチェーンで SOQL を組み立てる **immutable** なクラスです。各メソッドは新しい `Scribe` インスタンスを返します。 使い方や典型シナリオは [Scribe でクエリを組み立てる](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-scribe-guide) を参照してください。 ## Static ファクトリ | メソッド | 用途 | |---|---| | `Scribe.of(System.Type recordType)` | 通常クエリの起点。`Scribe.of(Account.class)` | | `Scribe.source(Schema.SObjectType sObjectType)` | 同じく起点。`SObjectType` を渡す形。`Scribe.source(Account.getSObjectType())` | | `Scribe.asParent(String parentRelationIdFieldName)` | 親リレーション専用 Scribe。`parentField` / `parentCondition` / `groupByParent` に渡す | | `Scribe.asChild(System.Type childRecordType)` | 子サブクエリ専用 Scribe。`withChildren` に渡す | | `Scribe.asGroup()` | WHERE 句を括弧で括る用 Scribe。`whereGroup` に渡す | | `Scribe.asHaving()` | HAVING 句専用 Scribe。`havingCondition` に渡す | | `Scribe.asThrough(System.Type junctionType, String relatedKey)` | Junction Object 経由の多対多用 Scribe。`through` に渡す | ## SELECT 系 | メソッド | 用途 | |---|---| | `field(String fieldName)` | 単一フィールドを SELECT | | `fields(List fieldNames)` | 複数フィールドを SELECT | | `allFields()` | 対象 SObject の全フィールドを SELECT | | `parentField(Scribe parentScribe)` | 親オブジェクトのフィールドを SELECT に組み込む | | `withChildren(Scribe childScribe)` | 子サブクエリを追加 | | `through(Scribe throughScribe)` | Junction Object 経由の関連を追加 | | `relationName(String relationName)` | 子サブクエリ / 多対多 で曖昧性を解消する relationship 名を明示 | ```apex List oppFields = new List{ 'Id', 'Name', 'StageName' }; Scribe scribe = Scribe.of(Opportunity.class) .fields(oppFields) .parentField(Scribe.asParent('AccountId').field('Name')); ``` ```soql SELECT id, name, stagename, Account.name FROM Opportunity ``` SELECT 句の項目名は小文字に正規化され、親項目は**リレーション名を前置**して並びます (自分の項目 → 親の項目の順)。WHERE 句の項目名は元の表記のままです。 ## WHERE 系 ### 比較・包含・パターンマッチ | メソッド | SOQL 出力 | |---|---| | `whereEqual(String f, Object v)` | `f = v` (null → `f = NULL`) | | `whereNotEqual(String f, Object v)` | `f != v` | | `whereGreaterThan(String f, Object v)` | `f > v` (null 不可) | | `whereGreaterThanOrEqual(String f, Object v)` | `f >= v` | | `whereLessThan(String f, Object v)` | `f < v` | | `whereLessThanOrEqual(String f, Object v)` | `f <= v` | | `whereLike(String f, String pattern)` | `f LIKE '...'` (`'` は自動エスケープ) | | `whereNotLike(String f, String pattern)` | `f NOT LIKE '...'` | | `whereIn(String f, Object values)` | `f IN (...)`。`List` / `Set` をそのまま受ける (詰め替え不要)。空の扱いは下記参照 | | `whereIn(String f, Scribe subQuery)` | `f IN (SELECT ...)` のサブクエリ版 | | `whereNotIn(String f, Object values)` | `f NOT IN (...)` | | `whereNotIn(String f, Scribe subQuery)` | `f NOT IN (SELECT ...)` | | `whereIncludes(String f, List values)` | 多値選択リスト `INCLUDES` | | `whereExcludes(String f, List values)` | 多値選択リスト `EXCLUDES` | | `whereNull(String f)` | `f = NULL` | | `whereNotNull(String f)` | `f != NULL` | ### 空コレクションの扱い (whereIn / whereNotIn で非対称) `whereIn` / `whereNotIn` に**空のコレクション**を渡したときの挙動は、両者で異なります。 | メソッド | 空を渡すと | 結果 | |---|---|---| | `whereIn(f, 空)` | **常に偽になる条件**を組み立てる | **0 件**になる | | `whereNotIn(f, 空)` | **条件ごと無視**する | 絞り込みなし = **全件**側に倒れる | SOQL としてはどちらも妥当ですが、**「空なら絞り込まない」つもりで `whereIn` を書くと 0 件になります**。意図を明示するなら、次の `ignoreWhen` を使ってください。 ### 論理結合とグループ化 | メソッド | 用途 | |---|---| | `orCondition()` | 次の where を OR で結合 (一度入れたら以降すべて OR) | | `whereGroup(Scribe groupScribe)` | 条件群を括弧で括る (`Scribe.asGroup()` から組み立て) | | `parentCondition(Scribe parentScribe)` | 親オブジェクトの条件で絞り込む | ```apex // (Industry = 'Tech' AND Name = 'X') OR BillingCity = 'Tokyo' Scribe scribe = Scribe.of(Account.class) .field('Id') .whereGroup( Scribe.asGroup() .whereEqual('Industry', 'Tech') .whereEqual('Name', 'X') ) .orCondition() .whereEqual('BillingCity', 'Tokyo'); ``` ```soql SELECT id FROM Account WHERE (Industry = 'Tech' AND Name = 'X') OR BillingCity = 'Tokyo' ``` #### AND と OR を素で混ぜると組み立て時に落ちる (v3.5.0+) SOQL は **1 つの階層で AND と OR が括弧なしで混ざる**ことを許しません。次はどちらの向きでも `ApexEloquentException` になり、**`whereGroup` を使うよう案内されます**。 ```apex // ❌ AND のあとに OR .whereEqual('Industry', 'Tech').whereEqual('Name', 'X').orCondition().whereEqual('BillingCity', 'Tokyo') ``` > ⚠️ **v3.5.0 未満では OR-after-AND がすり抜けます。** `toSoql()` は成功するのに、実行時に SOQL 側が `unexpected token: OR` で落ちるという分かりにくい失敗になっていました。 > > 判定は**チェーン時ではなく組み立て時**です。`ignoreWhen()` による取り下げや、空の `whereNotIn` のような意味上のスキップで混在が解消されるケースを、正しく通すためです。 ### 条件を動的に取り下げる: ignoreWhen | メソッド | 用途 | |---|---| | `ignoreWhen(Boolean shouldIgnore)` | `true` のとき、**直前の `where...()` 条件を取り下げる** | 「入力が空なら条件を付けない」を、`if` 分岐や再代入なしに 1 本のチェーンで書けます。 ```apex Scribe scribe = Scribe.of(Opportunity.class) .field('Id') .whereIn('Id', ids).ignoreWhen(ids.isEmpty()) .whereLike('Name', keyword).ignoreWhen(String.isBlank(keyword)); ``` 実行時の値によって、組み上がる SOQL が変わります。 ```soql -- ids に値があり、keyword は空のとき SELECT id FROM Opportunity WHERE Id IN ('006000000000000AAA', '006000000000000AAB') -- 両方とも空のとき (WHERE 句自体が付かない) SELECT id FROM Opportunity ``` 上の「空コレクションの扱い」で触れた、**空の `whereIn` が 0 件になる**罠も、これで避けられます。 **必ず `where...()` の直後にチェーンします。** 先頭で呼ぶ / `orderBy()` の後で呼ぶ / 2 回続けて呼ぶと `ApexEloquentException` になります。`ignoreWhen()` がどの条件に掛かっているかを曖昧にしないための制約です。 `whereGroup(...)` の直後に置いた場合は、**そのグループ全体**が取り下げ対象になります。 #### null 値も取り下げられる (v3.5.0+) `whereGreaterThan` / `whereGreaterThanOrEqual` / `whereLessThan` / `whereLessThanOrEqual` / `whereLike` / `whereNotLike` / `whereIn` / `whereNotIn` / `whereIncludes` / `whereExcludes` の **10 種 (オーバーロード込みで 12 メソッド)** は `null` を受け付けません。 v3.5.0 から、この **null エラーは組み立て時 (`toSoql()`) まで遅延**します。チェーン時点では「無効な条件」として記録されるだけなので、**直後の `ignoreWhen(true)` で取り下げられます**。 ```apex // v3.4.x まで: whereGreaterThan の時点で例外 → ignoreWhen に到達しない // v3.5.0 から: 取り下げられて WHERE 句に出ない .whereGreaterThan('CloseDate', closeAfter).ignoreWhen(closeAfter == null) ``` 取り下げられずに `toSoql()` まで残った場合は、**どのメソッドのどの項目か**を名指しし、`ignoreWhen` という逃げ道も併記した例外になります。 > ⚠️ `whereEqual` / `whereNotEqual` はこの 12 に含まれません。`X = null` は SOQL として正当なので、null がそのまま条件になります。 #### orCondition との組み合わせ 条件が 1 つも無い状態で `orCondition()` を呼んでも **何も起きません (no-op)**。取り下げによって先頭の条件が消えても落ちず、残ったほうが単独の条件になります。 ```apex // 両方あれば OR、片方が消えれば残りが単独条件、両方消えれば WHERE 自体が付かない Scribe scribe = Scribe.of(Opportunity.class) .field('Id') .whereIn('StageName', stages).ignoreWhen(stages.isEmpty()) .orCondition() .whereIn('OwnerId', ownerIds).ignoreWhen(ownerIds.isEmpty()); ``` ```soql -- 両方あるとき SELECT id FROM Opportunity WHERE StageName IN ('Prospecting') OR OwnerId IN ('005000000000000AAA') -- stages だけあるとき (OR が消え、単独条件になる) SELECT id FROM Opportunity WHERE StageName IN ('Prospecting') -- 両方とも空のとき SELECT id FROM Opportunity ``` OR 条件だけが取り下げられた場合、**OR モードも一緒に解除されます**。取り下げた条件の OR マーカーが残って、後続の素の条件が弾かれる、ということは起きません。 ## ORDER / LIMIT / OFFSET / forUpdate | メソッド | 用途 | |---|---| | `orderBy(String field)` | ASC 並び替え | | `orderBy(String field, String order)` | `ASC` / `DESC` 指定 | | `orderBy(String field, String order, String nullsOperator)` | `NULLS FIRST` / `NULLS LAST` 指定 | | `take(Integer limitNumber)` | LIMIT 句 | | `offset(Integer offsetNumber)` | OFFSET 句 (最大 2000、超えると例外) | | `forUpdate()` | FOR UPDATE 句 | **制約**: `forUpdate` は `orderBy` および `offset` と併用できません。組み立て段階で例外が出ます。 ## 集計関数 | メソッド | SOQL | |---|---| | `count(String field, String alias)` | `COUNT(field) alias` | | `countDistinct(String field, String alias)` | `COUNT_DISTINCT(field) alias` | | `sum(String field, String alias)` | `SUM(field) alias` | | `average(String field, String alias)` | `AVG(field) alias` | | `max(String field, String alias)` | `MAX(field) alias` | | `min(String field, String alias)` | `MIN(field) alias` | **`alias` は必須引数** です。Salesforce 標準の `AggregateResult` はエイリアスを省略するとデフォルトで `expr0` / `expr1` / ... (宣言順) のフィールド名でアクセスする必要があり、初学者の罠になりがちですが、ApexEloquent はメソッドシグネチャでエイリアスを強制することでこのつまずきを避けています。戻り値の取り出しは `aggregateEntry.get('alias 名')` で、自分が付けた名前で参照できます。 **そのほかの注意**: - 同じ alias を複数の集計関数で使うと例外。 - 子サブクエリ (`withChildren`) と集計関数の併用は不可。 ## GROUP BY / HAVING | メソッド | 用途 | |---|---| | `groupByField(String fieldName)` | 単一フィールドで GROUP BY | | `groupByFields(List fieldNames)` | 複数フィールドで GROUP BY | | `groupByParent(Scribe parentScribe)` | 親オブジェクトのフィールドで GROUP BY (`Scribe.asParent(...).groupByField(...)` を渡す) | | `havingCondition(Scribe havingScribe)` | HAVING 句 (`Scribe.asHaving().whereGreaterThan(alias, value)` を渡す) | ```apex Scribe scribe = Scribe.of(OpportunityLineItem.class) .field('Product2Id') .sum('TotalPrice', 'totalPrice') .parentField( Scribe.asParent('OpportunityId').field('Id').max('Amount', 'maxAmount') ) .groupByField('Product2Id') .groupByParent(Scribe.asParent('OpportunityId').groupByField('Id')) .havingCondition( Scribe.asHaving().whereGreaterThan('totalPrice', 1000) ); ``` ```soql SELECT product2id, SUM(TotalPrice) totalPrice, Opportunity.id, MAX(Opportunity.Amount) maxAmount FROM OpportunityLineItem GROUP BY Product2Id, Opportunity.Id HAVING SUM(TotalPrice) > 1000 ``` HAVING では **alias が集計式に展開されます**。`whereGreaterThan('totalPrice', 1000)` と書けば `SUM(TotalPrice) > 1000` になるので、集計式を二度書く必要はありません。 ## 検査・出力 | メソッド | 戻り値 | 用途 | |---|---|---| | `toSoql()` | `String` | 組み立てた SOQL 文字列を返す | | `isAggregate()` | `Boolean` | 集計クエリかどうかを判定 (Eloquent が `get` の振り分けで内部利用) | | `buildFieldStructure()` | `FieldStructure` | SELECT 句のフィールド構造を組み立て (MockEntry の SELECT 漏れ検知に内部利用) | | `buildAggregateFieldStructure()` | `FieldStructure` | 集計クエリ用のフィールド構造を組み立て | | `getSelectedFields(Map)` | `List` | SELECT 対象フィールドのリスト | `toSoql()` はデバッグや学習用にも使いますが、**実用上の主役はバッチの `start()`** です。 ### toSoql() でバッチの QueryLocator を組み立てる `Database.getQueryLocator()` は SOQL を**文字列**で要求するため、ここだけは `Scribe` をそのまま渡せません。組み立ては `Scribe` で行い、最後に `toSoql()` で文字列にします。 ```apex public Database.QueryLocator start(Database.BatchableContext bc) { return Database.getQueryLocator(scope().toSoql()); } ``` ### Scribe を切り出すと、テストでも SELECT 漏れ検知が効く バッチには構造上の穴があります。`execute(bc, scope)` に渡ってくるレコードは **プラットフォームが直接渡してくる** もので、`IEloquent` を通りません。つまり「このクエリが何を SELECT したか」という契約が、Usecase 側に届きません。 - **本番**: `scope` は実クエリの結果なので、SELECT していない項目に触れればプラットフォームが例外を投げます - **テスト**: `scope` は自分で組み立てた `MockEntry` なので、**何も検査されません** この差を埋めるのが `fetchedBy(scribe)` です。クエリの組み立てを `@TestVisible` なメソッドに切り出しておけば、テストから**本番とまったく同じ `Scribe`** を取り出して、モックに焼き付けられます。 ```apex public with sharing class RematchCompanyCardsHandler implements Database.Batchable { public Database.QueryLocator start(Database.BatchableContext bc) { return Database.getQueryLocator(scope().toSoql()); } // 本番とテストで同じ 1 つの定義を共有する @TestVisible private static Scribe scope() { List cardFields = new List{ 'Id', 'CompanyName__c', 'MatchStatus__c' }; return Scribe.of(BusinessCard__c.class) .fields(cardFields) .whereEqual('MatchStatus__c', 'Unprocessed'); } } ``` ```soql SELECT id, companyname__c, matchstatus__c FROM BusinessCard__c WHERE MatchStatus__c = 'Unprocessed' ``` ```apex // テスト側: 本番の SELECT 契約をモックに焼き付ける MockEntry card = MockEntry.of(BusinessCard__c.class) .autoId(1) .set('CompanyName__c', 'Acme') .fetchedBy(RematchCompanyCardsHandler.scope()); ``` これで、Usecase が `scope()` に無い項目を読んだ瞬間に**単体テストで落ちます**。バッチの `start` に項目を足し忘れたまま Usecase 側だけ増やした、という食い違いを、本番に出る前に捕まえられます。 > `fetchedBy` は [API リファレンス: MockEntry](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-entry) を参照してください。 ## 次に読む - [Scribe でクエリを組み立てる](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-scribe-guide): 使い方ガイド - [親項目・子サブクエリ・多対多](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-relations): リレーション操作の典型例 - [API リファレンス: IEloquent / Eloquent / MockEloquent](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-eloquent): 組み立てた Scribe を実行する - [ApexEloquent ガイド](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-guide): ガイド目次に戻る ============================================================================== Source: https://krileworks.com/document/ja/apex-eloquent-api-eloquent.md Page: https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-eloquent ============================================================================== # API リファレンス: IEloquent / Eloquent / MockEloquent `IEloquent` は ApexEloquent のデータアクセス契約 (インターフェース)。本番では `Eloquent`、テストでは `MockEloquent` を Layered Constructor Pattern で DI して使います。 使い方や典型シナリオは [データ取得と DML、IEntry、Mock](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-data-access) を参照してください。 > このページは v3 系・v2 系の共通内容です。両者の違いは実行モードだけなので、そこだけ「実行モード (v3 系)」として分けて示します。 ## 3 つの関係 ``` IEloquent (interface) ← Usecase が依存する契約 ↑ ├── Eloquent ← 本番。標準 SOQL / DML をそのまま叩く └── MockEloquent ← テスト。DB を介さず、Spy + failOn* を提供 ``` Usecase は `IEloquent` 型のフィールドに本番では `new Eloquent()`、テストでは `new MockEloquent(...)` を Layered Constructor で受け取ります。 ## IEloquent (インターフェース) すべての契約メソッドは `Eloquent` と `MockEloquent` の両方で実装されています。 ### ラベリング系 | シグネチャ | 戻り値 | 用途 | |---|---|---| | `label(String labelName)` | `IEloquent` | 直後の 1 操作にラベルを付与してチェーン可能にする。1 つの `IEloquent` を「先月用」「今期用」「Account 更新用」のように **ラベルで多重化** できるので、用途ごとに `IEloquent` を分けて DI する代わりに 1 本にまとめられる | ```apex // 同じ IEloquent で 2 つの SOQL と 1 つの DML を区別する IEntry job = this.eloquent.label('jobLoad').firstOrFail(jobScribe); List details = this.eloquent.label('detailLoad').get(detailScribe); this.eloquent.label('finalDml').doUpdate(toUpdate); ``` `null` / 空文字を `label(...)` に渡すと例外になります。 `'default'` は **ラベルなしの操作に内部で割り当てられる名前**です。`label('default')` 自体は通りますが、ラベルなしの呼び出しと同じ枠を指すことになるので、意図が伝わりません。別の名前を付けてください (`whenLabel('default')` のほうは冗長として明示的に弾かれます)。 **opt-in strict mode**: `IEloquent` インスタンスで初めて `.label(...)` を呼んだ瞬間、そのインスタンスは **strict mode** にスイッチします (インスタンスの生存中ずっと sticky)。 - 以降のすべての操作は `.label(...)` を前置する必要がある (ラベルなし操作は例外) - 同じラベルを同じインスタンスで 2 回使うと例外 - 一度も `.label()` を呼ばないインスタンスは従来通り (= lenient mode、後方互換) これは「新しい DML を足したのにラベルを忘れていた」という事故を実行時に塞ぐためのガード。silent に default バケットに溜まる footgun を防ぎます。 ### 実行モード系 (v3 系のみ) | シグネチャ | 戻り値 | 用途 | |---|---|---| | `userMode()` | `IEloquent` | 以降の SOQL / DML を `AccessLevel.USER_MODE` で実行する (v3 系の既定) | | `systemMode()` | `IEloquent` | 以降の SOQL / DML を `AccessLevel.SYSTEM_MODE` で実行する。FLS・オブジェクト権限を無視する | v3 系では `Eloquent` が `inherited sharing` になり、SOQL / DML の既定が **ユーザーモード** (実行ユーザーの項目・オブジェクト権限を尊重) に変わりました。集計・焼き付け・データ移行のように「誰が起こしても完遂すべき処理」だけ `systemMode()` で明示的にオプトアウトします。 ```apex // system プロセス: 呼び出し元クラスを without sharing にしたうえで明示する this.eloquent.systemMode().label(LBL_UPDATE).doUpdate(entries); ``` - **sticky**: 一度呼ぶと、そのインスタンスの以降すべての操作に効きます (`label()` のように操作ごとにリセットされません) - **`MockEloquent` では no-op**: 自身をそのまま返すだけなので、単体テストはモードを意識せずに書けます - ⚠️ `systemMode()` が外すのは **FLS・オブジェクト権限だけ** です。**共有 (レコードの可視性) は別軸**で、外すには呼び出し元クラスを `without sharing` にする必要があります ### クエリ系 | シグネチャ | 戻り値 | 用途 | |---|---|---| | `get(Scribe scribe)` | `List` | クエリ実行。0 件なら空リスト | | `first(Scribe scribe)` | `IEntry` | 先頭 1 件。0 件なら `null` | | `firstOrFail(Scribe scribe)` | `IEntry` | 先頭 1 件。0 件なら `ApexEloquentException` | | `firstOrFail(Scribe scribe, Exception orFail)` | `IEntry` | 先頭 1 件。0 件なら **渡した例外をそのまま** throw する | | `getAsSObject(Scribe scribe)` | `List` | `SObject` リスト (最終手段) | | `firstAsSObject(Scribe scribe)` | `SObject` | 先頭 1 件の SObject 版 | | `firstOrFailAsSObject(Scribe scribe)` | `SObject` | 先頭 1 件の SObject 版、0 件で例外 | | `rawSoql(String soql)` | `List` | `Scribe` をバイパスして生 SOQL を実行 (最終手段。SELECT 漏れ検知が無効化される) | **`firstOrFail(scribe, orFail)` の用途**: 0 件を業務エラーとして画面に返したいときに使います。渡した例外がそのまま throw されるので、`first` + null 判定 + 自前 throw を書かずに済みます。 効いてくるのは **catch した先**です。業務例外を渡しておけば、そのまま `catch (UsecaseException)` で受けられます。 ```apex IEntry job; try { job = this.eloquent.label(LBL_JOB).firstOrFail( jobScribe, new UsecaseException('対象のジョブが見つかりませんでした。') ); } catch (UsecaseException ex) { this.t.skip('業務エラー: ' + ex.getMessage()); } ``` 引数なしの `firstOrFail(scribe)` だと `ApexEloquentException` が飛ぶので、この `catch` には入りません。 ### DML 系 | シグネチャ | 戻り値 | |---|---| | `doInsert(SObject record)` | `SObject` | | `doInsert(List records)` | `List` | | `doUpdate(SObject record)` | `SObject` | | `doUpdate(IEntry entry)` | `IEntry` | | `doUpdate(List records)` | `List` | | `doUpdate(List entries)` | `List` | | `doUpdate(SObject record, Boolean allOrNone)` | `Database.SaveResult` | | `doUpdate(IEntry entry, Boolean allOrNone)` | `Database.SaveResult` | | `doUpdate(List records, Boolean allOrNone)` | `List` | | `doUpdate(List entries, Boolean allOrNone)` | `List` | | `doUpsert(SObject record)` | `SObject` | | `doUpsert(IEntry entry)` | `IEntry` | | `doUpsert(List records)` | `List` | | `doUpsert(List entries)` | `List` | | `doUpsertByExternalId(SObject record, Schema.SObjectField externalIdField, Boolean allOrNone)` | `Database.UpsertResult` | | `doUpsertByExternalId(IEntry entry, Schema.SObjectField externalIdField, Boolean allOrNone)` | `Database.UpsertResult` | | `doUpsertByExternalId(List records, Schema.SObjectField externalIdField, Boolean allOrNone)` | `List` | | `doUpsertByExternalId(List entries, Schema.SObjectField externalIdField, Boolean allOrNone)` | `List` | | `doDelete(SObject record)` | `void` | | `doDelete(IEntry entry)` | `void` | | `doDelete(List records)` | `void` | | `doDelete(List entries)` | `void` | bulk 化を基本とし、単件版は処理対象が常に 1 件のときだけ使います。 **`allOrNone` 引数付き `doUpdate` の用途**: 戻り値が `Database.SaveResult` / `List` なので、partial-success 時の per-record エラー集約をユニットテストで検証できます。ETL / 増分同期 / バッチマイグレーションで「失敗したレコードだけログに残して続行する」ようなパスがある時に使います。 **`doUpsertByExternalId` の用途**: 標準 `Database.upsert(records, externalIdField, allOrNone)` の API を `IEloquent` 抽象の下に取り込んだもの。ETL / 増分同期 / バッチマイグレーションで必須となる「外部 ID キーによる upsert」を、本番 / Mock 共通の契約で扱えます。 ## Eloquent (Production) `IEloquent` を実装する本番クラス。標準の SOQL / DML をそのまま叩きます。**追加の public メソッドは無し** (インターフェース通り)。 ```apex IEloquent eloquent = new Eloquent(); List opps = eloquent.get(scribe); eloquent.doUpdate(opps); ``` ## MockEloquent (Mock 拡張) `IEloquent` の本契約に加えて、テスト用の **Spy プロパティ** と **failOn シリーズ** を持ちます。 ### コンストラクタ | シグネチャ | 振る舞い | |---|---| | `new MockEloquent()` | 空。ラベルを使う場合は `attach(...)` でデータを与える | | `new MockEloquent(IEntry entry)` | ラベルなし (`'default'`) の返却データとして 1 件をプリロード | | `new MockEloquent(List entries)` | 同上、リスト版 | コンストラクタで渡したデータが供給されるのは **ラベルなし (`'default'`) の操作だけ**です。本番コードが `label(...)` を使っている場合は、コンストラクタではなく `attach(label, ...)` で与えます。 `MockEloquent` は **`Scribe` の WHERE 条件を評価せず**、渡したリストをそのまま返します。条件違いのクエリを区別したい場合は、**クエリ単位にラベルを付けて `attach(label, ...)` で仕分けます** (下記)。 ### 未 attach のラベルは例外になる `label('X')` で `get` / `first` / `firstOrFail` を呼んだのに `attach('X', ...)` していない場合、**テスト実行時は例外**になります。エラーには attach 済みのラベル一覧が付くので、打ち間違いはその場で分かります。 これは「ラベル名を間違えた → 0 件が返る → 対象なしでスキップの分岐に入る → **何も検証していないのにテストが緑**」という偽陽性を塞ぐためのガードです。 「0 件の経路」を意図してテストしたいときは、**空リストを明示的に attach** して意図を宣言します。 ```apex MockEloquent mock = (new MockEloquent()) .attach(MyUsecase.LBL_FETCH, new List()); ``` > 古いバージョンから上げると、この変更で赤くなるテストが出ることがあります。それは「attach 漏れで何も検証せずに緑だった」テストです。機械的に空 attach を足して緑に戻すのではなく、本来そこに注入すべきだったデータを確認してください。 ### Spy プロパティとラベル別アクセサ | プロパティ / メソッド | 型 | 中身 | |---|---|---| | `upsertedRecords` | `List` | `doInsert` / `doUpdate` / `doUpsert` / `doUpsertByExternalId` で渡されたレコードのうち **`'default'` バケット** の累積 (※ `@deprecated`: 新規コードは `upsertedRecordsAt('default')` を推奨) | | `deletedCount` | `Integer` | `doDelete` の呼び出し件数のうち **`'default'` バケット** の累計 (※ `@deprecated`: 新規コードは `deletedCountAt('default')` を推奨) | | `upsertedRecordsAt(String label)` | `List` | 指定ラベルの DML レコード累積 | | `deletedCountAt(String label)` | `Integer` | 指定ラベルの delete 件数累計 | | `attach(String label, IEntry entry)` | `MockEloquent` | 指定ラベルのクエリ返却データとして 1 件をプリロード (チェーン可能、同じラベルへの再 attach は上書き) | | `attach(String label, List entries)` | `MockEloquent` | 同上、リスト版 | | `failSave(Id recordId, String errorMessage)` | `MockEloquent` | 指定 Id のレコードだけ **保存失敗** させる (チェーン可能) | ### failSave() で部分失敗を作る `allOrNone` 付きの DML (`doUpdate(records, false)` / `doUpsertByExternalId(..., false)`) の `SaveResult` / `UpsertResult` を、**レコード単位で失敗**にできます。対象は Id で指名するので、`MockEntry.autoId()` と対で使います。 ```apex Id badId = MockEntry.of(Account.class).autoId(2).getId(); MockEloquent mock = (new MockEloquent()) .failSave(badId, '入力規則で拒否されました'); ``` - `allOrNone = false` — そのレコードの結果が `success = false` と指定メッセージを持ち、**Spy には積まれません** (保存されていないため) - `allOrNone = true` — 実際の all-or-nothing DML と同じく、**何も記録される前に操作全体が例外**になります 「失敗したレコードだけログに残して続行する」パスを、実 DML なしで検証できます。 ```apex // ラベルなしの従来の使い方 (後方互換) MockEloquent updateEloquent = new MockEloquent(); (new MyUsecase(input, fetchEloquent, updateEloquent)).invoke(); Assert.areEqual(1, updateEloquent.upsertedRecords.size()); Opportunity updated = (Opportunity) updateEloquent.upsertedRecords[0]; Assert.areEqual('Technology', updated.Industry__c); ``` ```apex // ラベル多重化版 — 1 つの MockEloquent でクエリ返却と DML 検証をラベルで仕分け MockEloquent mock = (new MockEloquent()) .attach('jobLoad', jobEntry); (new FinalizeJobUsecase(jobId, mock)).invoke(); Assert.areEqual(1, mock.upsertedRecordsAt('finalDml').size()); ``` **後方互換性ノート**: 既存の `upsertedRecords` / `deletedCount` パブリックフィールドは引き続き動作し、内部的に `'default'` バケットを反映します。JSDoc 上で `@deprecated` 化されており、段階的に `*At('default')` への移行が推奨されます。また、レガシーコンストラクタ `new MockEloquent(List)` も引き続き動作し、渡されたデータは `'default'` ソースに供給されます。 ### failOn シリーズ (例外シミュレーション) 各メソッドは引数なし版と `Exception` 受け取り版の 2 オーバーロード。`Exception` を渡さない場合はデフォルト例外が投げられます。 | メソッド | 対応する契約 | |---|---| | `failOnGet()` / `failOnGet(Exception e)` | `get(scribe)` | | `failOnFirst()` / `failOnFirst(Exception e)` | `first(scribe)` | | `failOnFirstOrFail()` / `failOnFirstOrFail(Exception e)` | `firstOrFail(scribe)` | | `failOnGetAsSObject()` / `failOnGetAsSObject(Exception e)` | `getAsSObject(scribe)` | | `failOnFirstAsSObject()` / `failOnFirstAsSObject(Exception e)` | `firstAsSObject(scribe)` | | `failOnFirstOrFailAsSObject()` / `failOnFirstOrFailAsSObject(Exception e)` | `firstOrFailAsSObject(scribe)` | | `failOnRawSoql()` / `failOnRawSoql(Exception e)` | `rawSoql(soql)` | | `failOnDoInsert()` / `failOnDoInsert(Exception e)` | `doInsert(*)` | | `failOnDoUpdate()` / `failOnDoUpdate(Exception e)` | `doUpdate(*)` | | `failOnDoUpsert()` / `failOnDoUpsert(Exception e)` | `doUpsert(*)` | | `failOnDoUpsertByExternalId()` / `failOnDoUpsertByExternalId(Exception e)` | `doUpsertByExternalId(*)` | | `failOnDoDelete()` / `failOnDoDelete(Exception e)` | `doDelete(*)` | ### whenLabel() で失敗をラベルにスコープする | シグネチャ | 用途 | |---|---| | `whenLabel(String label)` | 直前の `failOn*()` を **特定のラベルでのみ発火** するようスコープする (チェーン可能) | ```apex // label('finalDml') の DML だけが失敗する、 他のラベルは正常に動く MockEloquent mock = (new MockEloquent()) .failOnDoUpdate(new DmlException('Simulated finalDml failure')) .whenLabel('finalDml'); ``` 「fail on get when label is X」のように、ラベル多重化 Usecase で「この副作用だけ落としたい」ケースを表現できます。 ```apex MockEloquent mock = (new MockEloquent()) .failOnDoUpdate(new DmlException('Simulated DML failure')) .whenLabel(MyUsecase.LBL_UPDATE); try { (new MyUsecase(input, mock)).invoke(); Assert.fail('Expected exception'); } catch (DmlException e) { Assert.isTrue(TraceFlow.isLastAbort()); } ``` ### repeat() で「常に失敗」を表現 | シグネチャ | 用途 | |---|---| | `repeat()` | 直前の `failOn*` を **以降ずっと繰り返し** たい時に呼ぶ (回数指定はできない) | `failOn*()` 単独だと「次の 1 回だけ失敗」です。`failOn*` は **同じメソッドに対してキューに積まれる** ので、2 回並べれば 1 回目と 2 回目が失敗し 3 回目は成功します (リトライの成功パス)。 `.repeat()` を付けると、直前に仕掛けた設定が **以降すべての呼び出しで失敗** し続けます。「最大 3 回までリトライ、それでもダメなら中止」というロジックの **中止側のパス** を、回数を数えずに確かめられます。 キューは **ラベルごとに独立** しています。`whenLabel` を付けなかった設定は、ラベルなしの呼び出し (`'default'`) 宛てになります。 ⚠️ テスト設定のビルダー (`attach` / `failOn*` / `whenLabel` / `failSave` / `repeat`) は、いずれも **`this` を変更せず新しいインスタンスを返します**。戻り値を受け取らないと設定が消えるので、チェーンでつなぐか受け直してください。 💡 ラベルの「1 度だけ」制約が数えるのは **成功した操作** です。失敗した操作はラベルを解放するため、本番コードが catch して同じラベルでリトライする実装もそのままテストできます。 ⚠️ ラベルを使っているテストで `whenLabel` を付け忘れると、その設定は `'default'` 宛てになり永久に発火しません。これは診断メッセージ付きの例外として検出されます (付けるべきラベル名も表示されます)。 ### 表現できる失敗パターン `failOn*` / `whenLabel` / `repeat` の組み合わせで書ける形は、次の 5 つに整理できます。 | 書き方 | 起きること | |---|---| | `failOnGet(exA).failOnGet(exB)` | 1 回目は `exA`、2 回目は `exB` で失敗し、**3 回目は成功** | | `failOnGet(ex).repeat()` | **毎回失敗** | | `failOnGet(ex).whenLabel('opp')` | `'opp'` の呼び出しが 1 回失敗し、**リトライすれば成功** | | `failOnGet(exA).whenLabel('opp')`
`.failOnGet(exB).whenLabel('opp')` | `'opp'` が 2 回連続で失敗し、**3 回目は成功** | | `failOnGet(ex).whenLabel('opp').repeat()` | `'opp'` の呼び出しが**毎回失敗** | ```apex // 「1 回失敗 → リトライで成功」を書く MockEloquent mock = (new MockEloquent()) .attach('opp', new List{ oppEntry }) .failOnGet(new QueryException('boom')) .whenLabel('opp'); // 1 回目: 例外 // 2 回目: 失敗がラベルを解放しているので、attach したデータが返る ``` キューは**ラベルごとに独立**しているので、同じメソッドに対してラベル違いの失敗を並べても、互いに干渉しません。 ```apex // 取得は 'fetch' で、保存は 'update' で、それぞれ別の理由で落とす MockEloquent mock = (new MockEloquent()) .failOnGet(new QueryException('取得に失敗')).whenLabel('fetch') .failOnDoUpdate(new DmlException('保存に失敗')).whenLabel('update'); ``` これらに `failSave` (呼び出しは成功するが特定レコードだけ保存されない) を加えたものが、`MockEloquent` で表現できる失敗の全体です。 ## 次に読む - [データ取得と DML、IEntry、Mock](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-data-access): 使い方ガイド - [API リファレンス: Scribe](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-scribe): クエリ組み立て側 - [API リファレンス: IEntry / Entry / MockEntry](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-entry): 返り値の `IEntry` 側 - [Layered Constructor Pattern](https://krileworks.com/ja/apex-stem/docs/layered-constructor-pattern): `IEloquent` を Usecase に DI する設計 - [ApexEloquent ガイド](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-guide): ガイド目次に戻る ============================================================================== Source: https://krileworks.com/document/ja/apex-eloquent-api-entry.md Page: https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-entry ============================================================================== # API リファレンス: IEntry / Entry / MockEntry `IEntry` は ApexEloquent のレコードラッパー (インターフェース)。`Eloquent.get(scribe)` などの戻り値が `List` で返ります。本番は `Entry`、テストは `MockEntry` を使います。 使い方や典型シナリオは [データ取得と DML、IEntry、Mock](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-data-access) と [親項目・子サブクエリ・多対多](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-relations) を参照してください。 ## 3 つの関係 ``` IEntry (interface) ← Usecase / ビジネスロジックが扱う型 ↑ ├── Entry ← 本番。SObject や AggregateResult を内包 └── MockEntry ← テスト。SObject 型に縛られず、自由にフィールドを set できる ``` `IEntry` は `SObject` ラッパーであり、SObject では作れないテストデータ (数式項目・ロールアップ・親リレーション・auto-number への書き込み) を `MockEntry.set` 経由で表現できます。また `AggregateResult` も同じ `IEntry` インターフェースで扱えるので、SObject クエリと集計クエリの取り出し側コードを揃えられます。 ## IEntry (インターフェース) ### フィールドアクセス | シグネチャ | 戻り値 | 用途 | |---|---|---| | `get(String fieldName)` | `Object` | 任意フィールドの値取得 (キャスト必須) | | `put(String fieldName, Object value)` | `void` | 値の書き込み | | `getId()` | `Id` | Id 専用 getter (キャスト不要) | | `getName()` | `String` | Name 専用 getter (キャスト不要) | | `getRecord()` | `SObject` | 内包する `SObject` を取り出す (最終手段) | | `setRecord(SObject record)` | `IEntry` | 内部用。SObject 差し替え | | `setFieldStructure(FieldStructure fs)` | `IEntry` | 内部用。SELECT 漏れ検知のスキーマ設定 | | `setDescribeResult(Schema.DescribeSObjectResult)` | `void` | 内部用 | ```apex IEntry oppEntry = eloquent.first(oppScribe); Id oppId = oppEntry.getId(); String name = oppEntry.getName(); String industry = (String) oppEntry.get('Industry__c'); oppEntry.put('Status__c', 'Active'); ``` ### リレーション (読み取り) | シグネチャ | 戻り値 | 用途 | |---|---|---| | `getParent(String parentIdFieldName)` | `IEntry` | 親レコードを取得 (例: `'AccountId'`) | | `getChildren(String name)` | `List` | 子レコードのリスト | | `getThrough(String junctionName, String relatedKey)` | `List` | 多対多。Junction オブジェクト経由で関連先を取得 | `getChildren` / `getThrough` の第 1 引数は、**オブジェクト名と relationship 名のどちらでも解決されます**。まずオブジェクト名として探し、見つからなければ relationship 名として解決します。 ```apex // どちらでも同じ結果になる List opps = accountEntry.getChildren('Opportunity'); // オブジェクト名 List opps = accountEntry.getChildren('Opportunities'); // relationship 名 ``` `Scribe` 側で `relationName(...)` を明示した場合は、その名前でも引けます。 #### 非推奨: `*ByRelationName` | シグネチャ | 代替 | |---|---| | `getChildrenByRelationName(String childRelationName)` | `getChildren(String)` | | `getThroughByRelationName(String junctionRelationName, String relatedKey)` | `getThrough(String, String)` | 上記の名前解決が統合されたことで、relationship 名専用の入口は不要になりました。後方互換のため残っていますが、**新規コードでは使わないでください**。将来のバージョンで削除される可能性があります。 ## Entry (Production) `IEntry` を実装する本番クラス。SObject 由来と AggregateResult 由来の両モードをサポートし、`Scribe` の選択フィールドに基づいた SELECT 漏れ検知を内部で行います。**追加の public メソッドは無し**。 `Eloquent.get(scribe)` の戻り値の各要素は `Entry` インスタンスです。 ### バッチ状態に載せられます (v3.4.1+) `IEntry` を `Database.Stateful` のバッチのインスタンス変数として持ち回れます。 > ⚠️ **v3.4.0 以前は、チャンクをまたぐ時点で `SerializationException` になることがありました。** 内部に持つ `Schema.DescribeSObjectResult` がシリアライズ不可のためです。しかも**キャッシュが温まっているかどうかで発生が変わる**ため (その型で最初にキャッシュミスしたインスタンスだけが値を持つ)、同じコードが通ったり落ちたりしました。v3.4.1 で該当フィールドを `transient` にし、必要なときに都度導出する形に変えて解消しています。 ## MockEntry (Mock 拡張) テスト用の `IEntry` 実装。`IEntry` の契約に加え、テストデータ構築用の拡張 API を多数持ちます。 ### ファクトリ | シグネチャ | 用途 | |---|---| | `MockEntry.of(System.Type recordType)` | 通常の SObject 型でエントリを作る (`MockEntry.of(Account.class)`) | | `MockEntry.asAggregateResult()` | 集計クエリ結果用のエントリを作る (SObject 型に紐づかない) | | `MockEntry.asAggregateResult(Map fieldToValue)` | 同上、初期値付き | ### フィールド操作 | シグネチャ | 戻り値 | 用途 | |---|---|---| | `set(String fieldName, Object value)` | `MockEntry` | フィールドへの値設定 (書き込み不可項目も OK) | | `template(Map fieldToValue)` | `MockEntry` | 複数フィールドを Map で一括セット | ```apex MockEntry accEntry = MockEntry.of(Account.class) .template(new Map{ 'Name' => 'Acme Co.', 'Industry' => 'Technology' }) .set('Active__c', true); ``` **SObject フィールド名 typo の即時検知**: `set` / `add` / `setParent` / `addParent` で **存在しない SObject フィールド名** を渡すと、即座に `ApexEloquentException` が投げられます。 ```apex MockEntry.of(Account.class).set('Naame', 'foo'); // → ApexEloquentException ("The field 'Naame' does not exist on the SObject.") ``` これまでは typo は `fieldToValue` に黙って入り、SUT が正しいフィールド名 (`get('Name')`) で取りに来た時に `null` が返って、テストは通るが本番で落ちる、という偽陽性の温床でした。今は **テストセットアップの瞬間に叩き出される** ので、セットアップ起源の偽陽性が構造的に塞がれます。 なお `put` は標準 `SObject.put` 経由でフィールド名検証が走るので、元から typo は弾かれており、挙動変更はありません。 > `set` には `add(String, Object)` という同義のメソッドもあります。挙動は同じ (どちらも新しい `MockEntry` を返す) なので、新規コードは `set` に寄せてください。`setParent` / `setChildren` にも同様に `addParent` / `addChildren` があります。 ### SELECT 契約を焼き付ける: fetchedBy | シグネチャ | 戻り値 | 用途 | |---|---|---| | `fetchedBy(Scribe scribe)` | `MockEntry` | その `Scribe` の SELECT 句を、このエントリの契約として焼き付ける | `MockEloquent` 経由で返すエントリは、**`get(scribe)` に渡した `Scribe` から契約が自動的に付きます**。SELECT していない項目に触れば例外になる、あの仕組みです。 一方、**エントリを直接 SUT に渡す場合は契約がありません**。`Scribe` を通っていないので、どの項目が SELECT 済みかを知りようがないからです。`fetchedBy` は、そこに契約を後付けします。 ```apex MockEntry card = MockEntry.of(BusinessCard__c.class) .autoId(1) .set('CompanyName__c', 'Acme') .fetchedBy(RematchCompanyCardsHandler.scope()); ``` これが効くのは、主に **バッチ**です。`execute(bc, scope)` に渡ってくるレコードは `IEloquent` を通らないため、本番では実クエリの結果としてプラットフォームが検査してくれますが、テストでは自前で組んだエントリなので**何も検査されません**。クエリの組み立てを `@TestVisible` なメソッドに切り出しておけば、本番とまったく同じ `Scribe` をテストから渡せます。 詳しくは [API リファレンス: Scribe](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-scribe) の「Scribe を切り出すと、テストでも SELECT 漏れ検知が効く」を参照してください。 > 本番の `Entry` に `fetchedBy` は要りません。実 SOQL の結果は、未 SELECT の項目に触れた時点でプラットフォーム自身が例外を投げるためです。これはモック側だけに必要な厳密化の道具です。 ### リレーション (組み立て) | シグネチャ | 戻り値 | 用途 | |---|---|---| | `setParent(String parentIdFieldName, MockEntry parent)` | `MockEntry` | 親レコードをぶら下げる | | `setChildren(String name, List children)` | `MockEntry` | 子レコードリストをぶら下げる | `setChildren` の第 1 引数は、Scribe 側で `relationName` を指定したならその名前、指定していないなら **オブジェクト名** (`'Opportunity'` のように単数、`__r` なし)。Scribe と MockEntry で同じキーを使う必要があります。 ### ID 自動生成 | シグネチャ | 戻り値 | 用途 | |---|---|---| | `autoId(Integer suffix)` | `MockEntry` | 18 桁の Id を自動生成 (数値サフィックス) | | `autoId(String suffix)` | `MockEntry` | 18 桁の Id を自動生成 (文字列サフィックス、`{#}` などのプレースホルダ展開対応) | ### 量産 (times) | シグネチャ | 戻り値 | 用途 | |---|---|---| | `times(Integer count)` | `List` | テンプレートから `count` 件展開。プレースホルダ `{#}` `{A}` `{a}` を連番に置換 | | `times(Integer count, Integer startAt)` | `List` | 連番の開始番号を指定 | | `times(Integer count, Integer startAt, Integer interval)` | `List` | 連番の開始番号と increment を指定 | ```apex List contacts = MockEntry.of(Contact.class) .autoId('{#}') .set('LastName', 'Contact-{#}') .alias('con_{#}') .times(3); // con_1, con_2, con_3 ``` **ネストした掛け算には未対応**: `MockEntry.of(Account.class).times(2)` の中で子側に `times(4)` を持たせて「親 2 件 × 子 4 件ずつ」のように **階層的に掛け算で展開する** ことはできません。子側を個別に `setChildren` 内で列挙する形で組み立ててください。 ### alias (生成 Id の取り出し) | シグネチャ | 戻り値 | 用途 | |---|---|---| | `alias(String name)` | `MockEntry` | このエントリに名前を付ける | | `getByAlias(String name)` | `MockEntry` | 再帰検索で alias から MockEntry を取得 | | `getAliasId(String name)` | `Id` | alias の自動生成 Id を取得 (アサーション用に便利) | ```apex MockEntry oppEntry = MockEntry.of(Opportunity.class) .alias('opp').autoId(1); Id oppId = oppEntry.getAliasId('opp'); // テストで: Opportunity updated = (Opportunity) mock.upsertedRecordsAt(MyUsecase.LBL_UPDATE)[0]; Assert.areEqual(oppId, updated.Id); ``` ### 検知の無効化 (2 系統) `MockEntry` の検知には **2 系統** あり、それぞれ独立した escape hatch を持ちます。どちらも原則使いません (検知が偽陽性を塞いでいるため)。 | シグネチャ | 戻り値 | 無効化するもの | |---|---|---| | `withoutFieldValidation()` | `MockEntry` | **Scribe FieldStructure** チェック (`Scribe` で SELECT されていないフィールドへの `get(...)` アクセスを許可) | | `withoutSObjectFieldValidation()` | `MockEntry` | **SObject フィールド名** チェック (`set` / `add` / `setParent` / `addParent` で実在しないフィールド名を許可) | ```apex // 例: 合成フィールド名でデータを保持したいレアケース MockEntry.of(Account.class) .withoutSObjectFieldValidation() .set('Synthetic__c', 'value'); // Account には存在しないが許可される ``` **この 2 つは別フラグ**です。`withoutFieldValidation()` で Scribe チェックを緩めても、SObject typo 検知は閉じたまま (= `set('Naame', ...)` のような明らかな typo は依然として叩き出される)。逆も同様。各 escape hatch は自分の責務だけを無効化し、もう片方の安全網は維持されます。 ## 次に読む - [データ取得と DML、IEntry、Mock](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-data-access): 使い方ガイド - [親項目・子サブクエリ・多対多](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-relations): リレーション操作の典型例 - [API リファレンス: IEloquent / Eloquent / MockEloquent](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-eloquent): `IEntry` を返す側 - [API リファレンス: Scribe](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-scribe): SELECT 漏れ検知の元データを作る側 - [ApexEloquent ガイド](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-guide): ガイド目次に戻る ============================================================================== Source: https://krileworks.com/document/ja/query-delegation-pattern.md Page: https://krileworks.com/ja/apex-stem/docs/query-delegation-pattern ============================================================================== # Query Delegation Pattern クエリの「構築」と「実行」を分離する、ApexEloquent の設計哲学を解説します。Salesforce 開発で広く採用される Selector Pattern の長期運用課題から出発し、Query Delegation Pattern がなぜそれを解くのか、ApexEloquent がそれをどう体現しているのかをまとめます。 ## Introduction Salesforce 開発の現場では、データアクセスを整理する手段として **Selector Pattern** が広く採用されてきました。クエリをひとつの場所に集めることで、ビジネスロジック側からは生 SOQL を見せず、メソッド呼び出しだけでデータが取れる、というシンプルな利点があります。 しかし、Selector Pattern を中規模以上のプロジェクトで長期運用すると、「ちょっと違うクエリが欲しい」という要求がたびたび発生し、メソッドや引数を追加するパッチが積み重なっていきます。Selector のクラスは膨らみ、当初のシンプルさは少しずつ失われていきます。さらに、テスト自体が DB を必要とするため、結合テストしか書けないという制約もついて回ります。 ### Selector Pattern の長期運用で起きること - ユースケースごとに微妙に違うクエリが必要になり、メソッドや引数が継ぎ足されて Selector が肥大化する - 用途別に Selector を分割すると、似たような Selector クラスとインターフェースが大量に生まれる - フラグ引数や条件分岐が積み上がり、メソッドの読みやすさと保守性が下がっていく - あるユースケース向けのクエリ変更が、別の機能に予期しない影響を与える - テストは Selector に対するモッククラスの実装と DB アクセスを伴う結合テストに偏る ### 「分離」が必要だと感じた理由 これらの状況に向き合っていくうちに、ひとつの問いに辿り着きました。 > 「クエリの構築とクエリの実行は、本当に同じ責務なのか?」 どのオブジェクトに、どんな条件でクエリを投げるかを決めるのは、ドメインのルールやユースケースに深く根ざした **ビジネスロジックの一部** です。一方、組み立てられたクエリを実際に DB に投げ、結果を返す処理は、純粋な **I/O** でしかありません。 この 2 つを Selector の中に同居させるのではなく、明確に切り分けたい。そう考えた末に行き着いたのが Query Delegation Pattern という発想です。 ## Problems with Traditional Patterns ### クエリ構築と I/O が混ざることで起きる問題 Selector は典型的に、「何のデータをどう取り出すか」を決めるクエリ構築と、「実際に DB に問い合わせて結果を取り出す」実行処理の両方を抱えます。クエリ構築はビジネスロジックに近く、実行はインフラ層に属する、と整理すると、本来この 2 つは **別の責務** です。 これらが Selector の中に混ざると、徐々に次のような状態に陥っていきます。 - クエリの組み立てが Selector の内側に閉じ込められ、ドメイン側からは「何をどう取りに行っているのか」が見えづらくなる - クエリの修正が、その Selector を使うすべての機能に波及するリスクを伴う - テストには Selector インターフェースに対するモック実装が必要で、テストごとに使い分けるためにバリエーションを準備する手間が増える - ひとつの Selector に処理が集中すると、メソッドを足し続ける運用になり、継続的な機能開発の足を引っ張る - ユースケースごとに Selector を分割しても、似たようなインターフェースと実装クラスが何個も並ぶことになり、テスト構築への心理的負担が増えていく 理想的には、「どんなデータが必要か」はユースケース側 (ドメイン側) で明示的に表現し、Selector には「実行」だけを委ねる形にしたい。これが Query Delegation Pattern が出発する地点です。 ### 再利用性と文脈の喪失 Selector の魅力のひとつは **再利用性** です。「特定条件で絞り込んでレコードを取得する」メソッドがひとつあれば、複数のユースケースから共通で使えます。 しかしこの再利用性は、しばしば **ビジネス上の意図の不明瞭さ** と引き換えになります。汎用的に設計された Selector メソッドは、「なぜそのデータが必要なのか」「どんな文脈で使われるのか」という情報を失いがちです。 > 💭 Selector のメソッドは、汎用的に作るほど「具体的なビジネス意図」から遠ざかっていく。 この問題はテストの観点からも軽視できません。ビジネス意図が曖昧なメソッドは、テストケースを書くときに「何を検証すればよいか」が漠然としやすく、結果としてテストの厚みが不足しがちになります。 ## What is Query Delegation Pattern? ### 基本コンセプト Query Delegation Pattern は、「クエリ構築」と「クエリ実行」の責務を明確に分離するアプローチです。 | 責務 | 担い手 | 性質 | |---|---|---| | **Query Construction** (クエリ構築) | ドメイン側 (Usecase) | ビジネスルールに基づいて条件を組み立てる | | **Query Execution** (クエリ実行) | ApexEloquent 内蔵の `IEloquent` | 組み立て済みクエリを受け取り、DB との I/O を引き受ける | 従来の Selector は両方の責務を抱えていましたが、Query Delegation Pattern では次のように分けます。 1. **ドメイン側** (Usecase) が、クエリの設計図を組み立てる 2. **実行側** (`IEloquent`) は、組み立て済みクエリを受け取り、それを実行する > 💡 実行側が関心を持つのは「何を取るか」ではなく「どう取るか」だけ、という設計に切り替わります。 ### ApexEloquent における役割分担 ApexEloquent はこの分離をフレームワークレベルで実現するために、3 つのコア機能を提供します。 | 役割 | クラス | 説明 | |---|---|---| | **Query Builder** | `Scribe` | クエリの設計図を、型ヒントとメソッドチェーンで組み立てる immutable なビルダー | | **Data Access** | `IEloquent` (`Eloquent` / `MockEloquent`) | 設計図を受け取って SOQL を発行する側。本番は `Eloquent`、テストは `MockEloquent` を DI で差し替える | | **Record Wrapper** | `IEntry` (`Entry` / `MockEntry`) | 取得結果のラッパー。SObject と AggregateResult を同じインターフェースで扱える | ## Query Reusability ### クエリの再利用をどう扱うか Query Delegation Pattern では、クエリは基本的にユースケースごとに個別に組み立てる方針を取ります。そのほうがビジネス意図がコードに残るからです。一方で、「このクエリは複数のユースケースから共通で使いたい」という場面も現実には存在します。 ApexEloquent はこの問題に対して、**2 つの選択肢** を提供します。チームの規模、ドメインの複雑さ、クエリの共通度合いを見て、「このプロジェクトではどちらに倒すか」を意識的に選びます。 ### 選択肢 1: Usecase 内に閉じ込める ビジネスロジックのそばで `Scribe` を組み立て、文脈を完全に保つアプローチ。Apex Stem の [Handler-Usecase Architecture](https://krileworks.com/ja/apex-stem/docs/handler-usecase-architecture) ではこちらが原則です。 メリット: - なぜそのデータが必要なのか、という文脈がコードに残る - 別のユースケースへの予期しない影響が起きにくい - ユースケースを読むだけで、必要なデータの全体像が見える ### 選択肢 2: Selector 派生としての「クエリ保管庫」を持つ 共通化したい `Scribe` をユーティリティ的なクラスに切り出し、必要なユースケースだけが明示的に取り込むアプローチ。Selector Pattern の派生として、「クエリの部品」を提供するスタイルです。 メリット: - 重複が大きいクエリを 1 箇所に集約できる - 共通化したクエリが Scribe オブジェクトとして渡されるので、ユースケース側でさらにチェーンメソッドで条件を継ぎ足せる - Selector Pattern からの段階的移行が自然に取れる 利用側のイメージ: ```apex // 1. Id で絞り込む基本クエリを保管庫から取得 Scribe oppScribe = OpportunityVault.getById(oppId); // 2. ユースケース固有の条件を継ぎ足す oppScribe = OpportunityVault.addNameCondition(oppScribe, '%TestName%'); // 3. 見積を子サブクエリとして追加 List quoteFields = new List{ 'Id', 'Name', 'GrandTotal' }; oppScribe = OpportunityVault.addQuotes(oppScribe, quoteFields); // 4. 実行は IEloquent に委譲 (Query Delegation) List entries = this.eloquent.get(oppScribe); ``` ここでは商談のクエリ保管庫として **Vault** という単語を使用しています。 保管庫の各メソッドは `Scribe` を返すだけで、SOQL の発行は最後の `IEloquent.get(scribe)` に集約されます。これが Query Delegation Pattern と「保管庫」スタイルの両立を成立させる仕組みです。従来の Selector のように 「メソッドの中で SOQL を実行して結果を返す」形だと、ユースケース側から条件を継ぎ足す余地がなく、結局メソッドや引数のバリエーションを増やすしかなくなります。 ### どちらを選んでも成立する理由 いずれの場合も、`Scribe` が `.field()` / `.whereEqual()` などのメソッドチェーンで **クエリを部品として組み立てられる** 性質を持っていることが、両方の選択肢を成立させています。ユースケース内で 1 から組むことも、保管庫から取ってきた途中状態の `Scribe` に対してさらに `.whereEqual(...)` を継ぎ足すことも、同じ書き味で扱えます。 > 🎯 どちらが正解、という話ではありません。チームの状況に合わせて選び、必要なら片方からもう片方へ移行することもできます。 ## Comparison with Traditional Patterns ### 何がどう変わるのか | 観点 | Traditional Selector | Query Delegation Pattern | |---|---|---| | **責務の置き方** | クエリ組み立てと DB 実行が同居 | ドメイン側がクエリを組み立て、`IEloquent` は実行のみ | | **長期運用** | パッチの積み重ねで肥大化、シンプルさを失う | 構造が崩れにくく、追加機能を継ぎ足しやすい | | **テストの書き方** | DB を必要とする結合テスト中心 | `MockEloquent` 差し替えで DB なしの単体テストが書ける | | **意図の可視化** | 汎用メソッド化で文脈が失われがち | ユースケースのそばに組み立てがあり、なぜそのデータが必要かが残る | ## Implementation in ApexEloquent ApexEloquent は Query Delegation Pattern をフレームワークレベルで体現します。実装上の主な特徴は以下のとおりです。 ### 動的なクエリ構築 ドメイン側が `Scribe` でクエリを組み立て、文脈に応じて条件を継ぎ足します。`Scribe` は immutable なので、共通の `Scribe` を起点に「先月用」「今期用」など複数のクエリを派生させても、互いに影響しません。 ### クエリ実行は ApexEloquent 内蔵で済む 実行側は ApexEloquent 内蔵の `IEloquent` が共通で引き受けます。自前で Selector を量産する必要がなく、組み立てた `Scribe` を渡すだけで SOQL が発行されます。 ### モック差し替えで DB なし単体テスト 構築と実行が分かれているので、`IEloquent` を `MockEloquent` に差し替えるだけで DB なしの単体テストが書けます。`MockEloquent` には `upsertedRecordsAt(label)` / `deletedCountAt(label)` といった Spy もあり、何が DML されたかをアサートできます。 ### 書き込み不可項目もモック可能 数式項目・ロールアップ・親リレーション・auto-number など、通常は書き込み不可な項目も `MockEntry` 側で自由に値を入れられます。これにより、本番では計算結果でしか取れない値も、テストでは「この値が返る前提でロジックを検証する」という形で扱えます。 ### 実装の入口 実装の入り口としては、まず [Scribe でクエリを組み立てる](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-scribe-guide) を、続いて [データ取得と DML、IEntry、Mock](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-data-access) を読むと、Query Delegation の実コードへの落とし込みがそのまま見えます。 ## Summary ### Query Delegation Pattern が解くもの Query Delegation Pattern は、Selector が抱えがちな「クエリ構築」と「DB 実行」の責務の混在を、根本から分けてしまうアプローチです。 主な効果: - 責務の明確な分離による、長期運用での保守性向上 - 複雑なモック実装を書かずに済むテスト構築の手軽さ - クエリ意図の可視化によるドメインモデルの健全化 - ビジネスロジックとデータアクセス層の結合度低下 > Query Delegation Pattern は単なる技術的な工夫ではなく、データアクセス層の設計に対する考え方そのものを、よりクリーンな方向に押し出すための哲学です。 ### 関連ドキュメント - [ApexEloquent トップ](https://krileworks.com/ja/apexeloquent): Query Delegation Pattern を採用した OSS の全体像 - [Apex Stem](https://krileworks.com/ja/apex-stem): ApexEloquent を含む 4 OSS と Handler-Usecase Architecture の組み合わせ - [Apex における Repository パターンの試行錯誤と内蔵化](https://krileworks.com/ja/apex-stem/docs/repository-pattern-challenges-builtin-solution-apex): なぜ Repository を ApexEloquent に内蔵したか (Selector Pattern との関係) - [Apex の生 SOQL から、チェーンメソッドで組み立てる ORM へ](https://krileworks.com/ja/apex-stem/docs/dynamic-query-creation-apex-eloquent): 生 SOQL から Scribe への移行入門 - [MockEntry: Apex のテストデータ作成を成立させる仕組み](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-mockentry-deep-dive): モック側の Deep Dive - [モックテストの偽陽性を検知する: SELECT 漏れの安全網](https://krileworks.com/ja/apex-stem/docs/false-positive-detection-comprehensive-guide): Scribe との連携で SELECT 漏れを単体テストで検知 - [Handler-Usecase Architecture](https://krileworks.com/ja/apex-stem/docs/handler-usecase-architecture): Query Delegation を実コードへ落とし込む場所 (Usecase 層) - [ApexEloquent ガイド](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-guide): Scribe / IEloquent / IEntry の使い方と API リファレンス ============================================================================== Source: https://krileworks.com/document/ja/apex-eloquent-mockentry-deep-dive.md Page: https://krileworks.com/ja/apex-stem/docs/apex-eloquent-mockentry-deep-dive ============================================================================== # MockEntry: Apex のテストデータ作成を成立させる仕組み Apex でビジネスロジックのテストを書こうとすると、すぐに「テストデータが作れない」という壁にぶつかります。数式項目やロールアップ集計は SObject に直接代入できず、親子関係をコードで組むには DML が必要、子レコードのリレーション名は SObject に書き戻せない、など、**動かしてみるまで埋められない項目** がたくさんあります。 ApexEloquent の `MockEntry` は、これらの **書き込み不可項目を含むテストデータを、DB を介さずに組み立てる** ためのコア機能です。数式・ロールアップ・親リレーション・auto-number をそのまま値設定でき、親子の階層もコード上で構造を保ったまま記述できます。 このドキュメントでは「MockEntry が何を解決するか」「どのような書き味で使うか」を、典型シナリオに沿って解説します。個別 API の網羅は [API リファレンス: IEntry / Entry / MockEntry](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-entry) を参照してください。 ## Apex でテストデータを書くのが難しい理由 Apex の SObject には、ビジネスロジックのテストで重要なのに **コードからは値を入れられないフィールド** が多数あります。 - 数式項目 (`Formula`) - ロールアップ集計項目 (`Roll-Up Summary`) - 自動採番項目 (`Auto Number`) - システム保持項目 (`CreatedDate` / `LastModifiedDate` / `Id` など) - 親子のリレーション名 (例: `Account` の `Contacts`、lookup の `Parent__r` など) これらは Salesforce プラットフォームが計算・付与するもので、純粋な SObject インスタンス (`new Opportunity(...)`) に手で設定することはできません。結果として、これらの値に依存するビジネスロジックをテストするには: - 親 / 子レコードを実際に `insert` してプラットフォームに数式を計算させる - ロールアップが反映されるまで再クエリする - リレーション名を辿った結果を確認するため、DML 経由でクエリし直す といった、**本来のロジック検証とは無関係な準備** が増えていきます。テストは遅く、重く、ガバナ制限を気にしながら書く対象になります。 ## 既存の常套手段: JSON シリアライズハックとその限界 書き込み不可項目を埋める手段として、Apex コミュニティでは **`JSON.deserialize` を使ったハック** が広く知られています。SObject を JSON 文字列として組み立て、デシリアライズして戻すと、通常はアクセスできない項目にも値を入れられる、というテクニックです。 ```apex // Map で組み立てて、JSON 経由で SObject にデシリアライズする Map oppMap = new Map{ 'Id' => '006000000000001AAA', 'Name' => 'Opportunity A', 'NameWithAccountName__c' => 'Opportunity A_Test Account' }; Opportunity opp = (Opportunity) JSON.deserialize(JSON.serialize(oppMap), Opportunity.class); // → 数式項目 NameWithAccountName__c に値が入った SObject インスタンス ``` 単純なケースでは確かに動きます。文字列連結とエスケープのつらさは Map で書くことで避けられますが、それでもこのハックの本質は **「JSON という別レイヤーを経由する」ことに依存している** ため、以下の代償が残ります。 - **JSON 往復のコスト**: Apex の SObject を扱うのに Map → `JSON.serialize` → `JSON.deserialize` の 3 ホップが必要。単純なフィールド設定なら気になりませんが、親子や子サブクエリの構造を組む段になると、この「JSON 経由である」ことが効いてきます - **親子関係の表現が Salesforce 固有**: 親項目や子サブクエリ風のデータを Map で組み立てる場合、Salesforce 独自の JSON 構造 (子サブクエリは `'records'` キーの下にリスト、親や子レコードに `attributes` キーが必要、など) を理解する必要があり、Apex の SObject 操作とは別の規約を覚えることになる - **テンプレート管理がメソッド量産に逆戻り**: 似たような Map 構造が複数のテストに散らばり、共通化のために結局「テストデータ生成メソッドを増やす」ことになって、Selector Pattern のメソッド爆発と同じ問題に陥る つまり JSON ハックは「動くテストを書く」ことはできても、「保守できるテスト」にはなりにくい性質を持ちます。 `MockEntry` は、この JSON ハックがやりたかったこと (= 書き込み不可項目への自由な値設定) を、**Map と JSON の往復を介さず、Apex 内で完結する専用ファクトリのメソッドチェーンで実現する** ためのコア機能です。 ```apex // Map / JSON 往復ではなく、 ファクトリ系メソッドで構造的に書く MockEntry oppEntry = MockEntry.of(Opportunity.class) .alias('opp').autoId(1) .set('Name', 'Opportunity A') .set('NameWithAccountName__c', 'Opportunity A_Test Account'); ``` `MockEntry.of(...)` を起点に、`.set` / `.setParent` / `.setChildren` / `.times` / `.alias` / `.autoId` といった専用メソッドで **構造のまま** テストデータを組めます。親子の階層も、量産パターンも、生成 Id の取り出しも、すべて型付き API で扱えます。 加えて、ApexEloquent では **`Scribe` との連携でフィールド名のタイポが二重に検出されます**。 - **Scribe レベル**: `Scribe.of(Account.class).field('TypoField__c')` のように存在しないフィールド名を指定すると、`.toSoql()` を呼ぶ過程で `The field TypoField__c does not exist on the SObject Account` という例外が投げられる。テスト実行時に検出される - **MockEntry レベル**: `Scribe` の SELECT に含まれていないフィールドへ `entry.get('XXX')` でアクセスすると、本物の `Entry` と同じく即座に例外を投げる ([MockEntry の仕組み](#mockentry-の仕組み) の「SELECT 漏れの検知」参照) 文字列キーであることのリスクは、`Scribe` と組み合わせた瞬間にテスト段階で叩き出される構造になっています。 ## MockEntry の仕組み `MockEntry` は `IEntry` インターフェースの実装で、本番用 `Entry` と同じ契約に従いながら、**テストでは任意のフィールドに値をセットできる** ように設計されています。内部的には次の 2 つの軸で動きます。 ### 1. 値の上書き (override map) `MockEntry` は、値取得時にまず内部の override map をチェックし、そこに値があればそれを返します。無ければ内包する SObject から取得します。SObject には書き込めない数式・ロールアップ・auto-number でも、override map には自由に書き込めるので、結果として **任意のフィールド値を返せる** 仕組みです。 ```apex // MockEntry.get() の挙動 (内部実装の単純化) public override Object get(String fieldName) { // 1. override map に値があればそれを優先 if (fieldToValue.containsKey(fieldName)) { return fieldToValue.get(fieldName); } // 2. 無ければ内包する SObject から取得 return record.get(fieldName); } ``` ### 2. SELECT 漏れの検知 `MockEntry` には、もう 1 つ強力な安全装置があります。`Scribe` で組み立てたクエリの **SELECT 句を覚えていて**、`entry.get('FieldName')` で取り出そうとしたフィールドが Scribe の SELECT 句に入っていなければ、本番の `Entry` と同じく **即座に例外を投げます**。 つまり、「テストでは設定したから動くが、本番では SOQL に書き忘れているフィールドにアクセスして落ちる」という典型的なバグを **単体テスト段階で検知できます**。この性質の背景については [Query Delegation Pattern](https://krileworks.com/ja/apex-stem/docs/query-delegation-pattern) でも触れています。 > 📘 詳細: 4 つの実用ケース (主オブジェクト / 親リレーション / 子サブクエリ / 集計エイリアス) で「バグ入り Usecase が正常系テストで叩き出される」動きをコード例つきで掘り下げる Deep Dive を、[モックテストの偽陽性を検知する: SELECT 漏れの安全網](https://krileworks.com/ja/apex-stem/docs/false-positive-detection-comprehensive-guide) で用意しています。 本番コードは標準の `Entry` を使い、テストコードは `MockEntry` を inject する、という構造が崩れないので、production と test で動きが乖離する心配がありません。 ## 書き込み不可項目を含むテストを書く 数式項目に依存するビジネスロジックの例を見ます。`Opportunity` に `PriceBand__c` という数式項目があり、`Amount` に応じて `'Small'` / `'Medium'` / `'Large'` のいずれかを返します。`PriceBand__c` が `'Large'` の場合のみ、`Description` の先頭に `[要承認] ` を付与する、という処理です。 ### 本番コード ```apex public with sharing class FlagLargeOppForApprovalUsecase { private final Id oppId; private final IEloquent eloquent; public FlagLargeOppForApprovalUsecase(Id oppId, IEloquent eloquent) { this.oppId = oppId; this.eloquent = eloquent ?? new Eloquent(); } public Opportunity invoke() { Scribe oppScribe = Scribe.of(Opportunity.class) .fields(new List{ 'Id', 'Description', 'PriceBand__c' }) .whereEqual('Id', this.oppId); IEntry oppEntry = this.eloquent.first(oppScribe); if (oppEntry == null) { return null; } String priceBand = (String) oppEntry.get('PriceBand__c'); if (priceBand != 'Large') { return (Opportunity) oppEntry.getRecord(); } Opportunity opp = (Opportunity) oppEntry.getRecord(); String currentDescription = opp.Description != null ? opp.Description : ''; opp.Description = '[要承認] ' + currentDescription; return (Opportunity) this.eloquent.doUpdate(opp); } } ``` ### テストコード ```apex @isTest static void testInvoke_WhenPriceBandIsLarge_ThenDescriptionIsPrefixed() { Trace t = Trace.of('正常系: PriceBand が Large のとき Description に [要承認] が付与されること'); t.start(); // Arrange: 数式項目 PriceBand__c に直接値を入れた MockEntry を組み立てる MockEntry oppEntry = MockEntry.of(Opportunity.class) .alias('opp').autoId(1) .set('Description', '大型案件') .set('PriceBand__c', 'Large'); IEloquent mockEloquent = new MockEloquent(oppEntry); // Act FlagLargeOppForApprovalUsecase usecase = new FlagLargeOppForApprovalUsecase(oppEntry.getAliasId('opp'), mockEloquent); Opportunity updatedOpp = usecase.invoke(); // Assert Assert.areEqual('[要承認] 大型案件', updatedOpp.Description); t.finish(); } ``` 数式項目 `PriceBand__c` に直接 `'Large'` を入れています。通常の `new Opportunity(Amount = 10000000)` のように本物の `Amount` を設定しても、数式項目はプラットフォームが計算するので **コード上では値が入りません** (本物の `insert` が必要)。`MockEntry.set` は override map に書くので、数式項目の値そのものを任意に指定できます。これにより、「`PriceBand__c` が `'Large'` のときのロジック」を **DB に触れず、`Amount` の境界条件を意識する必要もなく** 検証できます。 同じ要領で、ロールアップ集計項目・auto-number 項目・システム保持項目 (`CreatedDate` 等) もモックできます。 ## MockEntry が提供する機能カタログ `MockEntry` には書き込み不可項目への `.set` 以外にも、テストデータ作成を楽にする機能がいくつかあります。ここでは「何ができるか」の概要だけ示し、具体的なコード例と使い方は [データ取得と DML、IEntry、Mock](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-data-access#mockentry-を構築する) に集約しています。 - **親子の階層構造**: `setParent` / `setChildren` で親レコードと子レコードリストを構造のままぶら下げる。コードのインデントがそのままデータのリレーション構造を表すので、「この Account にどんな子レコードがぶら下がっているか」が一目で読める。親子の `Id` 連結も `MockEntry` 側で自動。詳細は [親項目・子サブクエリ・多対多](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-relations) で扱う - **大量データのパターン生成**: `times()` と `{#}` / `{A}` / `{a}` プレースホルダで 1 つのテンプレートから連番 / 大文字 / 小文字で N 件展開。`times(count, startAt, interval)` で開始番号と増分の指定も可能 (⚠️ ネストした「親 N 件 × 子 M 件ずつ」のような掛け算展開には未対応) - **生成 Id の名前付き取り出し**: `autoId` で `MockEntry` が自動生成する 18 桁 Id を、`alias('opp')` で名前付けして `getAliasId('opp')` で後から取り出せる。「upsert された Opportunity の Id が、モックで設定した Id と一致するか」をアサーションするときに使う - **集計クエリ結果のモック**: `MockEntry.asAggregateResult()` で `AggregateResult` 型に紐づかない `IEntry` を作る。`COUNT` / `SUM` / `GROUP BY` の結果を 1 グループ 1 エントリでモック 「書き込み不可項目」「親子」「量産」「集計」のいずれも、同じ `MockEntry` の API に対するメソッドチェーンで完結し、JSON シリアライズや手作りファクトリは要らない、というのが MockEntry の設計上の核です。 ## まとめ Apex のテストデータ作成が難しい理由は、「書き込み不可項目が多い」と「親子関係を組むには DML が必要」の 2 つに集約されます。これまでの常套手段である JSON シリアライズハックは前者を一応解きますが、文字列の組み立てと Salesforce 固有の JSON 構造を覚える負担を抱え込みます。`MockEntry` はこの両方を、専用ファクトリのメソッドチェーンで構造のまま解決します。 - **書き込み不可項目**: override map で任意のフィールド値を返す (JSON シリアライズ不要) - **親子関係**: `setParent` / `setChildren` で構造のまま組み立てる - **大量データ**: `times()` とプレースホルダで 1 つのテンプレートから展開 - **本番との一貫性**: `IEntry` インターフェース + SELECT 漏れ検知で、本番と同じ振る舞いを保証 - **生成 Id**: `autoId` + `alias` で名前経由で取り出してアサーション可能 - **集計クエリ**: `AggregateResult` も同じインターフェースでモック これにより、Apex のテストは「DB を必要とせずに、業務ロジックそのものを検証する」ところまで降りていけます。 ### 関連ドキュメント - [API リファレンス: IEntry / Entry / MockEntry](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-entry): `MockEntry` の全 API を網羅 - [親項目・子サブクエリ・多対多](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-relations): リレーション操作の使い方ガイド - [データ取得と DML、IEntry、Mock](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-data-access): `MockEloquent` との統合 (Spy / failOn を含む) - [Query Delegation Pattern](https://krileworks.com/ja/apex-stem/docs/query-delegation-pattern): SELECT 漏れ検知の設計哲学 - [ApexEloquent ガイド](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-guide): ApexEloquent 全体の入口 ============================================================================== Source: https://krileworks.com/document/ja/false-positive-detection-comprehensive-guide.md Page: https://krileworks.com/ja/apex-stem/docs/false-positive-detection-comprehensive-guide ============================================================================== # モックテストの偽陽性を検知する: SELECT 漏れの安全網 モックを使った高速な単体テストを書くと、テストは通るのに本番で落ちるという「偽陽性」問題に出くわすことがあります。原因はほとんどの場合「SOQL の SELECT 句に書き忘れたフィールド」です。ApexEloquent は `Scribe` との連携でこの偽陽性を構造的に塞いでいます。 MockEntry の仕組みの概要は [MockEntry: Apex のテストデータ作成を成立させる仕組み](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-mockentry-deep-dive#mockentry-の仕組み) で軽く触れています。ここでは SELECT 漏れの 4 つの実用ケース (主オブジェクト / 親リレーション / 子サブクエリ / 集計エイリアス) を深掘りしたうえで、同じ「テストだけ緑になる」を塞ぐ別の 2 つの仕組みにも触れます。 ## 偽陽性問題の核心 Salesforce のテストデータ作成は伝統的に TestDataFactory + DML insert で行われてきました。ここからモック中心のテストに乗り換えると、テストが圧倒的に速くなる代わりに、もう 1 つ別のリスクが生まれます。 本番の SOQL は次のように、SELECT 句で取得すると指定したフィールドだけが値を持ちます。 ```soql SELECT Id, StageName FROM Opportunity WHERE Id = :oppId ``` このクエリの結果に対して `opp.Amount` でアクセスすると、Salesforce は `System.SObjectException: SObject row was retrieved via SOQL without querying the requested field` を投げます。 一方、単体テストで自作モックの SObject を注入する場合、メモリ上のオブジェクトには **どのフィールドを選択した結果かという情報自体を持っていない** ので、すべてのフィールドへ自由に値を入れられるし、取り出すこともできてしまいます。 - **本番**: SELECT に無いフィールドへのアクセスで即エラー - **モックテスト**: 未選択フィールドを取り出してもエラーは出ない、テストが通る これが「**テストでは通って本番で落ちる**」タイプの障害の構造です。リファクタリングで SOQL を変更したときも、アクセス側のロジックは表面的にはコンパイルが通り、単体テストもパスする → 本番デプロイ後にエラーで発覚、という流れになります。 ApexEloquent は **モック注入時に `Scribe` の SELECT 句 (FieldStructure) と照合し、SELECT に無いフィールドへのアクセスを単体テストの段階で例外として検出します**。これが「SELECT 漏れの偽陽性検知」という独自機能です。 ## ケース 1: 主オブジェクトのフィールド漏れを検知 最もシンプルなケースから。「指定された `Opportunity` の Name を返す」Usecase を書きますが、開発者は `Scribe` の SELECT 句に `Name` を追加し忘れています。 ### バグ入り Usecase クラス ```apex public with sharing class GetOppNameUsecase { private final Id oppId; private final IEloquent eloquent; public GetOppNameUsecase(Id oppId, IEloquent eloquent) { this.oppId = oppId; this.eloquent = eloquent ?? new Eloquent(); } public String invoke() { // ❌ Name を SELECT し忘れた Scribe oppScribe = Scribe.of(Opportunity.class) .field('Id') .whereEqual('Id', this.oppId); IEntry oppEntry = this.eloquent.first(oppScribe); // この行で例外が出る (Name は SELECT されていない) return oppEntry.getName(); } } ``` ### 正常系を期待するテストが、バグで落ちる このテストは「Opportunity の Name が取得できる」ことを確認しようとする、ごく普通の正常系テストです。ところが Usecase 側のバグ (Name の SELECT 漏れ) によって `Assert` まで到達できず、`ApexEloquentException` で落ちます。 ```apex @isTest static void testInvoke_WhenOppExists_ThenReturnsName() { Trace t = Trace.of('正常系: Opportunity の Name が取得できること'); t.start(); // Arrange MockEntry oppEntry = MockEntry.of(Opportunity.class) .alias('opp') .autoId(1) .set('Name', 'Acme Opportunity'); IEloquent mockEloquent = new MockEloquent(oppEntry); GetOppNameUsecase usecase = new GetOppNameUsecase(oppEntry.getAliasId('opp'), mockEloquent); // Act // ※ Scribe で Name を SELECT 漏れしているため、invoke 内部で // ApexEloquentException が投げられ、以降の Assert には到達しない String name = usecase.invoke(); // Assert Assert.areEqual('Acme Opportunity', name); t.finish(); } ``` このテストを実行すると、`Assert.areEqual` には到達せずに `ApexEloquentException` が投げられて失敗します。例外メッセージは次のような複数行のフォーマットで出ます。 ``` ====== APEX ELOQUENT EXCEPTION ====== Location: MockEntry Error : The `fieldName` argument is Invalid. Reason : The specified field is not selected in Scribe Action : Add the field to the Scribe definition (e.g. `.field('Name')`) or call .withoutFieldValidation() when intentional Provided: [fieldName => 'Name'][objectName => 'Opportunity'] ``` `Reason` を見れば「Scribe で SELECT していない」ことが分かり、`Action` を見れば修正案が、`Provided` を見れば「どのフィールド (`Name`) を、どのオブジェクト (`Opportunity`) で取ろうとして引っかかったか」が一目で分かります。開発者は失敗メッセージを見て、`Scribe.of(Opportunity.class).fields(new List{'Id', 'Name'})` のように修正してテストを通します。 `getId()` / `getName()` も同じく検証対象です。例えば `Scribe.of(Account.class).field('Name')` (= `Id` を SELECT 漏れ) で `entry.getId()` を呼ぶと、`Id` が SELECT 句に無いとして例外が投げられます。 ## ケース 2: 親リレーションのフィールド漏れを検知 親リレーション (`parentField` でつなぐ親項目) も同じく検証されます。「指定された `Opportunity` の親 `Account` の Name を返す」Usecase を書きますが、開発者は `parentField` の中で `Name` を SELECT 句に追加し忘れています。 ### バグ入り Usecase クラス ```apex public String invoke() { // ❌ Account.Name を parentField の SELECT 句に追加し忘れた Scribe oppScribe = Scribe.of(Opportunity.class) .field('Id') .parentField( Scribe.asParent('AccountId').field('Id') // Name が抜けている ) .whereEqual('Id', this.oppId); IEntry oppEntry = this.eloquent.first(oppScribe); IEntry accountEntry = oppEntry.getParent('AccountId'); // この行で例外 (Account.Name は SELECT されていない) return accountEntry.getName(); } ``` ### 正常系を期待するテストが、バグで落ちる このテストは「親 `Account` の Name が取得できる」ことを確認しようとする正常系テストです。ところが Usecase 側のバグ (Account.Name の SELECT 漏れ) で `Assert` まで到達せずに `ApexEloquentException` で落ちます。 ```apex @isTest static void testInvoke_WhenOppExists_ThenReturnsParentAccountName() { Trace t = Trace.of('正常系: Opportunity の親 Account の Name が取得できること'); t.start(); // Arrange: モック側には Account.Name の値をセットしておく // (Scribe の SELECT に Name が含まれていなくても、モックには値がある状態) MockEntry oppEntry = MockEntry.of(Opportunity.class) .alias('opp') .autoId(1) .setParent('AccountId', MockEntry.of(Account.class) .autoId(1) .set('Name', 'Acme Corporation') ); IEloquent mockEloquent = new MockEloquent(oppEntry); GetParentAccountNameUsecase usecase = new GetParentAccountNameUsecase(oppEntry.getAliasId('opp'), mockEloquent); // Act // ※ Scribe の parentField で Account.Name を SELECT 漏れしているため、 // invoke 内部で ApexEloquentException が投げられ、以降の Assert には到達しない String accountName = usecase.invoke(); // Assert Assert.areEqual('Acme Corporation', accountName); t.finish(); } ``` ここで注目すべきは、**モック側では `Account.Name` の値をちゃんとセットしている** ことです。普通のモックなら「値があるから取り出せて当然」と動きます。しかし ApexEloquent では **`Scribe` の SELECT 句に `Name` が無いので例外** が投げられます。例外メッセージは次のような形です。 ``` ====== APEX ELOQUENT EXCEPTION ====== Location: MockEntry Error : The `fieldName` argument is Invalid. Reason : The specified field is not selected in Scribe Action : Add the field to the Scribe definition (e.g. `.field('Name')`) or call .withoutFieldValidation() when intentional Provided: [fieldName => 'Name'][objectName => 'Account'] ``` `Provided` の `objectName` が親側の SObject (`Account`) を指していて、SELECT 漏れの場所が親リレーションだとピンポイントで分かります。 ⚠️ さらに踏み込んで、`Scribe.asParent('SomeId')` で **関連として宣言していない親項目** を `getParent('SomeId')` で取り出そうとすると、別の reason で例外が投げられます。こちらは validation メッセージが少し違います。 ``` ====== APEX ELOQUENT EXCEPTION ====== Location: MockEntry.getParent(String parentIdFieldName) Error : The `parentIdFieldName` argument is Invalid. Reason : The specified parentIdFieldName is not defined as a relationship in Scribe. SObject: Opportunity Action : Ensure that the parentIdFieldName 'AccountId' is included as a relationship in the Scribe definition for this object. Provided: [parentIdFieldName => 'AccountId'] ``` 「`Scribe` で `asParent` していない親リレーションを取りに行こうとした」こと自体を、アクセスした瞬間に検出します。 ## ケース 3: 子サブクエリのフィールド漏れを検知 子サブクエリ (`withChildren`) でも同じです。`Account` 配下の `Contract` (契約) の `Name` を SELECT し忘れたケース。 「指定された `Account` 配下の `Contract` の Name 一覧を返す」Usecase を書きますが、開発者は `withChildren` の中で `Name` を SELECT 句に追加し忘れています。 ### バグ入り Usecase クラス ```apex public List invoke() { Scribe accountScribe = Scribe.of(Account.class) .field('Id') .withChildren( Scribe.asChild(Contract.class).field('Id') // ❌ Name が抜けている ) .whereEqual('Id', this.accountId); IEntry accountEntry = this.eloquent.first(accountScribe); List contracts = accountEntry.getChildren('Contracts'); List contractNames = new List(); for (IEntry contract : contracts) { // この行で例外 (Contract.Name は SELECT されていない) contractNames.add((String) contract.get('Name')); } return contractNames; } ``` ### 正常系を期待するテストが、バグで落ちる このテストは「`Account` 配下の `Contract` の Name 一覧が取得できる」ことを確認する正常系テストです。ケース 2 と同様に、**モック側では `Contract.Name` の値をちゃんとセットしている** にも関わらず、`Scribe` の SELECT 句に `Name` が含まれていないため `ApexEloquentException` で落ちます。 ```apex @isTest static void testInvoke_WhenAccountHasContracts_ThenReturnsContractNames() { Trace t = Trace.of('正常系: Account 配下の Contract の Name 一覧が取得できること'); t.start(); // Arrange: モック側には Contract.Name の値をセットしておく MockEntry accountEntry = MockEntry.of(Account.class) .alias('acc') .autoId(1) .setChildren('Contracts', new List{ MockEntry.of(Contract.class) .autoId(1) .set('Name', 'Contract 1'), MockEntry.of(Contract.class) .autoId(2) .set('Name', 'Contract 2') }); IEloquent mockEloquent = new MockEloquent(accountEntry); GetAccountContractNamesUsecase usecase = new GetAccountContractNamesUsecase(accountEntry.getAliasId('acc'), mockEloquent); // Act // ※ Scribe の withChildren で Contract.Name を SELECT 漏れしているため、 // invoke 内部で ApexEloquentException が投げられ、以降の Assert には到達しない List names = usecase.invoke(); // Assert Assert.areEqual(2, names.size()); Assert.areEqual('Contract 1', names[0]); Assert.areEqual('Contract 2', names[1]); t.finish(); } ``` この場合の例外メッセージは次のような形です。 ``` ====== APEX ELOQUENT EXCEPTION ====== Location: MockEntry Error : The `fieldName` argument is Invalid. Reason : The specified field is not selected in Scribe Action : Add the field to the Scribe definition (e.g. `.field('Name')`) or call .withoutFieldValidation() when intentional Provided: [fieldName => 'Name'][objectName => 'Contract'] ``` 「**モックの設計と `Scribe` の設計が一致しているか**」をテスト段階で照合する仕組みが、親 / 子の両方向で同じく働きます。もし `Name` の値を取り出すロジックを後から追加したとき、同時に `Scribe` の `.field('Name')` を加えるのを忘れていれば、即座にこのテストが落ちて気付けます。 ## ケース 4: 集計クエリのエイリアス漏れを検知 集計クエリ (`COUNT` / `SUM` / `AVG` / `GROUP BY` 等) では、結果は `AggregateResult` として返り、SELECT した集計関数の **エイリアス名** でアクセスします。エイリアスを間違える / 別のエイリアスでアクセスする、もよく起きるバグです。 「Stage ごとの合計金額を Map で返す」Usecase を書きますが、開発者は `Scribe` で `sum('Amount', 'totalAmount')` を書き忘れ、別の集計関数 (`average`) の結果を SELECT した状態のままアクセス側で `totalAmount` を参照しています。 ### バグ入り Usecase クラス ```apex public with sharing class AggregateOppByStageUsecase { private final IEloquent eloquent; public AggregateOppByStageUsecase(IEloquent eloquent) { this.eloquent = eloquent ?? new Eloquent(); } public Map invoke() { Scribe analyticsScribe = Scribe.of(Opportunity.class) .field('StageName') .average('Amount', 'avgAmount') // ❌ 本当は sum を totalAmount で取りたいのに、average しか書いていない .groupByField('StageName'); List results = this.eloquent.get(analyticsScribe); Map stageToTotal = new Map(); for (IEntry result : results) { String stage = (String) result.get('StageName'); // この行で例外 (Scribe では totalAmount を SELECT していない) Decimal total = (Decimal) result.get('totalAmount'); stageToTotal.put(stage, total); } return stageToTotal; } } ``` ### 正常系を期待するテストが、バグで落ちる このテストは「Stage ごとの合計金額が Map で返る」ことを確認する正常系テストです。モック側では `totalAmount` の値をちゃんとセットしているにも関わらず、`Scribe` の SELECT 句に `totalAmount` (エイリアス) が含まれていないため、`result.get('totalAmount')` で例外が投げられます。 ```apex @isTest static void testInvoke_WhenOppsExist_ThenReturnsStageToTotal() { Trace t = Trace.of('正常系: Stage ごとの合計金額が Map で返ること'); t.start(); // Arrange: モック側には totalAmount をセット (Scribe の SELECT には含まれていない) IEloquent mockEloquent = new MockEloquent( MockEntry.asAggregateResult() .set('StageName', 'Prospecting') .set('totalAmount', 10000) ); AggregateOppByStageUsecase usecase = new AggregateOppByStageUsecase(mockEloquent); // Act // ※ Scribe で totalAmount エイリアスを SELECT していないため、 // invoke 内部で ApexEloquentException が投げられ、以降の Assert には到達しない Map stageToTotal = usecase.invoke(); // Assert Assert.areEqual(10000, stageToTotal.get('Prospecting')); t.finish(); } ``` 実行すると `ApexEloquentException` が投げられて、次のような複数行のメッセージが出ます。 ``` ====== APEX ELOQUENT EXCEPTION ====== Location: MockEntry.get(String fieldName) Error : The `fieldName` argument is Invalid. Reason : The specified field or alias is not exist in Scribe. Action : Add the field or alias to the Scribe definition for this AggregateResult Provided: [fieldName => 'totalAmount'] ``` `Provided` に「どのフィールド / エイリアス (`totalAmount`) でアクセスしたか」が出ているので、「`Scribe` 側のエイリアス宣言とアクセス側の参照名が一致しているか」を、テスト実行時に構造的に照合する仕組みになっています。 集計クエリでは 2 つの仕組みが組み合わさっています。 - ApexEloquent の `Scribe` は集計関数を `count('Id', 'eventCount')` のように **エイリアス必須** で書かせる (Salesforce 標準の `expr0` 罠を回避) - そのエイリアスでしかアクセスできない、タイポしたら即例外 「エイリアス必須」と「タイポ検出」の組み合わせで、集計クエリの両方の罠 (`expr0` で謎にアクセス / 別名でアクセスして null) を構造的に防いでいます。 ## SELECT 漏れ以外の 2 つの安全網 ここまでの 4 ケースは、いずれも「`Scribe` の SELECT 句とアクセスの照合」でした。同じ「テストだけ緑になる」を塞ぐ仕組みが、ほかに 2 つあります。 ### モックにデータを差し込み忘れると例外になる ラベルを使ったテストで、`label('X')` で取得しているのに `attach('X', ...)` を書き忘れる、あるいはラベル名を打ち間違えると、**そのクエリは 0 件を返します**。すると「対象が無いのでスキップ」の分岐に入り、**何も検証していないのにテストが緑になります**。 現在は、attach していないラベルで `get` / `first` / `firstOrFail` を呼ぶと例外になります (エラーには attach 済みのラベル一覧が付きます)。 「0 件の経路」を意図してテストしたいときは、**空リストを明示的に attach** して意図を宣言します。 ```apex MockEloquent mock = (new MockEloquent()) .attach(MyUsecase.LBL_FETCH, new List()); ``` **「attach していない」と「空を attach した」は別の状態**として扱われる、というのがこの設計の要点です。 ### 直接渡すエントリにも契約を焼き付けられる 上の 4 ケースは、`MockEloquent` 経由で返すエントリの話でした。`Scribe` を通るので契約が自動的に付きます。 一方、**エントリを SUT に直接渡す場合は契約がありません**。典型はバッチで、`execute(bc, scope)` のレコードは `IEloquent` を通らないため、本番では実クエリの結果としてプラットフォームが検査してくれますが、テストでは自前で組んだエントリなので無検査になります。 `fetchedBy(scribe)` は、そこに契約を後付けします。 ```apex MockEntry card = MockEntry.of(BusinessCard__c.class) .autoId(1) .set('CompanyName__c', 'Acme') .fetchedBy(RematchCompanyCardsHandler.scope()); ``` クエリの組み立てを `@TestVisible` なメソッドに切り出しておけば、本番とまったく同じ `Scribe` をテストから渡せます。詳細は [API リファレンス: Scribe](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-scribe) を参照してください。 ## どんな場面で効くか 「SELECT 漏れの偽陽性検知」は、単発で書いたコードの初期テストよりも、**コードベースが育って変化するときに効きます**。 - **リファクタリングの安全網**: ある Usecase の SOQL を変更 (フィールドを削る / 並べ替える) したとき、アクセス側のロジックでまだ使われているフィールドを SELECT 漏れさせれば、そのアクセスを通るテストが即座に落ちる - **新機能追加で SELECT を継ぎ足し忘れた時**: アクセス側のロジックに新しいフィールド参照を加えたが、同時に SOQL を更新し忘れた → そのロジックを通るテストが落ちる - **チーム開発での認知負荷削減**: 「この SOQL とこのロジックは整合しているか」を都度確認しなくても、テストが構造的に保証する 「テスト通っても本番で落ちる」タイプの障害は、リファクタリング後 / 機能追加後 / コードレビューを通った後、多くのケースで開発体験を崩します。ApexEloquent はこれを **モック注入時の `Scribe` との照合** で構造的に塞いでいます。 ## まとめ SELECT 漏れの偽陽性は本番でしか発覚しない隠れたバグになりがちですが、ApexEloquent ではテスト段階で構造的に叩き出せます。 | 検証対象 | 検出されるバグ | 例外メッセージの reason | |---|---|---| | **主オブジェクトのフィールド** | `Scribe.of(...).field('A')` のみで `entry.get('B')` | `The specified field is not selected in Scribe` | | **親リレーション (フィールド漏れ)** | `parentField` の中で SELECT 漏れ | `The specified field is not selected in Scribe` | | **親リレーション (asParent 自体なし)** | `Scribe` で `asParent` していない親項目を `getParent` | `The specified parentIdFieldName is not defined as a relationship in Scribe` | | **子サブクエリ** | `withChildren` で SELECT 漏れ | `The specified field is not selected in Scribe` (子の SObject 名で) | | **集計クエリ** | `average('Amount', 'avgAmount')` で SELECT、別エイリアスでアクセス | `The specified field or alias is not exist in Scribe` | | **モックの差し込み漏れ** | `label('X')` で取得するのに `attach('X', ...)` を書き忘れ / ラベル名の打ち間違い | `label 'X' has no attached entries` | > DB なしテストでありながら、SOQL とロジックの整合性を厳密に保証する。これが ApexEloquent のテスト哲学の核です。 モック作成だけなら他の OSS にもありますが、**「モックの中身と `Scribe` の SELECT 句を照合する」安全網** は ApexEloquent 独自の設計です。 ### 関連ドキュメント - [MockEntry: Apex のテストデータ作成を成立させる仕組み](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-mockentry-deep-dive): 書き込み不可項目モック / 親子 / 量産 / 集計 - [データ取得と DML、IEntry、Mock](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-data-access): 具体的な API の使い方 - [Scribe でクエリを組み立てる](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-scribe-guide): クエリ組み立て (`field` / `parentField` / `withChildren` / 集計) - [API リファレンス: IEntry / Entry / MockEntry](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-api-entry): 全 API のシグネチャ - [ApexEloquent トップ](https://krileworks.com/ja/apexeloquent): OSS の全体像 ============================================================================== Source: https://krileworks.com/document/ja/dynamic-query-creation-apex-eloquent.md Page: https://krileworks.com/ja/apex-stem/docs/dynamic-query-creation-apex-eloquent ============================================================================== # Apex の生 SOQL から、チェーンメソッドで組み立てる ORM へ 長くなりがちな SOQL 文字列、扱いにくいリレーション、遅くて壊れやすい DB 依存のテスト。Salesforce 開発者が日常的に向き合っているこれらの課題は、Apex の標準的な書き方をそのまま使っている限り、なかなか減りません。 ApexEloquent は、メソッドチェーンでクエリを宣言的に組み立て、テストでは DB に触れずに完結できる ORM を提供します。生 SOQL 中心の Apex 開発から、どこがどう変わるかを順に見ていきます。 ## 生 SOQL 中心の Apex 開発で起きること 標準の SOQL は強力ですが、運用していると次のような問題が必ず顔を出します。 - ❌ **可読性の限界**: 手で連結した長い SOQL 文字列は、すぐに保守不能になる - ❌ **動的条件の組み立てが面倒**: `if` 分岐の中で条件を追加するには、文字列操作の組み合わせを書くことになる - ❌ **型安全性が無い**: 文字列内のフィールド名のタイポは実行時まで気付けない - ❌ **リレーションが扱いにくい**: 親子のリレーション名 (`__r` 付きの名前など) を正確に覚えていないと書けない - ❌ **テストが書きづらい**: ビジネスロジックが DB に強く結合し、テストごとに大量のデータを `insert` する必要が出る これらは「生 SOQL を書く」という選択をした瞬間に、ほぼ自動的についてくる代償です。`ApexEloquent` はこの代償を払わずに同じ仕事をするために生まれました。 ## ApexEloquent によるアプローチ ApexEloquent は、Laravel の Eloquent から発想を借りた Apex 向けの ORM フレームワークで、2 つの主要コンポーネントが軸になります。 - **`Scribe`**: SOQL クエリをメソッドチェーンで組み立てる、immutable なクエリビルダー - **`IEloquent`** (`Eloquent` / `MockEloquent`): 組み立てた `Scribe` を受け取って実行する側。本番は `Eloquent`、テストは `MockEloquent` を DI で差し替える この「クエリ構築」と「クエリ実行」を分けて設計するアプローチには、独自の設計哲学があります。詳しい背景は [Query Delegation Pattern](https://krileworks.com/ja/apex-stem/docs/query-delegation-pattern) と、なぜ Repository を ApexEloquent に内蔵したかは [Apex における Repository パターンの試行錯誤と内蔵化](https://krileworks.com/ja/apex-stem/docs/repository-pattern-challenges-builtin-solution-apex) で扱っています。 このドキュメントでは、設計哲学はいったん置いておき、**生 SOQL からの移行で「実際にコードがどう変わるか」** を中心に見ていきます。 ## 基本的な使い方 クエリの組み立ては直感的です。`Scribe` のメソッドを連ねるだけで、SELECT・WHERE・並び替えがそのまま型付きの API として書けます。 ```apex // Industry が 'Technology' のすべての Account の Id と Name を取得するクエリ Scribe accountScribe = Scribe.of(Account.class) .fields(new List{ 'Id', 'Name' }) .whereEqual('Industry', 'Technology') .orderBy('Name'); ``` これと等価な生 SOQL は次のとおりです。 ```soql SELECT Id, Name FROM Account WHERE Industry = 'Technology' ORDER BY Name ``` 短い 1 行ならどちらも同程度に読めますが、条件・並び替え・サブクエリが増えるほど、`Scribe` 版は **構造のまま読めること** のメリットが効いてきます。組み立てた `Scribe` を実行する側 (`IEloquent.get(scribe)` 等) や、`Scribe` の API 全体については [Scribe でクエリを組み立てる](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-scribe-guide) と [データ取得と DML、IEntry、Mock](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-data-access) を参照してください。 ## 動的にクエリを組み立てる `Scribe` が immutable であることが効いてくる代表的な場面が、「ユースケースの条件によってクエリを分岐させたい」ケースです。 ```apex Scribe oppScribe = Scribe.of(Opportunity.class) .fields(new List{ 'Name', 'StageName' }) .whereIn('StageName', new List{ 'Prospecting', 'Qualification' }); // 特定条件のときだけ WHERE 条件を追加する if (includeHighValueDeals) { oppScribe = oppScribe.whereGreaterThan('Amount', 100000); } // 特定条件のときだけ SELECT するフィールドを追加する if (includeAmountDetails) { oppScribe = oppScribe.field('Amount').field('Probability'); } ``` `Scribe` のメソッドは毎回新しい `Scribe` を返すので、元のクエリを壊さずに「条件を足す」「フィールドを足す」を同じ感覚で扱えます。 > ここでは SELECT 項目の追加も含むため `if` で書いていますが、**WHERE 条件だけを出し入れする**なら `ignoreWhen` を使うと分岐なしの 1 本のチェーンで書けます (`whereIn('Id', ids).ignoreWhen(ids.isEmpty())`)。 ### 順番に依存しない構造 上の 2 つの `if` ブロックは **入れ替えても出来上がる SOQL が同じ** になります。`field` と `where` のどちらを先に追加しても、最終的に生成されるクエリの SELECT 句・WHERE 句に過不足なく反映されるからです。これは「ユースケース側の判断のしやすい順に if を並べる」自由度をそのまま許してくれます。 同じことを文字列の SOQL でやろうとすると、SELECT 句用の文字列と WHERE 句用の文字列を別の変数で保持して、最後に決まった順序で連結する、という **ビジネスロジックとは関係のない処理** が増えてきます。 ```apex // 文字列 SOQL で同じことをやる場合 (悪い例) String selectClause = 'SELECT Name, StageName'; String whereClause = " WHERE StageName IN ('Prospecting', 'Qualification')"; if (includeHighValueDeals) { whereClause += ' AND Amount > 100000'; } if (includeAmountDetails) { selectClause += ', Amount, Probability'; } String soql = selectClause + ' FROM Opportunity' + whereClause; ``` SELECT 句と WHERE 句を別変数で持つ書き方が「分岐ごとにどちらの変数を触るか」を間違えるリスクを抱えますし、`' AND ...'` の先頭スペースを入れ忘れて `WHERE Foo = 'X'AND Bar = 'Y'` のように繋がってしまう **空白の入れ忘れケアレスミス** も典型的です。`Scribe` ベースの組み立てでは、これらの「クエリ文字列を組み立てるためだけのコード」が一切要らなくなります。 このメソッドチェーン方式の良さは、**「クエリを部品として組み立てる」感覚がそのままコードに乗ること** です。ユースケース固有の条件を、文字列ではなく型付きのメソッド呼び出しとして表現できます。 ## リレーションも直感的に扱える 親子のリレーション名 (`__r` 付きの名前など) を正確に覚えていなくても、`Scribe` のメソッドで親子クエリを組めます。 ```apex // Account と、その配下の Contact を一緒に取得 Scribe accountScribe = Scribe.of(Account.class) .field('Name') .withChildren( Scribe.asChild(Contact.class) .fields(new List{ 'LastName', 'Email' }) ) .whereEqual('Id', someAccountId); IEntry accountEntry = (new Eloquent()).first(accountScribe); List contacts = accountEntry.getChildren('Contact'); ``` 親項目を SELECT する `parentField`、親条件で絞る `parentCondition`、Junction Object を経由する多対多の `through` などについては、[親項目・子サブクエリ・多対多](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-relations) で詳しく扱っています。 ## DB なしテストへの入口 ApexEloquent のもう一つの大きな価値は、`IEloquent` を `MockEloquent` に DI で差し替えるだけで、**DB を介さない単体テスト** が書けることです。 ```apex // 想定するクエリ結果をメモリ上に組み立てる IEloquent mockEloquent = new MockEloquent(new List{ MockEntry.of(Opportunity.class) .set('StageName', 'Closed Won') .setParent('AccountId', MockEntry.of(Account.class).set('Type', 'Prospect') ) }); // DB に触れず、サービスクラスを直接テスト AccountService service = new AccountService(mockEloquent); Account updatedAccount = service.updateAccountType('006...'); Assert.areEqual('Customer', updatedAccount.Type); ``` `MockEloquent` / `MockEntry` の詳しい使い方 (Spy プロパティ、`failOn*`、親子・集計のモック、SELECT 漏れ検知など) は [データ取得と DML、IEntry、Mock](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-data-access) で網羅しています。 ## 生 SOQL vs ApexEloquent 比較 | 観点 | 生 SOQL | ApexEloquent | |---|---|---| | **可読性** | 複雑なクエリで急速に悪化 | ✅ メソッドチェーンで構造が見える | | **動的 WHERE** | 文字列連結を手で書く | ✅ チェーンメソッドで安全に継ぎ足せる | | **リレーション** | リレーション名を覚えて記述 | ✅ `withChildren` / `parentField` で直感的 | | **テスト** | ❌ DML + テストデータが必須 | ✅ `MockEloquent` で DB なしテスト | | **安全性** | フィールド名タイポは実行時エラー | ✅ テスト段階で SELECT 漏れも検知 | ## まとめ 生 SOQL を書き続けることで支払っている代償 (可読性 / 動的条件の面倒さ / 型安全性の欠如 / リレーションの扱いづらさ / テストの重さ) は、別々の問題に見えて、**「文字列でクエリを書いている」という選択そのものが根に近い** 問題です。 `Scribe` でクエリを組み立て、`IEloquent` に実行を委ねるスタイルに切り替えると、これらの代償がまとめて軽くなります。生 SOQL を 1 行も書かない、というほど極端な話ではなく、ユースケースに紐づくクエリを `Scribe` で構造的に組み立て、保管庫に置く共通クエリも `Scribe` で部品化していく、というのが現実的な移行像です。 ### 関連ドキュメント - [Query Delegation Pattern](https://krileworks.com/ja/apex-stem/docs/query-delegation-pattern): なぜクエリ構築と実行を分けるのか - [Apex における Repository パターンの試行錯誤と内蔵化](https://krileworks.com/ja/apex-stem/docs/repository-pattern-challenges-builtin-solution-apex): Repository を内蔵した動機 - [Scribe でクエリを組み立てる](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-scribe-guide): Scribe API のガイド - [データ取得と DML、IEntry、Mock](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-data-access): IEloquent / MockEloquent のガイド - [親項目・子サブクエリ・多対多](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-relations): リレーション操作の典型例 - [ApexEloquent トップ](https://krileworks.com/ja/apexeloquent): OSS の全体像 ============================================================================== Source: https://krileworks.com/document/ja/repository-pattern-challenges-builtin-solution-apex.md Page: https://krileworks.com/ja/apex-stem/docs/repository-pattern-challenges-builtin-solution-apex ============================================================================== # Apex における Repository パターンの試行錯誤と内蔵化 Repository パターンに関する情報はネット上に豊富にあり、実装例も多くの言語で見つかります。しかし、これらをそのまま Apex (Salesforce) に持ち込もうとすると、ほかの言語にはない事情にぶつかります。 ここでは、Apex で実際に試した Repository クラスの構成パターンと、そこから ApexEloquent が **「内蔵 Repository」** という形に行き着いた経緯を整理します。詳しい設計哲学は [Query Delegation Pattern](https://krileworks.com/ja/apex-stem/docs/query-delegation-pattern) と合わせて読むと、なぜこの形に着地したのかが繋がります。 ## パターン 1: ユースケース別 Repository ユースケースごとに個別の Repository クラスを定義していくスタイルです。ユースケースが増えるたびに 1 つずつ Repository を切り出していけば、各処理の意図はコードに残ります。 一方で、**インターフェースと実装クラスが量産される** という重い課題があります。Apex は namespace を持たないため、**クラスを大量に作るとすぐに名前衝突や命名の枯渇に陥ります**。これは Apex 固有の設計上の制約として軽視できません。 ## パターン 2: オブジェクト別 Repository (Selector Pattern) クラス数を抑える方針として、SObject 単位で Repository (Apex の文脈では Selector) を定義するスタイルがあります。Salesforce 公式の **Selector Pattern** がこの形に当たります。 📚 [Selector Pattern 公式ガイド (Trailhead)](https://trailhead.salesforce.com/content/learn/modules/apex_patterns_dsl/apex_patterns_dsl_learn_selector_l_principles) ### 公式が掲げるメリット 公式ガイドは、Selector Pattern が解こうとしている問題として 3 つの「不整合」を挙げています。これらはいずれも、SOQL をビジネスロジックのあちこちに散らかしたときに実際に起こる問題で、Selector に集約する動機としては妥当です。 - **クエリの不整合**: 似たような SOQL がコードのあちこちにコピペされ、ある場所だけ条件が抜けるといった不一致が起きる - **クエリデータの不整合**: SOQL で取得した SObject を引き回すと、取得していない項目に触れた呼び出し先が `System.SObjectException: SObject row was retrieved via SOQL without querying the requested field` で落ちる - **セキュリティの不整合**: 各実行コンテキストでのオブジェクトセキュリティチェックを散在させると見落としが起きやすい。Selector に寄せると一元管理できる ### 公式が触れていない、長期運用での副作用 ここからが、公式ガイドではあまり言及されない領域です。Selector Pattern を中規模以上のプロジェクトで何年か運用すると、メリットを得ながら同時に別の副作用が積み上がっていきます。`Opportunity` のように複数のユースケースから使われる頻用オブジェクトで、特に顕著に現れます。 - **細かな絞り込み条件のためのメソッドが量産される** (`getById` / `getByIdAndStage` / `getByIdAndStageWithClosedDate` ...) - **フラグ引数によるクエリ制御** が増え、呼び出し側の意図が見えにくくなる (`getById(id, includeLineItems, lockForUpdate, ...)`) - メソッドの文脈が曖昧になり、**安全に修正できなくなる** (修正したら、どのユースケースに影響するか追えない) - **「すべてのフィールドを取得する」圧力** がかかる: 未取得フィールドへのアクセスで `SObjectException` が出る恐怖を避けるため、Selector メソッドは「念のため全フィールドを SELECT に入れておく」方向に膨らみがち。結果として SOQL コストの増加、ガバナ制限への圧迫、依存関係の不明瞭化 (SELECT 句から「どのユースケースが何を使っているか」が読めなくなる) を招く 特に 4 つ目は、公式メリットの 2 番目 (未取得フィールド対策) を実現する手段が「Selector に集約する」だけだと、その裏返しとして自然に発生します。「未取得フィールド恐怖」を避けるための一番楽な対処が「全部 SELECT に入れる」なので、Selector が複数ユースケースから共有されるほど、SELECT 句は最大公約数として肥大化します。 「良かれと思って踏んだ道が、そのまま技術的負債への道だった」という、Selector Pattern の典型的な崩れ方です。公式の 3 つのメリットは事実ですが、それを実現する手段が「メソッドを足し続ける」「SELECT 句を膨らませる」になっている限り、長期運用ではこの副作用とセットになります。 ApexEloquent が向き合いたいのは、**公式が挙げる 3 つのメリットを維持しつつ、長期運用の副作用を別の設計で回避すること** です。 ## 解決策: Built-in Repository これらの試行錯誤を経て、ApexEloquent は [Query Delegation Pattern](https://krileworks.com/ja/apex-stem/docs/query-delegation-pattern) に基づく **Built-in Repository** を採用しました。 設計の核は、**クエリの構築をドメイン層 (Usecase) に置き、実行だけを Repository に委ねる** ことです。これによって、アプリケーションごとに Selector を量産する必要がなくなり、共通の「内蔵 Repository」1 つで済みます。ApexEloquent ではそれが `IEloquent` (本番は `Eloquent`、テストは `MockEloquent`) として提供されます。 ### このパターンの利点 - ✅ Selector クラスの過剰な量産を防げる - ✅ 「こういうデータが欲しい」という意図とクエリの組み立てが、ドメイン層の同じ場所に残る - ✅ 細かなクエリの差分はドメイン層で吸収でき、Repository の肥大化が起きない - ✅ アプリ側で Repository インターフェースを定義する必要がなく、テストが書きやすい 実際にこのスタイルを採用してからは、テストコードを書く際の心理的負担が大きく下がりました。生 SOQL を量産する書き味からも、Selector のメソッドを継ぎ足し続ける運用からも解放され、Usecase のそばで `Scribe` を組み立て、`IEloquent` を `MockEloquent` に差し替えるだけで単体テストが書けます。 ### クエリ共通化の余地は残してある 「内蔵 Repository」にしたからといって、共通化したいクエリを置く場所が無くなるわけではありません。共通化したい `Scribe` の組み立てを集約したいときは、Selector 派生としての **クエリ保管庫 (Vault)** スタイルを併用できます。詳しくは Query Delegation Pattern の [Query Reusability](https://krileworks.com/ja/apex-stem/docs/query-delegation-pattern#query-reusability) を参照してください。 ## 従来パターンとの比較 | 観点 | Traditional Repository / Selector | Built-in Repository | |---|---|---| | **クラス数** | ユースケースやオブジェクトごとに増える | 共通の 1 つ (`IEloquent`) | | **テストの煩雑さ** | モックの準備が複雑 | DI で `MockEloquent` に差し替えるだけ | | **クエリの置き場所** | Selector に散らばる | ドメイン層 (Usecase) に集約 | | **保守性** | インターフェースが増えて辛くなる | 単一インターフェースで完結 | | **Apex 適性** | namespace 制約にぶつかりやすい | Apex の特性に合わせて設計済み | ## まとめ 「Query Delegation Pattern + Built-in Repository」は、単なる選択肢の 1 つというより、**Apex 開発における設計の見方そのものを切り替えるアプローチ** です。 生 SOQL を量産するスタイルや、DB に依存したテストの面倒さから開発者を解放し、Salesforce プラットフォーム上でも、ほかのプラットフォームと同じ感覚でクリーンなテスト駆動の開発を実践できます。結果として、技術的負債を抑え、開発スピードを保ちながら、より保守しやすいコードを残すチーム文化を育てていけます。 ### 関連ドキュメント - [Query Delegation Pattern](https://krileworks.com/ja/apex-stem/docs/query-delegation-pattern): Built-in Repository を支える設計哲学 - [ApexEloquent ガイド](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-guide): Scribe / IEloquent / IEntry の使い方と API リファレンス - [Handler-Usecase Architecture](https://krileworks.com/ja/apex-stem/docs/handler-usecase-architecture): クエリ構築をドメイン層に置くアーキテクチャ - [ApexEloquent トップ](https://krileworks.com/ja/apexeloquent): Built-in Repository を採用した OSS の全体像 ============================================================================== Source: https://krileworks.com/document/ja/apex-blueprint-prerequisites.md Page: https://krileworks.com/ja/apex-stem/docs/apex-blueprint-prerequisites ============================================================================== # 🛠 事前準備 ApexBlueprint を導入する前に、以下のツールと環境が正しくセットアップされていることを確認してください。 ## ✅ Salesforce CLI のセットアップ ApexBlueprint のデプロイには Salesforce CLI を使います。未インストールの場合は公式サイトからインストールしてください。 インストール確認: ```bash $ sf -v ``` バージョン情報が表示されれば準備 OK です。 ## ✅ 対象組織の確認 プロジェクトのルートディレクトリで以下のコマンドを実行し、接続中の組織を確認します。 ```bash $ sf org list ``` ターゲットにしたい組織に 🍁 `Default Org` の印が付いていることを確認してください。 例: ``` ┌────┬─────────┬────────────┬────────────────────────────────────────┬────────────────────┬───────────┐ │ │ Type │ Alias │ Username │ Org Id │ Status │ ├────┼─────────┼────────────┼────────────────────────────────────────┼────────────────────┼───────────┤ │ 🌳 │ DevHub │ devhub │ example-user@example.com │ 00DxxxxxxxxxxxxXXX │ Connected │ │ 🍁 │ Sandbox │ sandbox │ example-user@example.com.sandbox │ 00DyyyyyyyyyyyyYYY │ Connected │ └────┴─────────┴────────────┴────────────────────────────────────────┴────────────────────┴───────────┘ ``` ## ⚙️ デフォルト組織の設定 🍁 の印が付いていない場合は、ログインして以下のように設定します。 ```bash # 組織にログイン (alias と URL は環境に合わせて差し替え) $ sf org login web --alias my-sandbox --instance-url https://orgfarm-xxxxxxx-dev-ed.develop.my.salesforce.com # デフォルト組織として設定 $ sf config set target-org=my-sandbox ``` 設定後、もう一度 `sf org list` を実行して 🍁 が付いていることを確認してください。 ## 🛠️ make コマンドのセットアップ ApexBlueprint はデプロイ操作の一部で `Makefile` を使うため、`make` コマンドが使えることを確認してください。 ```bash $ make -v ``` 以下のような出力が表示されれば準備 OK です。 ``` GNU Make 4.3 ``` ## 🔗 ガイドへ戻る ← [ApexBlueprint ガイドへ戻る](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-guide) ============================================================================== Source: https://krileworks.com/document/ja/apex-blueprint-installation-guide.md Page: https://krileworks.com/ja/apex-stem/docs/apex-blueprint-installation-guide ============================================================================== # 🚀 インストールガイド このガイドでは、Salesforce プロジェクトに ApexBlueprint をインストールする一連の手順を解説します。 ## 📥 パッケージの取得 (初回のみ) Git Submodule で `ApexBlueprint` を取得します。 ```bash $ cd force-app/main/default/classes $ git submodule add https://github.com/krile136/ApexBluePrint.git ApexBlueprint ``` これでリポジトリに `ApexBlueprint` ディレクトリが追加され、ソース管理に組み込まれます。 ## 🚀 組織へのデプロイ 取得したクラスを以下のコマンドでデプロイします。 ```bash $ make install ``` `make install` は内部で Salesforce CLI のデプロイコマンドを呼び出します。 テストクラスは標準オブジェクトを使ったテストデータを含んでおり、**カバレッジ要件 75% を満たすように構成**されています。ただし、**組織側の設定や無効化された標準項目が原因でテストが失敗する場合**は、`*_T.cls` テストクラスを適宜調整してください。 ## 🔄 ApexBlueprint の更新 ApexBlueprint を更新するには、プロジェクトのルートから以下のコマンドを実行します。 ```bash $ cd force-app/main/default/classes/ApexBlueprint $ git pull $ make install ``` :::warning Submodule は親リポジトリがどのコミットを参照しているかという情報 (ポインタ) を保持しています。本番環境で一貫性を保つには、`git submodule update --remote` で参照を最新版に自動更新し、親リポジトリ側でもコミットすることをおすすめします。 ::: ## 🔗 ガイドへ戻る ← [ApexBlueprint ガイドへ戻る](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-guide) ============================================================================== Source: https://krileworks.com/document/ja/apex-blueprint-sblueprint-guide.md Page: https://krileworks.com/ja/apex-stem/docs/apex-blueprint-sblueprint-guide ============================================================================== # SBlueprint で単一レコードを宣言する ApexBlueprint で結合テストデータを組み立てる最初のステップは、`SBlueprint` で **単一レコードの設計図** を宣言することです。「Account を作って、Industry に Technology を入れて...」という手続きを書く代わりに、「最終的にどんなレコードがあってほしいか」を 1 つの式として宣言します。 このページでは、`SBlueprint` を組み立てるための基本 5 メソッド (`of` / `.set` / `.template` / `.alias` / `.use`) と、順序だけを宣言する `.after` を順に解説します。親子関係 (`withChildren`) や量産 (`times`) のような複数レコードを扱う API は [親子・量産・参照のパターン](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-relations-and-bulk) で扱います。 ## of(SObjectType): 起点 `SBlueprint` の宣言は必ずこの静的メソッドから始まります。これからどの SObject タイプの blueprint を組み立てるかを示すための「最初の一歩」です。 **シグネチャ:** `SBlueprint.of(System.Type recordType)` ```apex SBlueprint accountBp = SBlueprint.of(Account.class); ``` 返ってくる `SBlueprint` インスタンスに対して、後続のメソッドチェーンで値や関係を積み重ねていきます。 ## .set(field, value): フィールド値を設定する 最もよく使うメソッド。フィールド名と値を 1 組ずつ宣言します。 **シグネチャ:** `.set(String fieldName, Object value)` ```apex SBlueprint accountBp = SBlueprint.of(Account.class) .set('Name', 'Test Account') .set('Industry', 'Technology') .set('AnnualRevenue', 1000000); ``` ### 振る舞いの要点 - 同じフィールドに対して `.set()` を複数回呼ぶと、**最後の呼び出しが勝ちます** (last wins) - `.template(...)` で前もって入れた値も `.set(...)` で上書きできます。「共通設定はテンプレート + 検証対象だけ `.set` で差分」という運用パターンの土台 - フィールド名はそのテストで検証対象になる項目だけ書きます。必須項目や RecordTypeId など全テストに共通する値は `.template(...)` 側に逃がす > 「これは結局このテストで何を検証したいのか」が `.set()` の行を読むだけで分かる状態を目指す、という考え方です。 ## .template(Map): 共通設定の再利用 事前に定義した値の Map を `.template(...)` でまとめて適用します。RecordTypeId、必須項目、全テストで使い回したいデフォルト値などをここに集約することで、各テスト本体に書く `.set(...)` を最小限に絞れます。 **シグネチャ:** `.template(Map templateMap)` ### ベストプラクティス: 単一の `Blueprints.cls` に集約する 複数のテストで使い回せるテンプレートは、**単一の `Blueprints.cls` に、SObject ごとのメソッドとして並べる**のが定番運用です。命名は `{SObject の短縮}Basic()` (`accBasic()` / `oppBasic()` …)。 ```apex public with sharing class Blueprints { /** 法人顧客の基本構成 */ public static Map accBasic() { return new Map{ 'Name' => 'TestAccount', 'Industry' => 'Technology', 'AnnualRevenue' => 500000 }; } /** 法人顧客: エンタープライズ向け (年商を桁違いに) */ public static Map accEnterprise() { return new Map{ 'Name' => 'EnterpriseAccount', 'Industry' => 'Financial Services', 'AnnualRevenue' => 10000000, 'NumberOfEmployees' => 1000 }; } /** 商談の基本構成 */ public static Map oppBasic() { return new Map{ 'Name' => 'TestOpp', 'StageName' => 'Prospecting', 'CloseDate' => Date.today().addDays(30) }; } } ``` > **なぜ SObject ごとにクラスを分けないのか。** 分けると、各クラスが `Map` を返すだけの薄いクラスになり、ファイルが散らばります。1 クラスに並べておくと、**全 SObject の基本構成が 1 ファイルに集まります**。org 側で必須項目が追加されて結合テストが一斉に落ちたとき、直す場所が「該当する `xxxBasic()` の `Map` に 1 行足す」で自明になる、というのが実運用上いちばん効きます。バリエーションは `accEnterprise()` / `oppClosed()` のように接頭辞付きで同じクラスに並べます。 各テスト側はテンプレートを取り込み、検証対象の差分だけを `.set(...)` で上書きします。 ```apex SBlueprint accountBp = SBlueprint.of(Account.class) .template(Blueprints.accBasic()) .set('Name', '○○商事'); // このテストでは Name だけが本筋 ``` > RecordTypeId のような「全テストで共通だが書き忘れるとテストが落ちる」値は、必ずテンプレート側に入れておくのが安全です。 ## .alias(name): 識別子を付ける blueprint に一意な参照名 (alias) を付けます。alias は次の 2 つの場面で使います。 - 別の blueprint が `.use(alias, ...)` で値を参照する - `SOrchestrator.create()` 実行後に `getByAlias(alias)` で生成済みレコードを取り出す **シグネチャ:** `.alias(String aliasName)` ```apex SBlueprint accountBp = SBlueprint.of(Account.class) .template(Blueprints.accBasic()) .alias('parentAccount'); ``` ### 振る舞いの要点 - alias は同じ `SOrchestrator` 内で **一意である必要** があります。重複すると `Duplicate alias detected` で実行時エラー - `.alias(...)` を省略した blueprint には `__Account_0_1__` のような自動 alias が付与されますが、これを後で `getByAlias` で取り出すのは現実的ではありません。**取り出す予定があるなら必ず明示的に alias を付ける** のが原則です - `.times(n)` と組み合わせて `'con_{#}'` のようなプレースホルダ付き alias を作るパターンは [親子・量産・参照のパターン](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-relations-and-bulk) で扱います ## .use(alias, fromField, toField): 別 blueprint からの値コピー このメソッドは ApexBlueprint の中で最も多目的な API です。「別の blueprint が持っている値を、自分のフィールドに引き写す」という 1 行で、**リレーションの作成** と **データのコピー** の両方を表現します。 **シグネチャ:** `.use(String aliasName, String fromField, String toField)` - `aliasName`: 参照したい blueprint の alias (`.alias()` でセットしたもの) - `fromField`: 参照元の blueprint から読むフィールド (例: `'Id'`, `'Industry'`) - `toField`: 自分の blueprint に書き込むフィールド (例: `'AccountId'`, `'Description'`) ### 用途 1: リレーションを作る (Id コピー) 最もよく使うパターン。`Opportunity` を `Account` に紐付けます。`'parentAccount'` は **別の Account 用 blueprint に `.alias('parentAccount')` で付けた識別子** を指しています。 ```apex SBlueprint oppBp = SBlueprint.of(Opportunity.class) .set('Name', 'Test Opportunity') .set('StageName', 'Prospecting') .set('CloseDate', Date.today().addDays(30)) .use('parentAccount', 'Id', 'AccountId'); ``` `SOrchestrator` が先に `parentAccount` を insert して Id を払い出し、そのあと `Opportunity` を insert する、という順序解決は完全に自動です。親 Id を変数で持ち回るコードは一行も書きません。 ### 用途 2: データを引き写す (Id 以外) `.use(...)` は ID 以外のフィールドにも使えます。「親の Name を子の説明に転記したい」のようなケースで便利です。 ```apex SBlueprint contactBp = SBlueprint.of(Contact.class) .set('LastName', 'TestContact') .use('parentAccount', 'Name', 'Description'); ``` ### 高度な用法 「`{#}` で量産した複数親の一部だけを子に紐付けたい」「親の親 (`{P0}` / `{P1}`) を参照したい」といった応用は [親子・量産・参照のパターン](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-relations-and-bulk) で詳しく扱います。 ## .after(alias): 順序だけの依存 (v2.0.0+) `.use()` は「値を運ぶついでに順序も決まる」メソッドでした。`.after()` はそこから**値のコピーだけを抜いた**ものです。参照先が先のレイヤーで insert されることだけを保証し、フィールドは一切書き換えません。 **シグネチャ:** `.after(String aliasName)` ```apex SBlueprint.of(Task.class) .set('Subject', 'フォロー架電') .after('baseOpportunity'); // baseOpportunity より後に insert される ``` **項目としては繋がっていないのに、insert 順序だけは決めたい**ときに使います。典型的にはトリガーの副作用です。「先に商談が存在していないと、後から入れた活動のロールアップが合わない」のように、順序が結果に効くのにレコード同士に lookup が無い、という状況が実際にあります。 ```apex // ❌ 順序を作るためだけに、使いもしない項目へ Id を流し込む .use('baseOpportunity', 'Id', 'WhatId') // ✅ 順序だけが要るなら、順序だけを宣言する .after('baseOpportunity') ``` `{#}` などのプレースホルダも `.use()` と同じように使えます (`.after('opp_{#}')`)。起点と刻みを変える `.after(alias, startAt, interval)` のオーバーロードも同じ形です。 > 📌 `.use()` と `.after()` は内部的に**同じ依存グラフの辺**として扱われます。違いは「値を運ぶ辺か、運ばない辺か」だけで、順序解決の仕組みは完全に共通です ([依存解決の仕組み](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-dependency-resolution-deep-dive))。 ## 例: 全部を組み合わせる これまでの基本 5 メソッドを 1 つのテストに組み合わせると、こうなります。 ```apex @isTest static void testOppCreation() { SOrchestrator.start() .add( SBlueprint.of(Account.class) .template(Blueprints.accBasic()) .alias('parentAccount') ) .add( SBlueprint.of(Opportunity.class) .set('Name', 'Test Opportunity') .set('StageName', 'Prospecting') .set('CloseDate', Date.today().addDays(30)) .use('parentAccount', 'Id', 'AccountId') .alias('targetOpp') ) .create(); // 検証 Opportunity created = [ SELECT Id, Name, AccountId FROM Opportunity WHERE Name = 'Test Opportunity' LIMIT 1 ]; Assert.isNotNull(created.AccountId); } ``` ポイント: - `Account` 側は **テンプレートまかせ**。このテストの本筋は Opportunity の作成なので、Account の中身は揃ってさえいれば何でもよい - `Opportunity` 側は **検証対象になる項目だけ `.set(...)`**。「最終的にこの Opportunity が Account に紐付けて作成される」という意図がそのまま読める - `SOrchestrator` が依存関係を解決してくれるので、親子の insert 順を意識する必要はなし > このページの主題が **単一レコードの宣言** のため、ここでは 2 つの `SBlueprint` を別々に `.add(...)` して `.use(...)` でつなぐ書き方をしています。ただし実際の運用では、親子関係を作るときは **`withChildren` で「Account の下に Opportunity がぶら下がる」構造をそのまま 1 つの blueprint として書く方が、コードのインデント階層がデータ階層と一致して可読性が高い** ため、そちらを優先するのが推奨です。詳しくは [親子・量産・参照のパターン](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-relations-and-bulk) で扱います。 ## 関連ドキュメント - [SOrchestrator で依存解決と実 DML 挿入](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-sorchestrator-guide): 組み立てた blueprint を実行に移すフェーズ - [親子・量産・参照のパターン](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-relations-and-bulk): `withChildren` / `times` / `{#}` / `{P0}` `{P1}` などの応用 - [API リファレンス: SBlueprint](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-api-sblueprint): 全メソッドのシグネチャ網羅 - [ApexBlueprint ガイドへ戻る](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-guide) ============================================================================== Source: https://krileworks.com/document/ja/apex-blueprint-sorchestrator-guide.md Page: https://krileworks.com/ja/apex-stem/docs/apex-blueprint-sorchestrator-guide ============================================================================== # SOrchestrator で依存解決と実 DML 挿入 `SBlueprint` で組み立てた設計図を **実レコードに変換するエンジン** が `SOrchestrator` です。親 → 子 の挿入順、親 Id を子へ転記する処理、alias で参照した値の解決、これらをすべて自動でやってくれます。 このページでは、`SOrchestrator` の 4 つのメソッド (`start` / `add` / `create` / `getByAlias`) を順に解説し、最後に実運用でハマりやすいポイントをまとめます。 ## SOrchestrator.start(): builder の初期化 すべての操作はこの静的メソッドから始まります。戻り値は空の `SOrchestrator` インスタンスで、そこに blueprint を `.add(...)` して、最後に `.create()` で実行する、という流れになります。 **シグネチャ:** `SOrchestrator.start()` ```apex SOrchestrator orchestrator = SOrchestrator.start(); ``` ## .add(blueprint): blueprint をキューに登録する `.add(...)` は 1 つの `SBlueprint` を SOrchestrator のキューに登録します。 **シグネチャ:** `.add(SBlueprint blueprint)` ```apex SOrchestrator.start() .add( SBlueprint.of(Account.class) .template(Blueprints.accBasic()) .alias('parentAccount') ) .add( SBlueprint.of(Opportunity.class) .set('Name', 'Test Opportunity') .set('StageName', 'Prospecting') .set('CloseDate', Date.today().addDays(30)) .use('parentAccount', 'Id', 'AccountId') .alias('targetOpp') ); ``` ### 振る舞いの要点 - **追加順は無視される**。SOrchestrator が内部で依存関係を解析し、トポロジカルソートで insert 順を再決定する - 子を先に書いて親を後に書いても問題ない。「読みやすい順」で書ける - 1 つの SOrchestrator に複数の `.add(...)` を連続して呼べる - 親子関係を 1 つの blueprint 内で表現する場合 (`withChildren`) は、親側だけを `.add(...)` すれば子も一緒に登録される (詳細は [親子・量産・参照のパターン](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-relations-and-bulk)) ## .create(): 依存解決して DML 挿入する `.create()` は、登録された blueprint 群を解析し、**正しい順序で DML insert を実行** します。この呼び出しが終わった時点で、全レコードがデータベースに反映され、Id も払い出されています。 **シグネチャ:** `.create()` ```apex SOrchestrator orchestrator = SOrchestrator.start() .add(/* ... */) .add(/* ... */); orchestrator.create(); ``` ### 振る舞いの要点 - 内部で **トポロジカルソート** を実行し、依存される側 (親) から先に insert する - `.use('alias', 'Id', 'AccountId')` のような Id 参照は、親が insert された直後の Id を子のフィールドにコピーしてから子を insert する - DML 失敗時は通常の Apex DML 例外がそのまま投げられる - **戻り値は `void`**。`.create()` を `.add(...)` チェーンの末尾に繋いで変数へ代入することはできない。一旦 `SOrchestrator` を変数に受けてから `orchestrator.create()` を呼び、その同じ変数で `getByAlias(...)` する ## .getByAlias(name): 生成後のレコードを取り出す `create()` 後、alias で生成済みレコードを取り出せます。SOQL を書かずに「先ほど作った Account」を手元に持ってこられるので、アサーションの記述が短くなります。 **シグネチャ:** `.getByAlias(String aliasName)` (戻り値は `SObject`) ```apex SOrchestrator orchestrator = SOrchestrator.start() .add( SBlueprint.of(Account.class) .template(Blueprints.accBasic()) .alias('parentAccount') ); orchestrator.create(); Account parent = (Account) orchestrator.getByAlias('parentAccount'); Assert.isNotNull(parent.Id); ``` ### 振る舞いの要点 - 戻り値は `SObject` 型なので、利用側で目的の SObject 型にキャストする - **存在しない alias を渡すと `null` が返る** (例外ではない)。typo に気付きにくいので、取り出した直後に `Assert.isNotNull(...)` で守るのが安全 - `.times(n)` と `'{#}'` プレースホルダ付きの alias (例: `'con_{#}'`) で量産したレコードは、`'con_1'` / `'con_2'` / ... のように展開後の alias で個別に取り出せる (詳細は [親子・量産・参照のパターン](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-relations-and-bulk)) ## 例: 完全なテストワークフロー `start` → `add` → `create` → `getByAlias` までを 1 つのテストに通すと、こうなります。 ```apex @isTest static void testOppCreationWithAccount() { SOrchestrator orchestrator = SOrchestrator.start() .add( SBlueprint.of(Account.class) .template(Blueprints.accBasic()) .alias('parentAccount') ) .add( SBlueprint.of(Opportunity.class) .set('Name', 'Test Opportunity') .set('StageName', 'Prospecting') .set('CloseDate', Date.today().addDays(30)) .use('parentAccount', 'Id', 'AccountId') .alias('targetOpp') ); orchestrator.create(); Account parent = (Account) orchestrator.getByAlias('parentAccount'); Opportunity opp = (Opportunity) orchestrator.getByAlias('targetOpp'); Assert.areEqual(parent.Id, opp.AccountId); Assert.areEqual('Test Opportunity', opp.Name); } ``` ポイント: - **テスト本体に SOQL が一行も無い**。アサーション対象は `getByAlias(...)` で直接取り出せている - `Account` を先に `.add(...)` しているが、仮にここで `Opportunity` を先に `.add(...)` しても、SOrchestrator が依存解析で並び替えるので結果は同じ - 親 Id は `.use('parentAccount', 'Id', 'AccountId')` の宣言だけで子に転記される。「親を insert して Id を変数に取り、子の AccountId に代入して...」という手続きは消える ## ハマりどころ ### 循環依存 `A.use('B', ...)` と `B.use('A', ...)` の両方が成り立つような依存を作ると、トポロジカルソートが解けず `Circular or invalid reference detected` で `.create()` 時に失敗します。設計を見直して、一方を片方向の参照にするか、親子関係を `withChildren` に置き換えるかで解消します。 ### alias の重複 同じ `SOrchestrator` 内で `.alias('foo')` が 2 か所にあると `Duplicate alias detected` で失敗します。`.times(n)` で量産する場合は `'foo_{#}'` のようにプレースホルダ付きで宣言することで、展開後に一意な alias が払い出されます。 ### 存在しない alias の参照 `.use('typoAlias', ...)` のように、どこにも宣言されていない alias を参照すると、同じく `Circular or invalid reference detected` 系のエラーで `.create()` が失敗します。alias の typo は気付きにくいので、「`.use` の第 1 引数」と「対応する `.alias`」をペアで見直す習慣をつけると安全です。 ### 自動 alias は取り出し用には使わない `.alias(...)` を省略しても blueprint は問題なく登録できますが、内部的に `__Account_0_1__` のような **自動 alias** が割り振られます。これを後から `getByAlias` で取り出すのは現実的ではありません。**`getByAlias` で取り出す予定がある blueprint には必ず明示的に `.alias(...)` を付ける** のが原則です。 ## 関連ドキュメント - [SBlueprint で単一レコードを宣言する](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-sblueprint-guide): `.add(...)` に渡す設計図の作り方 - [親子・量産・参照のパターン](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-relations-and-bulk): `withChildren` / `times` / `{#}` / `{P0}` `{P1}` などの応用 - [API リファレンス: SOrchestrator](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-api-sorchestrator): 全メソッドのシグネチャ網羅 - [ApexBlueprint ガイドへ戻る](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-guide) ============================================================================== Source: https://krileworks.com/document/ja/apex-blueprint-relations-and-bulk.md Page: https://krileworks.com/ja/apex-stem/docs/apex-blueprint-relations-and-bulk ============================================================================== # 親子・量産・参照のパターン [SBlueprint で単一レコードを宣言する](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-sblueprint-guide) では、1 レコードを単独で宣言する基本 5 メソッドを扱いました。このページでは、そこから一歩進めて **複数レコードを構造的に組み立てる** ためのパターンを扱います。 具体的には以下のトピックです: - `withChildren`: 親 blueprint の中に子をネストする - `times(n)` + `{#}` プレースホルダ: 連番つきの量産 - `use` の offset 指定: 量産した親レコードの一部だけを子に紐付ける - `{P0}` / `{P1}`: ネスト内で「直近の親」「親の親」を参照する - `parentIdField`: 親が複数 lookup を持つときの曖昧解消 - `sharedWith`: 手動共有の宣言と、量産への追随 ## withChildren: 親の下に子をネストする `withChildren(child)` は、1 つの blueprint の中に「この blueprint の子レコードたち」を入れ子で書ける API です。コードのインデント階層がそのままデータ階層になるので、親子関係が一目で読み取れます。 ### 1:1 のシンプルな親子 ```apex SBlueprint.of(Account.class) .template(Blueprints.accBasic()) .alias('parentAccount') .withChildren( SBlueprint.of(Contact.class) .set('LastName', 'TestContact') .alias('childContact') ); ``` ポイント: - 子の `AccountId` への親 Id 転記は **自動**。`.use(...)` を書く必要は無い - `withChildren` の引数の中で子の blueprint を **インライン定義** することで、「この親の下にぶら下がる子」という構造が視覚化される - 子側で `.alias(...)` を付けておけば、`getByAlias('childContact')` で取り出せる ### 1:N の親子 (`times` との組み合わせ) `withChildren` の中で `.times(n)` を使うと、同じ親に複数の子をぶら下げられます。 ```apex SBlueprint.of(Account.class) .alias('parentAccount') .template(Blueprints.accBasic()) .withChildren( SBlueprint.of(Contact.class) .set('LastName', 'Contact-{#}') .alias('con_{#}') .times(3) ); ``` `con_1` / `con_2` / `con_3` の 3 件の `Contact` が、すべて同じ `Account` に紐付いた状態で生成されます。alias と `LastName` の両方で `{#}` を使っていることで、後から `getByAlias('con_2')` で個別に取り出せます。 ### ネストしてさらに孫まで `withChildren` は **ネスト可能** です。「Account の下に Contact、Contact の下に Case」のような 3 階層も自然に書けます。 ```apex SBlueprint.of(Account.class) .alias('acc') .template(Blueprints.accBasic()) .withChildren( SBlueprint.of(Contact.class) .set('LastName', 'TestContact') .alias('con') .withChildren( SBlueprint.of(Case.class) .set('Subject', 'TestCase') .alias('case') ) ); ``` ### 同じ親の下に複数種類の子を並べる 1 つの親に **異なる SObject 型の子** を並べたい場合は、`.withChildren(...)` を続けて呼び出します。 ```apex SBlueprint.of(Account.class) .template(Blueprints.accBasic()) .alias('acc') .withChildren( SBlueprint.of(Contact.class) .set('LastName', 'TestContact') .alias('childContact') ) .withChildren( SBlueprint.of(Opportunity.class) .set('Name', 'TestOpportunity') .set('StageName', 'Prospecting') .set('CloseDate', Date.today().addDays(30)) .alias('childOpp') ); ``` `Contact` と `Opportunity` が、それぞれ自分の `AccountId` に親 Id を持って同じ `Account` の下にぶら下がる形で生成されます。子側で `.times(...)` も使えるので、「Account の下に Contact 3 件 + Opportunity 2 件」のような不揃いな構造も自然に表現できます。 ## Multiplication: 上位階層の times が下位に伝播する `withChildren` と `.times(n)` を組み合わせるときに重要な仕様があります。**上位階層で `.times(n)` を入れて親を量産すると、その下にぶら下がる子は親ごとに丸ごと再生成される** ため、件数は掛け算で増えます。 ### 2 階層: 親 N × 子 M ```apex SBlueprint.of(Account.class) .set('Name', 'Acc-{#}') .alias('acc_{#}') .times(2) // 親 2 件 .withChildren( SBlueprint.of(Contact.class) .parentIdField('AccountId') .set('LastName', 'Con-{#}') .alias('con_{#}') .times(2) // 各親に 2 件ずつ ); ``` 結果: `Account` が 2 件、`Contact` が **2 × 2 = 4 件** 生成されます。 ### 3 階層以上: そのまま指数的に伸びる 階層を増やすと、件数はそのまま掛け算で積み上がっていきます。 ```apex SBlueprint.of(Account.class) .times(2) // 親 2 件 .withChildren( SBlueprint.of(Contact.class) .parentIdField('AccountId') .times(2) // 子 2 件 / 親 → 合計 4 件 .withChildren( SBlueprint.of(Case.class) .parentIdField('ContactId') .times(2) // 孫 2 件 / 子 → 合計 8 件 ) ); ``` 総レコード件数: `Account` 2 件 + `Contact` 4 件 + `Case` **2 × 2 × 2 = 8 件**。「階層の `.times(...)` 値の積」が末端のレコード件数になる、と覚えておけば見通しが立てやすくなります。 > 階層が深くなると **レコード数が指数的に増える** ことに注意してください。5 階層で各 `.times(3)` を重ねると `3⁵ = 243` 件のレコードが生成され、ガバナ制限の DML 行数 (10000) を圧迫します。「ちょっと多めに」のつもりが膨大な件数に化けやすいので、各階層の `.times(...)` は意識的に絞り込むのが安全です。 ## times + {#}: 連番つきの量産 `.times(n)` は同じ blueprint を `n` 件量産します。「3 件の Contact」「10 件の Account」のようなケースで、`for` ループを書かずに 1 行で済ませられます。 `{#}` プレースホルダは `.set(...)` の値や `.alias(...)` の引数に埋め込んで使います。`times(n)` で展開されるとき、`{#}` が `1`, `2`, `3`, ... と置き換わります。 ```apex SBlueprint.of(Contact.class) .set('LastName', 'Contact-{#}') .alias('con_{#}') .times(3); ``` 生成されるのは: | alias | LastName | |---|---| | `con_1` | `Contact-1` | | `con_2` | `Contact-2` | | `con_3` | `Contact-3` | ### アルファベット連番: `{A}` / `{a}` `{#}` の数値連番に加えて、**アルファベット連番** 用の `{A}` / `{a}` プレースホルダもサポートされています。大文字版が `{A}` で `'A'` / `'B'` / `'C'` ...、小文字版が `{a}` で `'a'` / `'b'` / `'c'` ... に展開されます。 ```apex SBlueprint.of(Account.class) .set('Name', 'Acc-{A}') .alias('acc_{a}') .times(3); ``` 生成されるのは: | alias | Name | |---|---| | `acc_a` | `Acc-A` | | `acc_b` | `Acc-B` | | `acc_c` | `Acc-C` | 「人間が読んで違いを判別したい」ようなテストデータ (テストレポートに出るラベル等) で、数字より文字記号の方が読みやすい場合に使い分けます。 ### startAt / interval: 連番の起点と刻みを変える `.set(...)` / `.alias(...)` には、`{#}` の **起点** と **刻み** を指定する追加引数があります。 ```apex SBlueprint.of(Account.class) .set('Name', 'Acc-{#}', 10, 2) // 10, 12, 14 .alias('acc_{#}', 10, 2) // acc_10, acc_12, acc_14 .times(3); ``` 「N 件目から始まる連番が欲しい」「偶数番号だけ作りたい」のような細かい要件にも対応できます。 ## use の offset 指定: 一部の親だけを子に紐付ける `.times(n)` で **量産した親レコード** の中から、一部だけを子に紐付けたいケースがあります。例えば「10 件の Account のうち、後半 5 件 (6 〜 10) だけに Contact を紐付ける」のような要件です。 `use` には 4-5 引数のオーバーロードがあり、参照する alias の **開始番号** と **刻み** を指定できます。 **シグネチャ:** `.use(String alias, String fromField, String toField, Integer startAt [, Integer interval])` ```apex SOrchestrator.start() .add( SBlueprint.of(Account.class) .alias('acc_{#}') .template(Blueprints.accBasic()) .times(10) // acc_1 〜 acc_10 ) .add( SBlueprint.of(Contact.class) .set('LastName', 'Con-{#}') .alias('con_{#}', 6) // con_6 〜 con_10 .use('acc_{#}', 'Id', 'AccountId', 6) // acc_6 〜 acc_10 を参照 .times(5) ); ``` 生成される子は `con_6` 〜 `con_10` の 5 件で、それぞれ `acc_6` 〜 `acc_10` を親に持つ形になります。「全件揃って同じ親を持つ」形ではなく、「不揃いな対応」を表現するのにこのオーバーロードが効きます。 ## {P0} / {P1} / ...: 上位階層の値を参照する ### なぜこのプレースホルダが必要か 各階層で `.times(...)` を使ったネストでは、末端のレコードから見た「自分の真の親」は **生成のたびに変わるインスタンス** になります。たとえば: - 親 (Account) `.times(2)` - 子 (Contact) `.times(2)` - 孫 (Case) `.times(2)` このとき孫は計 8 件、子は 4 件 (各親に 2 件ずつ)、親は 2 件です。各孫から見た「自分の真の親 (Contact)」は 4 件の Contact の中の特定の 1 件です。 ところが Contact 側で `.alias('child_{#}')` のように単純な `{#}` プレースホルダで alias を付けると、「Acme-1 の下の `child_1`」と「Acme-2 の下の `child_1`」が **同じ alias を二重宣言したことになり、alias 重複エラーで実行が止まります**。 抜け道はあります。`.alias('{P0}_child_{#}')` のように **親の alias を埋め込んだ複合 alias** を作れば、`__Account_0_1___child_1` / `__Account_0_1___child_2` / `__Account_0_2___child_1` / `__Account_0_2___child_2` のような 4 通りの一意な alias が払い出され、孫から `.use('__Account_0_1___child_1', ...)` のように指せます。 ただ、この方針には欠点があります: - 孫を `.use(...)` する側で **「自分の真の親の alias 文字列」を頭の中で組み立てる** 必要が出る - 階層構造を後で変更すると、alias 文字列の組み立てロジックも全部書き直し - 結果として、テストコードが「データ構造」ではなく「alias 文字列パズル」のように読めてしまう これを構造的に避けるのが `{P0}` / `{P1}` / `{P2}` ... プレースホルダです。「上から N 番目 (0 始まり) の階層にある、**自分にとっての真の親**」を SOrchestrator が内部で階層的に自動解決してくれるので、alias を打つ必要も、alias 文字列を頭で組み立てる必要もありません。 ### 数え方: ルートからの絶対深度 数え方は **「自分から上方向への距離」ではなく、ルートから数えた絶対深度** です: - `{P0}`: ルート (一番外側の親、階層 0) - `{P1}`: ルートの 1 つ下 (階層 1) - `{P2}`: さらにその下 (階層 2) - 以下、階層が深くなるごとに番号が増える つまり「4 階層目から階層 1 の値を参照したい」ときは `{P1}` を指定します (「現在地から上に 3 つ」ではないので注意)。 ### 最小例: 2 階層で `{P0}` を使う まずは最もシンプルな 2 階層構造で、`{P0}` (= ルート) を参照するパターンから見ます。子 Contact の `Description` に親 Account の `Name` を流し込むだけのケースです。 ```apex SBlueprint.of(Account.class) // P0 (ルート) .set('Name', 'Acme') .withChildren( SBlueprint.of(Contact.class) .set('LastName', 'TestContact') .use('{P0}', 'Name', 'Description') // ルート Account の Name を Description に ); ``` Contact の Description には `'Acme'` が入ります。`{P0}` が「ルート Account」を指していて、親側で `.alias(...)` を打つ必要がありません。 「親を 1 件作って、その値を子に引き写すだけ」という用途であれば、ここまでの理解で十分です。階層が深くなって `.times(...)` で量産が絡んだ、より高度な使い方は次の Example で見ます。 ### 例: 孫が「自分の真の親」の値を引き写す 3 階層 (親 / 子 / 孫) で、孫 Case の `Subject` に **自分の真の親 Contact の LastName** を入れます。 ```apex SBlueprint.of(Account.class) // P0 (階層 0 / ルート) .set('Name', 'Acme-{#}') .times(2) .withChildren( SBlueprint.of(Contact.class) // P1 (階層 1) .parentIdField('AccountId') .set('LastName', 'Contact-{#}') .times(2) .withChildren( SBlueprint.of(Case.class) // P2 (階層 2 = 孫 Case) .parentIdField('ContactId') .use('{P1}', 'LastName', 'Subject') // ← 自分の真の親 Contact の LastName .times(2) ) ); ``` 生成されるレコードと、孫 Case の Subject (= 自分の真の親 Contact の LastName) は次のようになります。 | Account | Contact (真の親) | Case (孫) Subject | |---|---|---| | `Acme-1` | `Contact-1` (Acme-1 の下) | `Contact-1` | | `Acme-1` | `Contact-2` (Acme-1 の下) | `Contact-2` | | `Acme-2` | `Contact-1` (Acme-2 の下) | `Contact-1` | | `Acme-2` | `Contact-2` (Acme-2 の下) | `Contact-2` | それぞれの Contact の下に孫 2 件ずつあるので、孫 Case は計 8 件、上記の組 4 通り × 各 2 件ずつ、という分布になります。同じ `Contact-1` という LastName を持つ Contact が 2 件 (Acme-1 の下 / Acme-2 の下) に存在しますが、各孫は **自分にぶら下がっている本物の親** から値を受け取ります。 ポイント: - alias を打たなくても、**「自分にとっての真の親」が階層的に自動解決される** - 数え方はルートからの絶対深度 (現在地から上方向への距離ではない) - 親 Id の転記は `withChildren` の自動処理に任せ、任意のフィールド値を引き写したいときだけ `{Pn}` を使う ## parentIdField: 親が複数 lookup を持つときに明示する 子オブジェクトが **複数の lookup 候補** を持っている場合 (例: `Contact` が `AccountId` と `CustomAccount__c` の両方を持つ)、`withChildren` だけでは「どちらの lookup に親 Id を入れるか」を SOrchestrator が判断できません。このとき `.parentIdField(...)` で明示します。 **シグネチャ:** `.parentIdField(String fieldName)` ```apex SBlueprint.of(Account.class) .alias('acc') .withChildren( SBlueprint.of(Contact.class) .parentIdField('AccountId') // どの lookup に親 Id を入れるかを明示 .template(Blueprints.conBasic()) ); ``` 複数 lookup が無いオブジェクトでは `.parentIdField(...)` を書く必要はありません。「曖昧でエラーになったら付ける」という後付けの保険として覚えておけば十分です。 ## sharedWith: 共有も量産に追随する (v2.0.0+) `.sharedWith(user, accessLevel)` は手動共有を**最終状態として宣言**します。`Foo__Share` / `AccountShare` のレコードを自分で組み立てる必要も、親より後に insert する順序を気にする必要もありません。 **シグネチャ:** `.sharedWith(User user, String accessLevel)` (`accessLevel` は `'Read'` または `'Edit'`) このページの文脈で重要なのは、**共有宣言がここまで説明してきた量産の仕組みにそのまま乗る**ことです。`times` で増えた親には、共有も同じ数だけ増えます。 ```apex SBlueprint.of(Invoice__c.class) .set('Name', 'Invoice-{#}') .alias('inv_{#}') .owner(admin) .sharedWith(rep, 'Read') .times(5); // → Invoice__c 5 件と、それぞれに対応する Invoice__Share 5 件 ``` ネストした子に付けた共有も同じで、**親の `times` に掛け算されて増えます**。 ```apex SBlueprint.of(Account.class) .set('Name', 'Acme-{#}') .times(2) .withChildren( SBlueprint.of(Invoice__c.class) .owner(admin) .sharedWith(rep, 'Read') .times(3) // 請求書 6 件 → 共有も 6 件 ); ``` これは共有だけの特別扱いではありません。`sharedWith` は内部で **`use()` で親に繋がれた兄弟 blueprint** を組み立てているだけなので、`times` の伝播も `{Pn}` の解決も、通常の子と完全に同じ経路をたどります (詳細は [依存解決の仕組み](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-dependency-resolution-deep-dive))。 ### 宣言が矛盾していれば DML の前に落ちる 共有は「書けば必ず通る」ものではないので、宣言の時点で成立しないケースは `create()` の解析段階で `ApexBlueprintException` になります。 | 状況 | 理由 | |---|---| | 組織の共有設定 (OWD) が `Public` のオブジェクト | 手動共有レコード自体が存在しない (`Foo__Share` が見つからない) | | `owner()` で指定した本人への共有 | オーナーは既に全権を持つため、Salesforce が手動共有を拒否する | | `accessLevel` が `'Read'` / `'Edit'` 以外 | 指定ミス | いずれも **DML には到達しません**。「insert してみたら共有が入っていなかった」ではなく、宣言した時点で理由つきで止まります。 > 📌 共有をテストする相手側のユーザー (ペルソナ) の作り方は [SPersona の API リファレンス](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-api-spersona) を参照してください。 ## 例: 親 1 件 + 子 3 件 + 孫 1 件 これまでのパターンを 1 つのテストに統合した例です。 ```apex @isTest static void testAccountWithContactsAndCase() { SOrchestrator orchestrator = SOrchestrator.start() .add( SBlueprint.of(Account.class) .template(Blueprints.accBasic()) .set('Name', 'ParentAccount') .alias('acc') .withChildren( SBlueprint.of(Contact.class) .set('LastName', 'Contact-{#}') .use('{P0}', 'Name', 'Description') // ルート Account (P0) の Name を Description に .alias('con_{#}') .times(3) ) ) .add( SBlueprint.of(Case.class) .set('Subject', 'TestCase') .use('con_2', 'Id', 'ContactId') // 2 番目の Contact に紐付け .alias('targetCase') ); orchestrator.create(); Account parent = (Account) orchestrator.getByAlias('acc'); Contact con2 = (Contact) orchestrator.getByAlias('con_2'); Case targetCase = (Case) orchestrator.getByAlias('targetCase'); Assert.areEqual(3, [SELECT COUNT() FROM Contact WHERE AccountId = :parent.Id]); Assert.areEqual('ParentAccount', con2.Description); Assert.areEqual(con2.Id, targetCase.ContactId); } ``` ポイント: - `withChildren` のネストで「Account に Contact が 3 件ぶら下がる」構造をそのまま視覚化 - `use('{P0}', 'Name', 'Description')` で親 Account の Name を子 Contact の Description に流し込み - `{#}` 付きの alias (`con_{#}`) で、後から `'con_2'` という形で 2 番目の Contact を取り出して Case に紐付け - テスト本体には複雑な手続きが無く、「最終的に作りたいデータ構造」がそのまま読める ## 例: 木をまたぐ共有参照 (ダイヤ型の依存) `withChildren` は「木」を表現しますが、実際の結合テストでは **木の外にある共有レコードを、深くネストした子から参照したい** ことがあります。たとえば `Account → Opportunity → Quote → QuoteLineItem` の 4 階層で、最深部の `QuoteLineItem` が共有の `Product2` を参照するようなケースで、これは木ではなく「ダイヤ型」(DAG) の依存になります。 親子の鎖は `withChildren` で、木をまたぐ参照は `use` で宣言し、共有レコードは別の `add` で登録して alias で参照するだけです。`add` の順序は自由で、SOrchestrator が「`Product2` と `Account` を `QuoteLineItem` より先に」とトポロジカルソートで並べてくれます。 ```apex @isTest static void testQuoteLineItemReferencesSharedProduct() { SOrchestrator orchestrator = SOrchestrator.start() .add( SBlueprint.of(Product2.class) .set('Name', 'Widget') .alias('product') // 木の外に共有レコード ) .add( SBlueprint.of(Account.class) .template(Blueprints.accBasic()) .withChildren( SBlueprint.of(Opportunity.class) .template(Blueprints.oppBasic()) .withChildren( SBlueprint.of(Quote.class) .set('Name', 'Q-2026') .withChildren( SBlueprint.of(QuoteLineItem.class) .template(Blueprints.qliBasic()) .set('Quantity', 3) .use('product', 'Id', 'Product2Id') // 木をまたぐ参照 .alias('qli') ) ) ) ); orchestrator.create(); Product2 product = (Product2) orchestrator.getByAlias('product'); QuoteLineItem qli = (QuoteLineItem) orchestrator.getByAlias('qli'); Assert.areEqual(product.Id, qli.Product2Id); } ``` ポイント: - 共有レコード (`Product2`) は木の枝ではないので、`withChildren` ではなく **別の `add` + `alias`** で登録する - 木をまたぐ参照は `use('product', 'Id', 'Product2Id')` の 1 行で宣言。親子の鎖 (`withChildren`) と組み合わせると、依存グラフは木ではなく「ダイヤ型」になる - `add` の順序は読みやすい順でよい。insert 順序は SOrchestrator がトポロジカルソートで解決するので、利用者は一切気にしない - `QuoteLineItem` の必須項目 (`PricebookEntryId` / `UnitPrice` など、実際には `PricebookEntry` を別途 `add` で用意する必要がある) は `Blueprints.qliBasic()` の `template` に寄せ、テスト本体には検証したい差分 (`Quantity`) と参照 (`use`) だけを残す ## 関連ドキュメント - [SBlueprint で単一レコードを宣言する](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-sblueprint-guide): 基本 5 メソッド - [SOrchestrator で依存解決と実 DML 挿入](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-sorchestrator-guide): 実行エンジン - [API リファレンス: SBlueprint](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-api-sblueprint): 全メソッドのシグネチャ網羅 - [ApexBlueprint ガイドへ戻る](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-guide) ============================================================================== Source: https://krileworks.com/document/ja/apex-blueprint-api-sblueprint.md Page: https://krileworks.com/ja/apex-stem/docs/apex-blueprint-api-sblueprint ============================================================================== # API リファレンス: SBlueprint `SBlueprint` は ApexBlueprint で **単一レコードの設計図** を宣言するメソッドチェーンクラス。`of(...)` で起点を作り、値・identifier・親子関係・量産・参照などをチェーンで積み重ねて、最終的に `SOrchestrator` に渡して実行します。 使い方や典型シナリオは [SBlueprint で単一レコードを宣言する](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-sblueprint-guide) と [親子・量産・参照のパターン](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-relations-and-bulk) を参照してください。 ## Static ファクトリ | メソッド | 用途 | |---|---| | `SBlueprint.of(System.Type recordType)` | SBlueprint の起点。`SBlueprint.of(Account.class)` の形式で、対象 SObject タイプを宣言 | `recordType` に `null` や非 SObject 型を渡すと例外が投げられます。 ## 値設定 (Set & Template) | メソッド | 用途 | |---|---| | `set(String fieldName, Object value)` | 単一フィールドに値をセット。同じフィールドへの再呼び出しは last wins。`template` の値も上書き可能 | | `set(String fieldName, Object value, Integer startAt, Integer interval)` | `{#}` プレースホルダの **起点** と **刻み** を指定。`set('Name', 'Acc-{#}', 10, 2)` → `'Acc-10'` / `'Acc-12'` / `'Acc-14'` | | `template(Map templateMap)` | デフォルト値の Map をまとめて適用。RecordType / 必須項目などの共通設定を `Blueprints.cls` に集約する運用との組み合わせを推奨 | `startAt` / `interval` に **負の値** を渡すと例外が投げられます。 ```apex SBlueprint accountBp = SBlueprint.of(Account.class) .template(Blueprints.accBasic()) // 共通デフォルト .set('Name', 'CustomName') // 個別上書き .set('Index', 'No.{#}', 1, 1); // {#} 連番 ``` ## 識別子 (Alias) | メソッド | 用途 | |---|---| | `alias(String aliasName)` | この blueprint に一意な参照名を付ける。後で `.use(alias, ...)` や `SOrchestrator.getByAlias(alias)` から参照可能 | | `alias(String aliasName, Integer startAt)` | `{#}` を含む alias で、展開の起点を指定 | | `alias(String aliasName, Integer startAt, Integer interval)` | 起点 + 刻みを指定 | alias は `SOrchestrator` 内で **一意である必要** があり、重複すると `Duplicate alias detected` で実行時エラーになります。`.times(n)` と組み合わせる場合は `'con_{#}'` のような `{#}` 付き alias を使って、展開後に一意な値が払い出されるようにします。 ```apex SBlueprint.of(Contact.class) .alias('con_{#}') // con_1 / con_2 / con_3 .times(3); ``` ## 参照 (Use) 別の blueprint からの値を、自分のフィールドにマッピングする多目的 API。「Id を子の lookup に転記する (リレーション作成)」と「任意のフィールド値を引き写す」の両方に使えます。 | メソッド | 用途 | |---|---| | `use(String aliasName, String fromField, String toField)` | 基本形。`aliasName` blueprint の `fromField` を、自分の `toField` にコピー | | `use(String aliasName, String fromField, String toField, Integer startAt)` | `{#}` プレースホルダ付きの alias を参照する場合の起点を指定 | | `use(String aliasName, String fromField, String toField, Integer startAt, Integer interval)` | 起点 + 刻みを指定 | `startAt` / `interval` に **負の値** を渡すと例外が投げられます。詳細な使い方や「不揃いな対応」のパターンは [親子・量産・参照のパターン](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-relations-and-bulk) を参照してください。 ```apex SBlueprint.of(Contact.class) .use('acc_{#}', 'Id', 'AccountId', 6) // acc_6 〜 acc_10 を参照 .alias('con_{#}', 6) // 子側も 6 始まり .times(5); ``` ## 量産 (Times) | メソッド | 用途 | |---|---| | `times(Integer n)` | 同じ blueprint を `n` 件生成。`n <= 0` を渡すと例外 | `.times(n)` を **`.withChildren(...)` のネスト内** で使うと、件数は階層をまたいで掛け算で増えます (親 2 × 子 2 = 子 4 件)。詳細は [親子・量産・参照のパターン > Multiplication](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-relations-and-bulk) を参照。 ## 順序だけの依存 (After) | メソッド | 用途 | |---|---| | `after(String alias)` | 値のコピーなしに「この alias より後のレイヤーで insert する」とだけ宣言 | | `after(String alias, Integer startAt)` | `{#}` を含む alias を参照する場合の起点を指定 | | `after(String alias, Integer startAt, Integer interval)` | 起点 + 刻みを指定 | `use()` は「参照を作る」ため、値のコピーが伴います。**値は要らないが順序だけ守りたい**とき、たとえばトリガーの都合で「A が入ってから B を入れたい」ようなケースに `after()` を使います。 ```apex SOrchestrator.start() .add(SBlueprint.of(Account.class).template(Blueprints.accBasic()).alias('acc')) .add( SBlueprint.of(Contact.class) .set('LastName', 'Yamada') .after('acc') // acc より後のレイヤーに置くだけ。値は引かない ); ``` ## 所有者と共有 (Owner & Share) | メソッド | 用途 | |---|---| | `owner(User user)` | レコードの所有者を設定。`set('OwnerId', user.Id)` の糖衣 | | `sharedWith(User user, String accessLevel)` | **手動共有を最終状態として宣言**。`accessLevel` は `'Read'` / `'Edit'` | `sharedWith` を書くと、`create()` の際に `Foo__Share` / `AccountShare` といった**兄弟 blueprint が自動生成**され、そのレコードの 1 レイヤー後に insert されます。親 Id の配線も自動です。 「誰がこのレコードを持っていて、誰から見えるのか」は項目値と同じく**宣言された最終状態の一部**、という考え方から blueprint 側に置かれています。 ```apex // 管理者が所有し、rep には Read だけ与える = runAs 監査テストの「敵対的データ」 SOrchestrator.start() .add( SBlueprint.of(Invoice__c.class).alias('inv') .owner(admin) .sharedWith(rep, 'Read') ); ``` `times` / ネスト / `{Pn}` と合成できます (子の共有は子と一緒に増殖します)。OWD が Public のオブジェクト、所有者自身への共有、不正な `accessLevel` は **fail-fast** で弾かれます。 ## 親子関係 | メソッド | 用途 | |---|---| | `withChildren(SBlueprint child)` | 親 blueprint の中に子をネストする。子の lookup には親 Id が自動転記される。同じ親に異なる SObject 型の子を並べる場合は `.withChildren(...).withChildren(...)` のように複数回呼べる | | `parentIdField(String fieldName)` | 子が複数の lookup を持っているとき、親 Id を入れるフィールドを明示。曖昧でない場合は不要 | ```apex SBlueprint.of(Account.class) .alias('acc') .withChildren( SBlueprint.of(Contact.class) .parentIdField('AccountId') // 複数 lookup を持つ場合に明示 .set('LastName', 'TestContact') ); ``` ## プレースホルダ `.set` / `.alias` / `.use` の文字列引数の中で使える特殊プレースホルダ。 | プレースホルダ | 展開ルール | |---|---| | `{#}` | `1`, `2`, `3`, ... の数値連番。`.times(n)` で n 件分展開される。`startAt` / `interval` を指定すると起点・刻みを変えられる | | `{A}` | `'A'`, `'B'`, `'C'`, ... の大文字アルファベット連番 | | `{a}` | `'a'`, `'b'`, `'c'`, ... の小文字アルファベット連番 | | `{P0}` / `{P1}` / `{P2}` / ... | **ルートから数えた絶対深度** で、自分にとっての真の親を階層的に解決。主に `.use('{P1}', ...)` のように `use` の第 1 引数で「自分の真の親」を参照するのに使う | `{P0}` 〜 `{Pn}` は `.times(...)` で量産された親階層を持つネスト構造で、alias の `{#}` 展開だけでは「自分の真の親」を特定できない問題への構造的な解決手段です。動機と仕組みは [親子・量産・参照のパターン > {P0} / {P1} / ...](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-relations-and-bulk) を参照してください。 ## 主な例外 フレームワークが検出したエラーは **`ApexBlueprintException`** として投げられます。 | 状況 | 例外型 / メッセージ (抜粋) | |---|---| | `of(...)` に null や非 SObject 型 | `ApexBlueprintException`: invalid type | | `times(0)` 以下 | `ApexBlueprintException`: `Times must be greater than 0` | | `set` / `alias` / `use` の `startAt` / `interval` に負の値 | `ApexBlueprintException`: negative value not allowed | | alias 重複 (`create()` 時) | `ApexBlueprintException`: `Duplicate alias detected` | | 存在しない alias 参照 (`use` の typo など、`create()` 時) | `ApexBlueprintException`: `Circular or invalid reference detected` | | 複数 lookup の曖昧 (`parentIdField` 未指定、`create()` 時) | `ApexBlueprintException`: `multiple parent relationships with the same parent object` | | 項目適用の失敗 (存在しない / 数式 / 自動採番 / 作成不可 / 型不一致) | `ApexBlueprintException`: 診断つき (`Reason:` に理由、`Provided:` に値) | | 必須項目の欠落・入力規則違反 | **通常の `DmlException` がそのまま** | ### 例外型で原因を切り分けられる `create()` が失敗したとき、**例外の型がそのまま原因の切り分けになります**。 - **`ApexBlueprintException`** = 宣言のミス。**テストコードを直す** - **`DmlException`** = org が insert を拒否した。**template か org 設定を直す** 項目適用のエラーは「どの blueprint (alias) の・どの経路 (`set` / `template` / `use`) で・なぜ (存在しない / 数式 / 自動採番 / 作成不可 / 型不一致)」まで自動で診断されます。 > ⚠️ v2.0.0 の破壊的変更です。それ以前は素の `DmlException` が投げられていたため、**既存の `catch (DmlException)` では捕まらなくなります**。移行時は catch 節の見直しが必要です。 ## 関連ドキュメント - [SBlueprint で単一レコードを宣言する](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-sblueprint-guide): 基本 5 メソッドの使い方 - [親子・量産・参照のパターン](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-relations-and-bulk): `withChildren` / `times` / `{P0}` 等の応用 - [API リファレンス: SOrchestrator](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-api-sorchestrator): 実行エンジン側の API - [ApexBlueprint ガイドへ戻る](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-guide) ============================================================================== Source: https://krileworks.com/document/ja/apex-blueprint-api-sorchestrator.md Page: https://krileworks.com/ja/apex-stem/docs/apex-blueprint-api-sorchestrator ============================================================================== # API リファレンス: SOrchestrator `SOrchestrator` は ApexBlueprint の **実行エンジン** です。`SBlueprint` で組み立てた設計図を受け取り、依存関係をトポロジカルソートで解析して、正しい順序で DML insert を実行します。API は意図的に **4 メソッドだけ** に絞られています。 使い方や典型シナリオは [SOrchestrator で依存解決と実 DML 挿入](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-sorchestrator-guide) を参照してください。 ## Static ファクトリ | メソッド | 用途 | |---|---| | `SOrchestrator.start()` | 通常用。内部で標準の DML 実行を使う | | `SOrchestrator.start(IDmlOperator dmlOperator)` | DML 実行層を差し替える。テストでは `new MockDmlOperator()` を渡して **実 DML を発火させずに挙動を検証** できる | ```apex // 本番 (実 DML) SOrchestrator orchestrator = SOrchestrator.start(); // テスト (DML なし、 ApexBlueprint 自身のテストで使うパターン) SOrchestrator orchestrator = SOrchestrator.start(new MockDmlOperator()); ``` `MockDmlOperator` は ApexBlueprint OSS 内部 (`DmlOperators/`) に含まれており、主に **ApexBlueprint 自身のテスト** を書く場面で使います。通常のテストデータ生成用途では `SOrchestrator.start()` (引数なし) を使います。 ## 登録と実行 | メソッド | 用途 | |---|---| | `add(SBlueprint blueprint)` | 1 つの `SBlueprint` をキューに登録。**追加順は無視される** (内部のトポロジカルソートで insert 順が決定する) | | `create()` | 登録された全 blueprint を解析し、依存解決後に DML insert を実行。**戻り値は `void`** (一旦 `SOrchestrator` を変数に受けてから `create()` を呼ぶ) | ```apex SOrchestrator orchestrator = SOrchestrator.start() .add(SBlueprint.of(Account.class).alias('parentAccount').template(Blueprints.accBasic())) .add( SBlueprint.of(Opportunity.class) .set('Name', 'Test Opportunity') .use('parentAccount', 'Id', 'AccountId') .alias('targetOpp') ); orchestrator.create(); ``` ## 結果の取り出し | メソッド | 用途 | |---|---| | `getByAlias(String aliasName)` | `create()` 後、alias で生成済み SObject を取り出す。戻り値は `SObject` (利用側でキャスト) | 存在しない alias を渡すと **`null` が返る** (例外ではない) ため、typo に気付きにくい場合は取り出した直後に `Assert.isNotNull(...)` で守ると安全です。 ```apex Account parent = (Account) orchestrator.getByAlias('parentAccount'); Opportunity opp = (Opportunity) orchestrator.getByAlias('targetOpp'); ``` `.times(...)` 付きで量産した blueprint は、alias の `{#}` プレースホルダ展開後の名前で個別に取り出せます (`'con_1'` / `'con_2'` / ...)。`withChildren` でネストした blueprint には `__Account_0_1___Contact_1_1__` のような **自動 alias** が振られるため、取り出す必要があれば必ず `.alias(...)` を明示してください。 ## 主な例外 (`create()` 時) | 状況 | 例外型 / メッセージ (抜粋) | |---|---| | 循環依存 (`A.use('B', ...)` と `B.use('A', ...)` が両方成立) | `ApexBlueprintException`: `Circular or invalid reference detected` | | 同一 blueprint チェーン内で alias 重複 | `ApexBlueprintException`: `Duplicate alias detected` | | 別の `.add(...)` 間で alias 重複 | `ApexBlueprintException`: `Duplicate alias detected` | | 存在しない alias を `.use(...)` で参照 | `ApexBlueprintException`: `Circular or invalid reference detected` (内部的に同じ扱い) | | 子の lookup が複数で `parentIdField` 未指定 | `ApexBlueprintException`: `multiple parent relationships with the same parent object` | | Apex 標準 DML が失敗する条件 (必須項目欠落 / バリデーション違反 等) | **通常の `DmlException` がそのまま** | **例外の型が、そのまま原因の切り分けになります。** `ApexBlueprintException` なら宣言のミス (テストコードを直す)、`DmlException` なら org が insert を拒否した (template か org 設定を直す)。 > ⚠️ v2.0.0 の破壊的変更です。それ以前はフレームワークの検証エラーも素の `DmlException` だったため、既存の `catch (DmlException)` では捕まらなくなります。 ## 関連ドキュメント - [SOrchestrator で依存解決と実 DML 挿入](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-sorchestrator-guide): start / add / create / getByAlias の使い方 - [API リファレンス: SBlueprint](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-api-sblueprint): `.add(...)` に渡す設計図の API - [親子・量産・参照のパターン](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-relations-and-bulk): `withChildren` / `times` / `{P0}` 等の応用 - [ApexBlueprint ガイドへ戻る](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-guide) ============================================================================== Source: https://krileworks.com/document/ja/apex-blueprint-api-spersona.md Page: https://krileworks.com/ja/apex-stem/docs/apex-blueprint-api-spersona ============================================================================== # API リファレンス: SPersona `SPersona` は、`System.runAs` で使う**制限ユーザー (ペルソナ) を生成する**ためのビルダーです。`SBlueprint` と同じくイミュータブルで、各メソッドが新しいインスタンスを返します。 権限まわりのテストを書こうとすると、テストユーザーの作成に細かい落とし穴が並びます (一意な Username、mixed DML、ロケール既定値、Profile 名の解決)。`SPersona` はその「正しい 1 つの実装」を 1 クラスに集約しています。**`UserFactory` を自前で書かないでください。** ## Static ファクトリ | メソッド | 用途 | |---|---| | `SPersona.of(String name)` | 起点。短いペルソナ名 (`'sales-rep'` など) を渡す。LastName などに使われる | ## 組み立て | メソッド | 用途 | |---|---| | `profile(String profileName)` | Profile を**名前**で指定 (`'標準ユーザー'` / `'Standard User'` など)。`create()` 時に解決され、テスト実行全体でキャッシュされる | | `permissionSets(String permissionSetName)` | 割り当てる PermissionSet を **API 名**で 1 つ追加 (ラベルではない) | | `permissionSets(List permissionSetNames)` | 同上、複数まとめて | | `set(String fieldName, Object value)` | 任意の User 項目を上書き。`SBlueprint.set()` と同じく last wins | ## 生成 | メソッド | 戻り値 | 用途 | |---|---|---| | `create()` | `User` | Profile を解決し、安全な既定値で User を組み立て、PermissionSetAssignment ごと insert して返す | ```apex User rep = SPersona.of('sales-rep') .profile('標準ユーザー') .permissionSets('InvoiceReadOnly') .set('LanguageLocaleKey', 'ja') .create(); ``` ## 知っておくべき挙動 - **Username は UUID で払い出される**ので、テストの並列実行でも衝突しません - **ロケール系の既定値は実行ユーザー基準**で埋まります (org に依存しない)。変えたければ `set()` で上書き - **mixed DML を内部で回避**しています (`System.runAs` でラップして insert)。通常の DML と混ぜて呼んで構いません - **`create()` はテストコンテキスト専用**です。`System.runAs` に依存するため、本番コードからは呼べません - **キャッシュはしません**。呼ぶたびに新しいユーザーが作られます (Profile 名 → Id の解決だけがキャッシュされます) - ⚠️ **Profile 名はロケール依存**です。日本語 org では `'標準ユーザー'`、英語 org では `'Standard User'` になります ## 敵対的データとセットで使う `SPersona` が本領を発揮するのは、`SBlueprint` の `owner()` / `sharedWith()` と組み合わせたときです。「管理者が所有し、テスト対象のペルソナには最小限だけ共有する」という**敵対的なデータ**を宣言できます。 ```apex User admin = SPersona.of('admin').profile('システム管理者').create(); User rep = SPersona.of('sales-rep').profile('標準ユーザー').create(); SOrchestrator orchestrator = SOrchestrator.start() .add( SBlueprint.of(Invoice__c.class).alias('inv') .owner(admin) // admin 所有 .sharedWith(rep, 'Read')); // rep には Read だけ orchestrator.create(); System.runAs(rep) { // rep から何が見えるか / 何が書けるかを検証する } ``` **`sharedWith` の有無が、そのまま可視性の期待値になります**。共有を宣言していないレコードが `runAs` の中で見えてしまったら、それは共有設定の穴です。 ## プロジェクト側での集約 `Blueprints.cls` と同じ考え方で、**プロジェクト固有のペルソナは単一の `Personas.cls` に集約**します。テストごとに Profile 名や PermissionSet 名を直書きすると、org 側の改名で一斉に壊れたときの修正箇所が散らばります。 ```apex public with sharing class Personas { /** 一般の営業担当 */ public static User salesRep() { return SPersona.of('sales-rep') .profile('標準ユーザー') .permissionSets('InvoiceReadOnly') .create(); } } ``` ## 関連ドキュメント - [API リファレンス: SBlueprint](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-api-sblueprint): `owner` / `sharedWith` で敵対的データを宣言する - [API リファレンス: SOrchestrator](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-api-sorchestrator): 依存解決と実 DML - [ApexBlueprint ガイド](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-guide): ガイド目次に戻る ============================================================================== Source: https://krileworks.com/document/ja/declarative-data-specification.md Page: https://krileworks.com/ja/apex-stem/docs/declarative-data-specification ============================================================================== # Declarative Data Specification: なぜ blueprint 形式か > **この記事の対象読者**: ApexBlueprint の **設計判断の背景・哲学** を理解したい方。「なぜこの API 形になったのか」を、従来の手続き型ファクトリパターンとの対比から読み解きたい開発者・アーキテクト向け。ApexBlueprint の内部実装には踏み込まないので、そちらに興味がある方は姉妹記事 [依存解決の仕組み](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-dependency-resolution-deep-dive) を参照してください。 ApexBlueprint の設計の中心にあるのは、結合テストデータの作成を **「手順を書き下す行為」から「データの最終状態を宣言する行為」へと置き換える** という発想です。このページでは、**手続き型のテストデータファクトリパターン全般** との概念対比から、「Declarative Data Specification」がどう違う角度で問題を解いているかを掘り下げます。 > このページでの対比対象は、特定の OSS ライブラリではなく、**「データ生成ロジックをメソッド内部に持つ一般的なファクトリパターン」** です。個別の TestDataFactory 実装が良いか悪いかという話ではなく、「メソッドにデータ生成を閉じ込める」という形式そのものが持つ性質を見ていきます。 ## 手続き型のテストデータファクトリパターン ここでの「手続き型ファクトリ」とは、**静的メソッドの中にレコード作成のロジックを書いておき、利用側はメソッドを呼ぶだけで完成済みレコードを受け取る** タイプの設計を指します。Salesforce の結合テストで広く採用される書き方で、単純なケースでは十分機能しますが、大別すると 2 つの典型パターンに分かれて、それぞれ別の弱点が出てきます。 ### パターン A. SObject ごとの「とりあえず一式作る」メソッドを置く `createAccount()` / `createOpportunity()` / `createContact()` のように、オブジェクトごとに「典型値で 1 件作る」メソッドを並べる形。利用側はそれらを順に呼び、戻ってきた ID で関連付けを組み立てます。 ```apex @isTest static void someTest() { Account acc = TestDataFactory.createAccount(); Contact con = TestDataFactory.createContact(acc.Id); // 親 Id を持ち回り Opportunity opp = TestDataFactory.createOpportunity(acc.Id); // ... } ``` このパターンで出やすい弱点: - **ID バケツリレーが利用側に残る**: 親レコードの ID を受け取って子の引数に渡す手続きを、利用側で毎回書く。階層が深くなると親 Id 用のローカル変数が増えて、テスト本体より接続コードの方が多くなる - **どんなデータが作られたか見えない**: メソッド名から「Account 1 件、Contact 1 件、Opportunity 1 件」のような **件数感** までは読めても、内部で何が起きているか (どんなフィールドが入っているか、数式が走るか、必須項目が満たされているか) はメソッド定義を辿らないと分からない - **親子の構造が形式的な ID 引数からしか読み取れない**: 「Contact が Account にぶら下がっている」という関係性が、単に `acc.Id` を引数に渡しているという事実からしか窺えない ### パターン B. シナリオごとに専用メソッドを置く `createOppForX01WithFlagA()` / `createOppForX02WithFlagB()` のように、検証シナリオに応じた専用メソッドを並べる形。利用側は 1 行呼ぶだけで複雑なデータ構造が手に入ります。 ```apex @isTest static void someTest() { Opportunity opp = TestDataFactory.createOppForX01WithFlagA(); // ... } ``` このパターンで出やすい弱点: - **メソッド爆発 / 引数爆発**: シナリオの組み合わせが増えるたびに、新しいメソッドが増えるか、既存メソッドの引数 (ブール値羅列) が増える。`createOpp(true, false, true, false)` のような呼び出しが出てきたら、「何を作っているか」を読み解くにはメソッド定義側を辿るしかない - **ファクトリ内部の責務肥大化**: 「どのシナリオを作るか」という条件分岐がファクトリ内に集積し、修正が局所化できなくなる - **後から読み直したときの理解コストが高い**: 利用側コードを読んでも、メソッド名だけでは中身を予測できず、必ずファクトリ側の実装を開かないと意図が掴めない ### パターン C. 業務シナリオ一式を共通の巨大ファクトリで作る A と B が混ざった形として、現場で最もよく見られるのが **「業務シナリオに必要なオブジェクトを、共通の 1 メソッドで全部作る」** パターンです。`createTestData()` のような汎用メソッドが、内部で Account / Opportunity / Quote / QuoteLineItem / Product まで一気に生成し、全てのテストがこれを呼んで使い回す、という形です。 ```apex @isTest static void someTest() { TestDataFactory.createTestData(); // Account / Opportunity / Quote / QuoteLineItem / Product が全部作られた状態 Opportunity opp = [SELECT Id, Amount FROM Opportunity LIMIT 1]; // ... opp に対する検証 ... } ``` 利用側は 1 行で済み、ID バケツリレーもメソッド爆発も一見回避できているように見えます。しかし、規模が大きくなるにつれて別種の構造的な弱点が表面化してきます: - **副作用が読めない**: テスト本体には `createTestData()` しか見えないため、「**このテストに対して Quote が同時に存在することは何を意味するのか?**」「**QuoteLineItem の存在は検証対象に影響するのか?**」を判断するには、結局ファクトリ実装を開いて全レコードの中身を把握する必要がある - **テストが落ちたときの切り分けが半日コース**: 失敗の原因がロジックの問題か、createTestData が作った副次レコードのトリガー副作用かを、容易に判別できない - **不要なレコードによる governor 圧迫**: 1 つの仕様検証のために、そのテストには関係ないレコードが毎回 5 オブジェクト分作られる。テスト件数が増えるとガバナ制限に追われる - **テスト独立性の崩壊**: `createTestData` を少しいじると **無関係に見えるテスト 20 件が連鎖的に落ちる** 現象が定期的に起きる - **結局 A / B の弱点を回避できない**: シナリオ違いに応じて `createTestDataForApproval()` / `createTestDataForCancellation()` のような派生メソッドが増殖し、やがて引数のブール値羅列も増える。「**メソッド爆発と引数爆発の両方が起きる**」という最悪の状態に至る A / B の表面的な手間を回避するために導入されたパターンですが、規模が大きくなるほど **テスト本体からデータの全体像と副作用が見えなくなる** という、より根の深い問題に置き換わっていきます。 ### 3 つのパターンに共通する根っこ A も B も C も、弱点の構造的な原因は同じで、**データ生成ロジックがメソッドの内側に閉じている** ことです。メソッドの出力は完成済みのレコードで、「**どんなフィールド構成で、どんな関連付けで作られたか**」という形は利用側のコードには現れません。 加えて、ファクトリのメソッドそれ自体に「条件 → 値」の生成ロジックが住んでいるので、シナリオが増えるたびに **メソッド or 引数を増やす以外の打ち手がない** という構造的な制約も生まれます。 そしてここで効いてくるのが、AI 時代における **コードとの向き合い方の変化** です。AI がコードを書く時代になると、人間の役割は「コードを **書く** こと」から「出てきたコードが **どんな意図で書かれたか** を理解すること」にシフトします。言い換えれば、**意図の理解こそが人間の仕事になる**。 このとき手続き型ファクトリには、構造的に決定的な弱点があります。「Account 1 件に Contact 3 件をぶら下げて、そのうち 1 件にだけ Opportunity を紐付ける」のようなシナリオの意図が、**メソッド呼び出しの裏側 (ファクトリ実装) に隠れていて、利用側のテストコードを読むだけでは復元できない** のです。メソッド名は **意図のラベル** にはなれても、**意図そのもの** にはなれません。ラベルが正確かどうかは、結局ファクトリ実装を開いてみないと分からない。 Declarative Data Specification が解こうとしているのは、まさにこの「**意図を構造体として表に出す**」という問題です。「最終的にどんなデータが存在してほしいか」が **コードの構造そのもの** として書かれていれば、メソッド定義を辿る必要がなくなり、AI が書いたコードでも人間が書いたコードでも、**意図を最短で読み取れる** 形になります。読み手にとってのコストが下がるだけでなく、**コード自体が意図のドキュメントを兼ねる** という性質を持つことになります。 ## Declarative Data Specification とは何か ApexBlueprint が選んだのは、これらの疲弊に対して「**API を増やす**」のではなく「**書く対象を変える**」という方向の解決です。 具体的には、「データを作るための手順」を書く代わりに、「**最終的に存在してほしいデータの構造**」を 1 つの式として書きます。親 Id の転記、insert 順、alias 解決といった機械的な作業はすべてフレームワークに肩代わりさせます。 ```apex SOrchestrator.start() .add( SBlueprint.of(Account.class) .template(Blueprints.accBasic()) .alias('acc') .withChildren( SBlueprint.of(Contact.class) .set('LastName', 'Contact-{#}') .times(3) ) .withChildren( SBlueprint.of(Opportunity.class) .set('Name', 'TestOpportunity') .set('StageName', 'Prospecting') .set('CloseDate', Date.today().addDays(30)) ) ) .create(); ``` このコードを上から下に読むと、そのまま「**Account 1 件、その下に Contact 3 件と Opportunity 1 件**」という最終データ構造が現れます。ID の持ち回りも insert 順の管理も書きません。コードのインデント階層がそのままデータ階層と一致しています。 これが **Declarative Data Specification** という発想の核です。「データの作り方」ではなく「**データの設計図**」を書く、という転換。そしてこの転換は、**コードの構造そのものが意図のドキュメントになる** という副次的な性質を生みます。「このテストは何を作って何を検証しているか」が、別ファイルを開かなくても、そのテスト本体の **形** から直接読み取れるようになります。 ### 構造から消えるもの 宣言的に書くことで、テストコードから以下の要素が消えます: - 親レコードの Id を一時変数で持ち回るコード - insert 順を考えて並べるコード - 子の lookup にコピーするコード - 「最終的にどんなデータができるか」をコメントで補足する必要 その代わり、「データ階層」「件数」「フィールド値」という **テストの本筋に必要な情報だけ** がコードに残ります。 ### 自動化される機械的作業 利用者から見えないところで、ApexBlueprint は次の作業を引き受けています: - 全 blueprint の **依存解析とトポロジカルソート** - 親 → 子の **insert 順** の自動決定 - 親 Id を子の lookup に **自動転記** - alias で参照された値の **階層的な解決** (`{P0}` / `{P1}` を含む) - 循環依存 / alias 重複 / 不正参照 / 複数 lookup の曖昧 などの **整合性チェック** これらは利用者には「依存を宣言したら勝手に動く」ように見えますが、内部では複数の問題が一手に解かれています。 ### テンプレートは「メソッド」ではなく「フィールドのプリセット」 冒頭で見た手続き型ファクトリと特に違うのは、**共通設定の再利用の仕組み** です。ApexBlueprint のテンプレート (`Blueprints.cls`) は、**メソッドではなく、フィールド値の `Map` をプリセットとして保持する** 形を取ります。 ```apex public with sharing class Blueprints { public static Map accBasic() { return new Map{ 'Name' => 'TestAccount', 'Industry' => 'Technology', 'AnnualRevenue' => 500000 }; } } ``` これを `.template(...)` で取り込み、各テストの **検証対象の項目だけ** `.set(...)` で上書きします。 ```apex SBlueprint.of(Account.class) .template(Blueprints.accBasic()) .set('Name', '○○商事'); // このテストで本筋の項目だけ ``` ポイントは: - **テンプレートはフィールド組み合わせの宣言だけで、ロジックを持たない** (`if` 分岐や条件付きの値生成は入らない) - **シナリオの違いはテンプレート側ではなく、テスト本体の `.set(...)`** で表現する - そのため、テンプレート側でメソッドを増やす必要は基本的になく、「SObject ごとの典型形」数種類で十分小さく保てる - テスト本体を読めば「このテストが何を変えて何を検証しているか」が `.set(...)` の行から直接読み取れる つまり、「メソッドを増やすか引数を増やすか」という二者択一の構造そのものから抜け出して、**「テンプレート = プリセットを置く場所、テスト本体 = 差分を書く場所」** という分業を持ち込んでいます。 ### DRY 原則との関係 ここまで読んで「**データの最終状態をテストごとに直書きするのは DRY 原則に反するのでは?**」という疑問を持つかもしれません。似たような blueprint チェーンが複数のテストに繰り返し現れる構造は、確かに「コードの形」として重複しているように見えます。 ただ、DRY 原則の本来の定義は "Every piece of knowledge must have a single, unambiguous, authoritative representation within a system" (`The Pragmatic Programmer`) であり、「**知識** は 1 箇所に集約する」であって「コードの形の重複を避ける」ではありません。 この観点で ApexBlueprint の設計を見直すと、**「何を DRY するべきで、何を露出するべきか」を意識的に分けている** ことが見えてきます: - **共通の値の組み合わせ (= 知識)**: `Blueprints.accBasic()` などのテンプレートに集約 → **DRY を満たす** - **テスト固有のデータ階層と検証意図 (= そのテストの主張)**: テスト本体に直書き → **意図として露出させる** 似たような構造が複数テストで繰り返し現れる場合でも、各テストが検証しているのは別の意図であり、これは知識の重複ではなく **意図ごとの個別具体性** です。DRY 原則を額面通り適用して「似た構造のテストは全部共通メソッドにまとめよう」とすると、結果として **意図がメソッド名の裏に隠れる** という、AI 時代に最も避けたい現象に逆戻りしてしまいます。 つまり ApexBlueprint の立場は次のようにまとめられます: > **知識は DRY、意図は露出** この分業によって、DRY の本来の精神 (知識の単一源) と、AI 時代の要求 (意図の透明性) を両立させる。「コードの形の重複」は表面的には許容するが、それは **意図を表に出すための意図的な選択** であり、DRY 原則そのものへの反抗ではない、という立て付けです。 ## なぜ Salesforce で特にこれが効くか Declarative Data Specification の発想自体は、結合テストを書く環境であれば一般的に有用です。ただし Salesforce には、この発想を **特に必要とする** 構造的な理由があります。 ### 多項目・複雑なリレーション Salesforce のオブジェクトモデルは、標準オブジェクト + カスタムオブジェクト + カスタム項目 + 複数の lookup + RecordType + 必須項目 + 検証ルール、という前提に立っています。1 つの Opportunity を「正しく」作るには、数十項目を意識する必要があり、そのうち何が必須・何がデフォルト・何が検証ルールでチェックされるかは組織ごとに異なります。 「手順」として書き下すと、この複雑性がテスト本体に流れ込みます。`Blueprints.cls` への共通設定の集約と `.template(...) + .set(...)` の組み合わせは、この複雑性を **テストの外側に追いやる** ための仕組みでもあります。 ### アドミン設定でテスト前提が変わる Salesforce では、開発者の知らないところでアドミンが項目を追加したり、バリデーションを増やしたり、RecordType を切ったりします。手続き型ファクトリでこの変更を吸収するには、**メソッド内部に書かれた条件分岐と値生成を順に修正** する必要があります。「いつ何が変わっているか分からない実行環境」という前提の上で、手続き型のテストデータ作成は綻びが大きくなりがちです。 宣言的に書いておくと、共通設定は `Blueprints.cls` で一元化されているため、変更の波及範囲が **構造的に絞られる** ことになります。 ### 「全部記憶するのは無理」という現実 複雑な業務システムでは、1 人の開発者が **全ての結合テストの手続きを把握しておく** ことは非現実的です。半年前に自分が書いたファクトリメソッドの中身を、半年後の自分が読み解けるかは怪しいものです。 宣言的なコードは、「**コードを読むこと自体がデータ構造の理解になる**」という性質を持ちます。これは、半年後の自分や他のチームメンバーがそのテストに介入するときの認知負荷を下げる、という長期的な利益につながります。 ## Apex Stem における位置づけ [Apex Stem](https://krileworks.com/ja/apex-stem) は 4 つの OSS で構成されており、ApexBlueprint はその中で **結合テストデータ生成 (Test Data Factory)** を担います。 [テスト戦略](https://krileworks.com/ja/apex-stem/docs/test-strategy) のページで詳しく書いていますが、Apex Stem は「Usecase 単体テスト」と「Handler 結合テスト」を構造的に分離しており、それぞれに異なる OSS を当てています: | テスト種別 | 担当 OSS | データ生成 | DML | |---|---|---|---| | Usecase 単体テスト | ApexEloquent (`MockEloquent` / `MockEntry`) | メモリ上のモック | なし | | Handler 結合テスト | ApexBlueprint (`SBlueprint` / `SOrchestrator`) | 実 DML で組織にレコード生成 | あり | 両者は「テストデータ作成」という同じ困難に対して、異なる文脈での解を提供しています。単体テストはロジックの網羅 (ApexEloquent の MockEntry が必要)、結合テストは「実プラットフォーム挙動との整合」(ApexBlueprint の SOrchestrator が必要)、という棲み分けです。 ApexBlueprint の Declarative Data Specification は、**結合テストでも宣言的な書き心地を維持する** ことを目的とした設計です。「テストの種類が違っても、テストコードの読み心地は揃える」という Apex Stem 全体の一貫性を支える役割を担っています。 ## 関連ドキュメント - [親子・量産・参照のパターン](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-relations-and-bulk): 宣言的記法の応用 (withChildren / times / {Pn}) - [依存解決の仕組み: トポロジカルソート + alias 解決](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-dependency-resolution-deep-dive): 内部で起きていることを掘り下げる Deep Dive - [テスト戦略](https://krileworks.com/ja/apex-stem/docs/test-strategy): ApexBlueprint / ApexEloquent の役割分担 - [Apex Stem 導入ガイド](https://krileworks.com/ja/apex-stem/docs/apex-stem-full-guide): 全体像の入り口 - [ApexBlueprint ガイドへ戻る](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-guide) ============================================================================== Source: https://krileworks.com/document/ja/apex-blueprint-dependency-resolution-deep-dive.md Page: https://krileworks.com/ja/apex-stem/docs/apex-blueprint-dependency-resolution-deep-dive ============================================================================== # 依存解決の仕組み: トポロジカルソート + alias 解決 > **この記事の対象読者**: ApexBlueprint の **内部実装** が気になる方。「シンプルな API の裏側で何が起きているか」を、フェーズ単位の擬似コードと実装ファイル (`SOrchestrator.cls` / `SBlueprintAnalyzer.cls` / `SBlueprintRealizer.cls`) の参照付きで追いたい開発者向け。設計判断の背景や哲学に焦点を当てた姉妹記事は [Declarative Data Specification: なぜ blueprint 形式か](https://krileworks.com/ja/apex-stem/docs/declarative-data-specification) を参照してください。 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 が下位に伝播する」仕様の源泉になっています ([親子・量産・参照のパターン](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-relations-and-bulk) 参照)。 #### `{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 点が際立っています: 1. **API の表面は 11 メソッドに収まり、学習コストが線形にしか増えない**。機能追加のたびにメソッド数が指数的に増えるタイプの API ではない 2. **裏側の各問題に対する解 (トポロジカルソート / 自動 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 形式か](https://krileworks.com/ja/apex-stem/docs/declarative-data-specification): なぜこの設計に至ったかの哲学を扱う姉妹 Deep Dive - [親子・量産・参照のパターン](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-relations-and-bulk): 利用者から見える表面の API パターン集 - [API リファレンス: SOrchestrator](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-api-sorchestrator): start / add / create / getByAlias と各例外 - [API リファレンス: SBlueprint](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-api-sblueprint): メソッドチェーンの全 API - [ApexBlueprint ガイドへ戻る](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-guide) ============================================================================== Source: https://krileworks.com/document/ja/apex-trace-lifecycle.md Page: https://krileworks.com/ja/apex-stem/docs/apex-trace-lifecycle ============================================================================== # Trace の 4 つのライフサイクルメソッド `Trace` クラスは、Usecase の処理の流れを表現する 4 つのライフサイクルメソッドを持ちます。 ## instance field に持つ `Trace` は Usecase クラスの instance field として **一度だけ** 生成します。 ```apex public with sharing class CopyAccountIndustryToOpportunityUsecase { private Trace t = Trace.of('商談に親取引先の業種をコピー'); // ... } ``` 「なぜ instance field で 1 回だけなのか」は、§「その他の注意点」で詳しく扱います。ここではまず、Usecase 1 つにつき 1 つの `Trace` を持つ、と覚えれば十分です。 ## 4 つのメソッドと 3 つの終了パス | メソッド | 用途 | 呼ぶタイミング | |---|---|---| | `t.start()` | 処理開始 | `invoke()` の冒頭 | | `t.log(msg)` | 中間ログ | 任意箇所、複数回 OK | | `t.skip(msg)` | スキップ終了 | 早期 return の直前 (処理対象なしなど) | | `t.abort(msg)` | 異常終了 | 例外で抜ける時、業務エラーで中止する時 | | `t.finish(msg)` | 正常終了 | `invoke()` の末尾 | `log` / `skip` / `abort` / `finish` には **引数なしのオーバーロード** もあります。メッセージを残す必要がない場面 (とにかく終了させたいだけ、など) では `t.finish()` のように呼べます。経路 (skip / finish / abort) の区別だけ残せばよく、追加のログは不要、というケースに向きます。 ポイントは **3 つの終了パス** です。 - **`finish`**: 処理が正常に完了したことを表す - **`skip`**: 「対象がない」「条件不一致」などで、何もせず正常に抜けることを表す - **`abort`**: 例外発生や業務エラーで、正常完了しなかったことを表す この 3 つを使い分けると、後段の `TraceFlow` で「どの経路で終わったか」をテストで検証できます。 ## コード例 `CopyAccountIndustryToOpportunityUsecase` を、`Trace` の使い方に焦点を当てて見てみます。 ```apex public with sharing class CopyAccountIndustryToOpportunityUsecase { @TestVisible static final String LBL_FETCH = 'oppFetch'; @TestVisible static final String LBL_UPDATE = 'oppUpdate'; private final Set opportunityIds; private final IEloquent eloquent; private Trace t = Trace.of('商談に親取引先の業種をコピー'); // (コンストラクタは省略。詳細は導入ガイドのステップ 3 を参照) public void invoke() { this.t.start(); if (this.opportunityIds == null || this.opportunityIds.isEmpty()) { this.t.skip('対象の商談がないため終了。'); return; } Scribe oppScribe = Scribe.of(Opportunity.class) .field('Id') .parentField(Scribe.asParent('AccountId').field('Industry')) .whereIn('Id', this.opportunityIds); List oppEntries = this.eloquent.label(LBL_FETCH).get(oppScribe); for (IEntry oppEntry : oppEntries) { IEntry accountEntry = oppEntry.getParent('AccountId'); oppEntry.put('Industry__c', accountEntry.get('Industry')); } this.eloquent.label(LBL_UPDATE).doUpdate(oppEntries); this.t.finish(oppEntries.size() + ' 件の商談に業種をコピー。'); } } ``` この Usecase は 2 つの経路で終わります。対象が空なら `skip`、処理を完了したら `finish`。次の章では、この 2 つの経路をテストで区別する方法を見ます。 ## 関連ドキュメント - [TraceFlow で経路を検証する](https://krileworks.com/ja/apex-stem/docs/apex-trace-flow-guide): 記録した経路をテストで縛る - [ネストと 2 つのモード](https://krileworks.com/ja/apex-stem/docs/apex-trace-nesting-and-modes): Usecase が Usecase を呼ぶときの順序ルール - [ApexTrace ガイド](https://krileworks.com/ja/apex-stem/docs/apex-trace-guide): ガイド目次に戻る ============================================================================== Source: https://krileworks.com/document/ja/apex-trace-flow-guide.md Page: https://krileworks.com/ja/apex-stem/docs/apex-trace-flow-guide ============================================================================== # TraceFlow でテストの経路を検証する `TraceFlow` は、直前に実行された Trace の状態を覗くための静的ユーティリティです。テストで「Usecase がどの経路で終わったか」を区別するのに使います。 ## 主な検証メソッド | メソッド | 検証内容 | |---|---| | `TraceFlow.isLastFinish()` | 直前の Trace が `finish()` で終わったか | | `TraceFlow.isLastSkip()` | 直前の Trace が `skip()` で終わったか | | `TraceFlow.isLastAbort()` | 直前の Trace が `abort()` で終わったか | | `TraceFlow.lastHistoryContains(text)` | **最後の 1 エントリ**に文字列が含まれるか | | `TraceFlow.contains(text)` | **履歴全体のどこか**に文字列が含まれるか | | `TraceFlow.lastHistory()` | 直前の `TraceHistory` オブジェクトを取得 | | `TraceFlow.usageOf(name)` | その名前のコンテキストのガバナ消費 (exclusive 合計、v1.2.0+) | | `TraceFlow.lastUsage()` | 直近に閉じた**1 コンテキスト**のガバナ消費 (バルク IT では `usageOf` を使う) | #### `lastHistoryContains` と `contains` の使い分け `lastHistoryContains` が見るのは**最後の 1 エントリだけ**です。そのため、あとから `t.log(...)` を 1 行足しただけでテストが落ちます。 - **`finish` / `skip` / `abort` の理由メッセージを縛る** → `lastHistoryContains`。直後のエントリなので順序が安定します - **途中の `t.log(...)` の内容を縛る / ログ追加に強くしたい** → `contains` ```apex Assert.isTrue(TraceFlow.contains('3 件の商談に業種をコピー')); // 履歴のどこにあっても通る ``` どちらも `null` を渡すと `false` を返します。 ## invoke() が void でも経路で検証できる 戻り値が `void` の Usecase は、テストの観察対象が副作用 (DML や field 更新の中身) に偏りがちです。それでも `TraceFlow` を組み合わせれば、「副作用」と「経路」の 2 軸で挙動を縛れます。これは ApexTrace が Apex Stem に標準装備されている理由のひとつです。 ## コード例: skip と finish を区別する 「対象がなくてスキップされた」と「正常に完了した」を、別々のテストとして書きます。 ```apex @isTest static void testInvoke_WhenOpportunityHasAccount_ThenIndustryCopied() { Trace t = Trace.of('正常系: 商談に親取引先の業種がコピーされること'); t.start(); MockEntry oppEntry = MockEntry.of(Opportunity.class) .alias('opp').autoId(1) .setParent('AccountId', MockEntry.of(Account.class).set('Industry', 'Technology')); MockEloquent mock = (new MockEloquent()) .attach(CopyAccountIndustryToOpportunityUsecase.LBL_FETCH, new List{ oppEntry }); (new CopyAccountIndustryToOpportunityUsecase( new Set{ oppEntry.getAliasId('opp') }, mock )).invoke(); Assert.areEqual(1, mock.upsertedRecordsAt(CopyAccountIndustryToOpportunityUsecase.LBL_UPDATE).size()); Assert.isTrue(TraceFlow.isLastFinish()); t.finish(); } @isTest static void testInvoke_WhenNoOpportunityIds_ThenSkipped() { Trace t = Trace.of('正常系: 対象の商談がないときスキップされること'); t.start(); MockEloquent mock = new MockEloquent(); (new CopyAccountIndustryToOpportunityUsecase( new Set(), mock )).invoke(); Assert.isTrue(TraceFlow.isLastSkip()); t.finish(); } ``` 両方のテストとも、戻り値ではない「経路」を `TraceFlow` で観察しています。`finish` パスを通ったか `skip` パスを通ったかが、副作用 (`upsertedRecords` の中身) とは別の軸で検証できます。 ログ内容の検証も可能です。 ```apex Assert.isTrue(TraceFlow.lastHistoryContains('対象の商談がないため終了')); ``` 「想定通りのメッセージで skip したか」までを縛れます。 ## 観測窓を Act に揃える: discardArrange() (v1.4.0+) `isLastFinish()` / `isLastSkip()` / `isLastAbort()` が見るのは「**最後に閉じたコンテキスト**」です。ところが **Trace の履歴はテストメソッドの先頭から溜まり続ける**ため、Arrange でレコードを作った DML がトリガー経由で Usecase を走らせていると、その分も履歴に残ります。 問題になるのは、**Act が何も起こさなかったとき**です。Act 側にコンテキストが 1 つも積まれないと、`isLastSkip()` が読むのは Arrange のコンテキストになります。つまり **Act を検証したつもりで Arrange を検証している**状態が起こり得ます。 `TraceFlow.discardArrange()` を Arrange と Act の境界に置くと、履歴がそこで区切られ、以降のアサートは Act だけを見ます。 ```apex setupAccountWithOpportunities(); // Arrange (トリガー経由で Usecase が走る) TraceFlow.discardArrange(); // ← ここ Test.startTest(); // ← と、ここは同じ境界 new ResummarizeUsecase(ids).invoke(); Test.stopTest(); Assert.isTrue(TraceFlow.isLastSkip()); // Act の経路だけを見る ``` `Test.startTest()` を置くのと同じ判断なので、新しく覚える概念はありません。書く場所も隣です。 > ⚠️ **Usecase のコンテキストが開いている最中には呼べません。** 境界で呼んでいる限り、開いているのはテスト自身の `Trace` が最大 1 つなので問題になりませんが、それ以上開いていると `TraceException` になります。 ## 関連ドキュメント - [Trace のライフサイクル](https://krileworks.com/ja/apex-stem/docs/apex-trace-lifecycle): 4 メソッドと 3 つの終了パス - [ガバナ消費を縛る](https://krileworks.com/ja/apex-stem/docs/apex-trace-governor-it): TraceUsage によるバルクの保険 - [ApexTrace ガイド](https://krileworks.com/ja/apex-stem/docs/apex-trace-guide): ガイド目次に戻る ============================================================================== Source: https://krileworks.com/document/ja/apex-trace-governor-it.md Page: https://krileworks.com/ja/apex-stem/docs/apex-trace-governor-it ============================================================================== # TraceUsage: コンテキスト単位のガバナ消費 各 Trace コンテキストは、`start()` からクローズ (`finish` / `skip` / `abort`) までの**ガバナ消費を自動で記録**しています。`TraceFlow.usageOf(name)` で名前を指定して取り出します (v1.2.0+)。 ## 何のための道具か まず設計意図を押さえてください。`TraceUsage` は「この Usecase は SOQL を何本使うべきか」を決める道具**ではありません**。狙いは**保険**です。バルクで走ったときに **1 件ごとにクエリを撃つ実装 (N+1) が混入していないこと**、それだけを緩い上限で縛ります。 > 📌 **本ページの「実測」は、商談 21 万件規模の 1 組織で計測した値です。** > とくに**起動回数はカスケードの再入回数に依存するため、あなたの環境では違う数字になります**。再入のない実装なら、後述の「30 件 → 2 回」は「30 件 → 1 回」です。数値は「この桁で動く」という感覚をつかむためのもので、そのまま閾値に使わないでください。 ## 3 者の棲み分け 測りたいものによって手段が変わります。 | 測りたいもの | 手段 | |---|---| | トリガーの**同期**カスケード全体 | `Limits` を**ブロック内で変数に掴む** | | **特定の Usecase** が引き起こした分 | **`TraceFlow.usageOf(name)`** (v1.2.0+) | | **非同期** (`stopTest()` で走るバッチ / Queueable) | **`TraceUsage` しかない** | 3 行目が `TraceUsage` の独壇場です。**バッチは `Test.stopTest()` で初めて走る**ので、`Limits` では原理的に測れません。 > 🚨 1 行目の「ブロック内で」は必須条件です。`Test.stopTest()` はガバナカウンタを `startTest()` 前に戻すため、**その後で読む `Limits.getQueries()` は Act ではなく Arrange の値**を返し、アサートが何があっても通ります (詳細は [テスト戦略](https://krileworks.com/ja/apex-stem/docs/test-strategy))。 ## 最小の形 まず 2 行で始められます。難しいのは「どこに置くか」と「上限をいくつにするか」だけです。 ```apex // Usecase を実行したあと、名前を指定して消費を引く TraceFlow.usageOf('取引先の商談サマリを再集計') .assertInvocationsAtMost(2); ``` `usageOf` に渡す名前は、Usecase 側の `Trace.of(...)` に書いた文字列**そのまま**です。 ```apex private Trace t = Trace.of('取引先の商談サマリを再集計'); // ← この文字列 ``` まずは**起動回数だけ**から始めるのをおすすめします。回数は「この Usecase は 1 回の保存につき 1 回動く」という設計意図そのものなので、**実際に走らせる前に決められます**。消費量の上限は実測しないと決まらないので、後回しで構いません。 ## バルク IT の全体像 本来の置き場所は、[ApexBlueprint](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-relations-and-bulk) の `times()` で本番サイズを量産し、**トリガー経由でカスケードを発火させる結合テスト**です。単体テスト (`MockEloquent`) は実 SOQL を発行しないので**クエリ数には構造的に盲目**で、この死角は実 DML を本番サイズで流す 1 本でしか塞げません。 ```apex @isTest static void testInsert_WhenBulk_ThenWithinGovernorLimits() { Trace t = Trace.of('エッジケース: 商談を一括登録してもガバナに余裕があること'); t.start(); // ---- Arrange: 本番サイズを宣言する (取引先 1 件 + 商談 201 件) ---- SOrchestrator o = SOrchestrator.start() .add(SBlueprint.of(Account.class) .template(Blueprints.accBasic()) .withChildren( SBlueprint.of(Opportunity.class) .template(Blueprints.oppBasic()) .times(201))); // 201 件以上 (200 件までだとトリガー分割を越えられない) // ---- Act: create() の実 DML が before/afterInsert をバルクで発火させる ---- TraceFlow.discardArrange(); // Arrange / Act の境界には必ず置く Test.startTest(); o.create(); Integer soqlUsed = Limits.getQueries(); // ★ 必ずブロックの中で掴む Test.stopTest(); // ---- Assert ---- // ① 結果の正しさ (1 件でも欠けたら合わない値で縛る。これが主眼) Assert.areEqual(201, [SELECT COUNT() FROM Opportunity], '201 件すべてが処理されていること'); // ② 狙った Usecase の消費 TraceFlow.usageOf('取引先の商談サマリを再集計') .assertInvocationsAtMost(6, '201 件 insert 時の実測は 5。増えたら別の入口からも配線された疑い') .assertSoqlQueriesAtMost(15, '実測 2 本。件数に比例して増えていたらループ内クエリの疑い'); // ③ トランザクション全体としてもガバナに余白があること Assert.isTrue(soqlUsed < Limits.getLimitQueries() / 2, 'バルクでも SOQL は上限の半分未満。実測 ' + soqlUsed); t.finish(); } ``` > ⚠️ この例は Arrange (`SOrchestrator` の組み立て) が DML を発行しないため、`discardArrange()` に実質的な効果はありません。**それでも置いてください。** 読者は自分のテストにこの形をコピーし、そちらには Arrange の DML があります。「境界には必ず置く」を型として見せるのが目的です。 `times(30)` では足りません。**Salesforce はトリガーを 200 件ずつに分けて呼ぶ**ため、200 件までのテストは「1 回の呼び出しで全件が来る」前提の実装を素通りさせます (根拠と実測値は [テスト戦略 > なぜ 201 件なのか](https://krileworks.com/ja/apex-stem/docs/test-strategy))。 **act になっているのは `o.create()` です。** ApexBlueprint は宣言を実 DML で realize するので、**データ生成そのものが発火装置を兼ねます** (update / delete のカスケードを見たいときは `create()` は Arrange なので `startTest()` の外に出します)。 ### 3 段のアサートが、それぞれ別のものを守る | # | アサート | 何を保険にしているか | これが破れるとき | |---|---|---|---| | ① | `Assert.areEqual(201, ...)` | **取りこぼしていないこと**。主眼はここ | `take(200)` / `LIMIT` / 「1 回の呼び出しで全件が来る」前提の実装が入った | | ②-1 | `assertInvocationsAtMost` | **配線**。この Usecase が何回起動してよいか | 別のトリガーからも呼ばれ始めた / 再入が増えた | | ②-2 | `assertSoqlQueriesAtMost` | **中身**。件数に比例してクエリを撃っていないこと | ループの中でクエリを撃つ実装 (N+1) が混入した | | ③ | `Assert.isTrue(soqlUsed < ...)` | **カスケード全体の余白** | 自分以外の場所も含めて、トランザクションが太った | **① を省かないでください。** `take(200)` を仕込んだ実装は**クエリ数がむしろ減る**ので、② と ③ は緑のまま通ります。ガバナだけ見ていると「取りこぼしているのに消費は少ない」を見逃します。 ## 上限の決め方 ### ちょうどの値にしない ここが実務で一番間違えやすい点です。**実測 2 本に対して `assertSoqlQueriesAtMost(2)` と書くと、それは実質「ちょうど 2 本」の主張**になり、正当なリファクタでクエリが 1 本増えただけで落ちます。**1 件ごとにクエリを撃つ実装なら件数ぶんになって確実に落ち、正当な追加 1〜2 本では落ちない**水準に置きます。トランザクション全体を `Limits.getQueries() < 上限の半分` で見る従来のガバナ IT と同じ考え方です。 **例外は「1 本も発行しないこと」自体に意味がある場合**です。「差分がなければ何もしない」ようなケースは `AtMost(0)` で縛って構いません。 実際に「案件ごとにクエリを撃つ」実装へ意図的に劣化させたところ、**プラットフォーム上限 100 本に届く前**に鳴りました。 ``` Usage assertion failed: SOQL queries is 32, exceeding the allowed maximum of 10. Actual usage: Invocations: 2, SOQL: 32 (rows: 92), DML: 1 (rows: 60), Callouts: 0 ``` このメッセージだけで、**回数は 2 なのに SOQL が 32**、つまり配線が増えたのではなく**中身のループが原因**、と切り分けられます。 ### reason には「実測値」と「増えたら何を疑うか」を書く 6 つの assert すべてに `(Integer max, String reason)` のオーバーロードがあります (v1.3.0+)。理由は失敗メッセージの**数字より前**に出ます。 ``` Usage assertion failed: DML statements is 1, exceeding the allowed maximum of 0. Reason: 案件20件でも実測1本(20行)。件数ぶん増えていたらループ内DMLが混入している Actual usage: SOQL: 0 (rows: 0), DML: 1 (rows: 20), Callouts: 0 ``` **このガードが守るのは、自分が書いていないコードの変更**です。Admin が Flow を 1 本足す、別チームがトリガーを増やす。Apex に一切触れていなくてもカスケードは変わります。問題は、**赤を見た人がそれを消しにくるまでの時間が短い**こと。数字だけでは「上限が厳しすぎる」と解釈され、閾値を上げるか行ごと消されます。ソースコメントは開かないと読めませんが、**失敗メッセージは必ず読まれます**。 書くべきものは 2 つ。**実測値** (今いくつか。上限だけでは厳しすぎる設定だと誤解される) と、**増えたら何を疑うか** (赤を見た人が次に何を見ればいいか) です。 > 💡 **副次効果として、書く時点で実測を強制します。** 上限だけなら `5` と書いて放置できますが、理由に数字を入れようとすると測らざるを得ません (実際、導入時にコメントの「実測 2 本」が古くなっていたことが発覚しました)。 ### 回数の上限は「そのテストの件数」とセット 消費本数は正当な変更で揺れますが、**回数は設計意図そのもの**なので動きません。 > ⚠️ 本ページと [テスト戦略](https://krileworks.com/ja/apex-stem/docs/test-strategy) に載せている回数の系列 (30 件 → 2 / 200 件 → 3 / 201 件 → 5) は、`discardArrange()` を**置かなかった**場合の値です (Arrange の起動を含みます)。置いた場合は Arrange 分だけ少なくなります (201 件の実測: **5 → 4**)。 ただし**回数だけは件数に依存します**。トリガーの分割で起動回数が変わるため、**30 件のテストで書いた上限のまま件数を 201 に増やすと、バグが一切無いのに落ちます** (消費量の上限は件数にほぼ一定なので、この問題は起きません)。`reason` に件数を書いておくと防げます。 ## 履歴は Arrange から溜まる — discardArrange() で区切る **Trace の履歴はテストメソッドの先頭から溜まり続けます。** Arrange でレコードを作れば、その DML でトリガー経由で走った Usecase も履歴に残り、`usageOf` の合算に混ざります。 `TraceFlow.discardArrange()` (v1.4.0+) を Arrange と Act の境界に置くと、以降のアサートは Act だけを見ます。 ```apex setupAccountWithOpportunities(); // Arrange。トリガー経由で対象 Usecase が 1 回走る TraceFlow.discardArrange(); // ← ここ Test.startTest(); // ← と、ここは同じ境界 Database.executeBatch(new ResummarizeBatch()); Test.stopTest(); TraceFlow.usageOf('取引先の商談サマリを再集計') // Act だけ。回数も正しい .assertInvocationsAtMost(1, 'バッチ 1 チャンクぶん') .assertSoqlQueriesAtMost(0, '差分が無ければクエリを撃たないこと'); ``` `Test.startTest()` を置くのと同じ判断なので、**新しく覚える概念はありません**。書く場所も隣です。 > 📌 **消費だけでなく経路アサートにも効きます。** `isLastSkip()` などは「最後に閉じたコンテキスト」を見るため、Act が何も起こさなかった場合に Arrange のコンテキストを読む余地がありました。`discardArrange()` を置くと観測窓が Act に揃います。 ⚠️ **書き忘れても、ライブラリは何も言えません** (Arrange の有無を判定できないため)。ただし **`assertInvocationsAtMost` が自然な番人になります**。Arrange が 1 回起動していれば回数が 1 つ多く出るので、回数を締めていれば書き忘れは落ちます。「まず回数から始める」の推奨が、ここでも効きます。 ### 入れ子: inclusive と exclusive (ほとんどの人は読み飛ばしてよい) 消費の取り出し方は 3 つあります。 | | 1 回ぶん | 全部の合計 | |---|---|---| | **inclusive** (子を含む) | `lastUsage()` | (存在しない) | | **exclusive** (自分だけ) | `usagesOf(name)` の要素 | `usageOf(name)` | 空欄が、**exclusive が必要な理由**です。ハンドラが 2 つの Usecase を呼ぶ場面を考えます。 ``` ハンドラ (SOQL 6) ├ UsecaseA (SOQL 2) └ UsecaseB (SOQL 4) ``` 全部を inclusive のまま足すと **6 + 2 + 4 = 12**。実際には 6 本しか撃っていないのに倍です。**合計を出す `usageOf` が exclusive でなければならない**のはこのためです (ハンドラ自身の exclusive は 0 になります)。 > 📌 **Usecase にしか `Trace` を貼っていなければ、inclusive と exclusive は同じ値です。** > 差が出るのは、ハンドラなど入れ子の外側にも `Trace` を貼ったときだけです。 ⚠️ **inclusive / exclusive は「回数」には掛かりません。** exclusive は消費を親子で分解する仕組みですが、`getInvocations()` が数えているのは**そのコンテキストが閉じた回数**です。分解の対象ではありません。 ### usagesOf はいつ使うか `discardArrange()` があれば、通常のアサートは `usageOf` で足ります。`usagesOf(name)` が要るのは、**Act の中で同じ Usecase が複数回走り、その内訳を個別に見たいとき**だけです (バッチのチャンクごとの消費を調べる、など)。 🚨 **「末尾を取れば Act の 1 回ぶんになる」という使い方はしないでください。** それが正しいのは Act がその Usecase を**ちょうど 1 回だけ**起動したときに限られ、チャンクが 2 つに分かれた瞬間に「最後のチャンクだけ」を測ることになります。**Act 全体を測りたいなら `discardArrange()` + `usageOf` です。** ## 誤用ガード 「間違った使い方が緑で通る」面が塞がれています。いずれも**テスト実行時 (Strict モード) のみ**で、本番ではスキップされます。 ### 曖昧な lastUsage() は例外 `lastUsage()` が返すのは「最後に閉じた 1 コンテキスト」だけです。ハンドラが Usecase を複数呼ぶバルク IT では、**測る対象がたまたま決まっている**状態でした。v1.3.0 から、同じ深さで 2 つ以上のコンテキストが閉じている場合は例外になります。 ``` TraceException: lastUsage() is ambiguous — 3 contexts closed at the same level: 取引先の商談サマリを再集計 / 商談の合計金額を再計算 / 取引先の商談サマリを再集計 Use TraceFlow.usageOf(contextName) to target one. ``` **候補リストがそのまま診断になります。** 同じ名前が 2 回出ているのは、Arrange の DML でもトリガー経由で同じ Usecase が走っていたためです。つまり従来は **Arrange に DML を 1 つ足すだけで測る対象が変わる**状態でした。なおネストは曖昧ではありません (LIFO なので一番外側が自然な対象)。 ### 起動 0 回への消費アサートは例外 `assertSoqlQueriesAtMost(n)` は「このコンテキストは走った。その上で n 以下」という主張です。名前を間違えたなどで `invocations = 0` のときは、その主張に根拠がないため例外になります。 ``` TraceException: Usage assertion on SOQL queries is unfounded: no context with this name closed in the transaction (invocations = 0). Check the context name for typos, the test layer, and the wiring. To assert that the context does not run, use assertInvocationsAtMost(0). ``` `usageOf` はコンテキスト名を**文字列**で渡すため、`Trace.of(...)` をリネームすると従来は黙って空振りしていました。これで赤くなります。**リネームの安全網としても効きます。** なお `assertInvocationsAtMost(0)` は「走らないこと」の正当な主張なので、意図的に対象外です。 ### discardArrange() は境界でしか呼べない Usecase のコンテキストが開いている最中に呼ぶと、その Start エントリが消えて `usagesOf` から静かに 1 件落ちます。境界で呼んでいる限り、開いているのは**テスト自身の `Trace` が最大 1 つ**なので、それ以上開いていれば例外になります (v1.4.0+)。 ``` TraceException: discardArrange() must be called at the Arrange / Act boundary, while no usecase context is open. Currently open: 2 contexts. ``` ## リファレンス ### 記録するのは決定的な 5 指標だけ | 指標 | getter | |---|---| | SOQL 数 | `getSoqlQueries()` | | SOQL 行数 | `getSoqlRows()` | | DML 文数 | `getDmlStatements()` | | DML 行数 | `getDmlRows()` | | コールアウト数 | `getCallouts()` | CPU 時間とヒープは**意図的に対象外**です。実行ごとにぶれるため、閾値でアサートするとテストが不安定になります。生の値が欲しいときは getter を使います (回数は `getInvocations()`)。 ```apex TraceUsage usage = TraceFlow.usageOf('商談に親取引先の業種をコピー'); Integer soql = usage.getSoqlQueries(); ``` ### 知っておくべき挙動 - **inclusive と exclusive がある**。履歴に記録される生の値は **inclusive** (`start` からクローズまでの総量なので、ネストした子の消費も含む) ですが、**`usageOf` / `usagesOf` が返すのは exclusive** (そこから直下の子のぶんを引いた、自分だけの消費) です。`lastUsage()` と本番デバッグログの `Usage:` 行は inclusive のままです - **アサート失敗は catch 可能な `TraceException`** (`Assert.fail` の `AssertException` は catch 不能でヘルパー自体をテストできないため)。メッセージには消費の内訳が全部載ります - **本番のデバッグログにも出ます**。`FINISH: ...` の直後に `Usage: SOQL: 3 (rows: 120), ...` が付くので、テスト外でも Usecase 単位のコストが常時見えます > ⚠️ **単体テスト (`MockEloquent`) では usage はすべてゼロになります。** 実 SOQL を発行しないので当然です。**ガバナのアサートは実 DML を流す結合テスト側に書いてください**。単体側に書いても、何も検証していないテストになります。詳しくは [テスト戦略](https://krileworks.com/ja/apex-stem/docs/test-strategy) の「その代表 1 本は、ガバナ IT にする」を参照してください。 ## 関連ドキュメント - [親子・量産・参照のパターン](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-relations-and-bulk): `times()` で本番サイズを量産する - [テスト戦略](https://krileworks.com/ja/apex-stem/docs/test-strategy): ガバナ IT を代表 1 本に据える理由 - [ApexTrace ガイド](https://krileworks.com/ja/apex-stem/docs/apex-trace-guide): ガイド目次に戻る ============================================================================== Source: https://krileworks.com/document/ja/apex-trace-nesting-and-modes.md Page: https://krileworks.com/ja/apex-stem/docs/apex-trace-nesting-and-modes ============================================================================== # ネスト Trace と 2 つのモード Usecase の中から別の Usecase を呼ぶような場面では、`Trace` が **ネスト** します。ApexTrace はネスト構造を LIFO スタックで厳密に管理し、不整合があれば例外を投げます。 ## Outer / Inner の順序ルール ネスト Trace は「Inner を先に閉じてから Outer を閉じる」が原則です。 ```apex Trace outer = Trace.of('Outer'); outer.start(); Trace inner = Trace.of('Inner'); inner.start(); inner.log('inside inner'); // ✅ inner.finish(); // ✅ Inner を先に閉じる outer.finish(); // ✅ ``` Inner が `start` した後、`finish` / `skip` / `abort` のいずれかで閉じる前に Outer の `log` / `finish` を呼ぶと、Strict モードでは即例外が投げられます。 ## Strict モードと Relaxed モード `TraceFlow` には 2 つのモードがあり、実行コンテキストで自動切替されます。 | モード | デフォルトの切替 | 挙動 | |---|---|---| | **Strict** | テスト実行時 (`Test.isRunningTest() == true`) | ネスト不整合・順序違反で即例外。バグを早期検出する | | **Relaxed** | 本番実行時 | 多少の不整合は **自動で abort** して吸収。本番が落ちにくくなる | 「テストで通った = 本番でも通る」と思いがちですが、モード差で挙動が変わることを意識しておきます。 > ⚠️ **切替は `Test.isRunningTest()` による自動判定のみです。** 利用者から明示的に切り替える API はありません (`changeModeTo` と `TraceMode` はどちらも `private`)。 ## Trace.of() は instance field の初期化で 1 回だけ `Trace.of(...)` は Usecase クラスの instance field の **初期化時に一度だけ** 呼びます。`invoke()` 内で呼び直すと、毎回新しい `Trace` インスタンスができ、ネスト・集約のロジックが破綻します。 ```apex public with sharing class YourUsecase { private Trace t = Trace.of('処理名'); // ✅ instance field で 1 回だけ public void invoke() { // private Trace t = Trace.of(...); // ❌ invoke 内で呼び直さない this.t.start(); // ... } } ``` ## テストの冒頭で Trace.of('...').start() を書く理由 これまでに見たテスト例では、メソッドの冒頭に必ず次の 2 行が並びます。 ```apex Trace t = Trace.of('正常系: ...'); t.start(); ``` そして末尾で `t.finish();` を呼びます。これには **第一の理由** と **副次効果** があります。 **第一の理由: Usecase 側の Trace ライフサイクル不整合を事前に防ぐため**。Usecase の内側で `finish` / `skip` / `abort` を呼ばずに `return` してしまうと、開始と終了がミスマッチした「開きっぱなしの Trace」がコンテキストに残ります。テスト側でアウター Trace に包んでおくと、Strict モード (テスト実行時の default) がそのミスマッチを **テストの段階で検出** してくれます。本番に出る前に、Usecase 側のライフサイクル抜けに気づける仕組みです。 **副次効果: 直前の Trace 状態をリセットする**。前のテストが残した Trace コンテキストを、新しいテストの冒頭で明示的に巻き直せます。 ### テストクラス全体の形 **すべてのテストメソッドで、最初と最後に置きます。** クラス全体では次の形になります。 ```apex @isTest(seeAllData=false) private class ResummarizeUsecase_T { @isTest static void testInvoke_WhenOpportunitiesExist_ThenSummaryUpdated() { Trace t = Trace.of('正常系: 商談があるとき取引先のサマリが更新されること'); t.start(); // Arrange ... // Act ... // Assert ... Assert.isTrue(TraceFlow.isLastFinish()); t.finish(); } @isTest static void testInvoke_WhenNoOpportunities_ThenSkipped() { Trace t = Trace.of('正常系: 商談が 1 件も無いときスキップされること'); t.start(); // Arrange ... // Act ... // Assert Assert.isTrue(TraceFlow.isLastSkip()); t.finish(); } } ``` `t.start()` と `t.finish()` が**メソッドの最初と最後を挟む**のがポイントです。この形になっていれば、内側の Usecase が終了を呼び忘れた瞬間に Strict モードが検出します。 ### もう 1 つの効能: 検証内容を命名規則の外に書ける テストメソッド名は `test{メソッド}_When{条件}_Then{結果}` のような形式に縛られます。grep できる識別子である以上これは正しい制約ですが、**識別子の制約の中では、書ける説明の量も語彙も限られます**。 `Trace.of(...)` の引数は**ただの文字列**なので、その制約の外にあります。 | | 役割 | 制約 | |---|---|---| | メソッド名 | 機械可読。grep と実行結果の識別子 | 命名規則・識別子として妥当な文字 | | `Trace.of` の引数 | 人間可読。何を検証しているかの説明 | **無し** | **英語圏以外のチームでは、これが可読性に直結します。** メソッド名は規約どおり英語のまま置き、**説明は母語で書く**という分担ができるからです。「何を検証しているのか」を母語で読めるほうが、レビューでも障害調査でも確実に速く、しかもメソッド名の規約を崩さずに済みます。 この文字列は**デバッグログにも出力される**ので、テストが落ちたときのログにも説明がそのまま載ります。 > 📌 Apex Stem の規約では、この文字列を **`正常系:` / `異常系:` / `エッジケース:`** のいずれかで始めます。区分ラベルの後ろに、人間可読の説明文を続けてください。 ## 関連ドキュメント - [Trace のライフサイクル](https://krileworks.com/ja/apex-stem/docs/apex-trace-lifecycle): 4 メソッドと 3 つの終了パス - [TraceFlow で経路を検証する](https://krileworks.com/ja/apex-stem/docs/apex-trace-flow-guide): Strict モードが叩き出す不整合 - [ApexTrace ガイド](https://krileworks.com/ja/apex-stem/docs/apex-trace-guide): ガイド目次に戻る ============================================================================== Source: https://krileworks.com/document/ja/apex-tools-trigger-handler.md Page: https://krileworks.com/ja/apex-stem/docs/apex-tools-trigger-handler ============================================================================== # TriggerHandler 基底クラスとフィールド変更検知 このドキュメントは、ApexTools が提供する `TriggerHandler` 基底クラスの解説です。他のツールも含めた一覧は [ApexTools ガイド](https://krileworks.com/ja/apex-stem/docs/apex-tools-guide) から確認できます。 ## 何ができるか `TriggerHandler` 基底クラスを継承するだけで、Trigger からのエントリーポイント処理が「7 イベントを宣言するだけのトリガーファイル」と「override したフックだけを書くハンドラクラス」というシンプルな形にまとまります。「特定フィールドが変更されたレコードだけを絞り込む」ヘルパーも組み込まれていて、`afterUpdate` の絞り込みがコード 1 行で書けます。 ## 継承と 7 つのフック + andFinally 各 Handler は `TriggerHandler` 基底クラスを継承し、必要なフックだけを override します。すべて `protected virtual` なので、override しないフックは何もしません。トリガーの 7 イベントに対応する 7 つと、コンテキストによらず最後に走る `andFinally` の計 8 つです。 | フック | シグネチャ | |---|---| | `beforeInsert` | `(List newRecords)` | | `beforeUpdate` | `(Map newMap, Map oldMap)` | | `beforeDelete` | `(Map deletedMap)` | | `afterInsert` | `(Map newMap)` | | `afterUpdate` | `(Map newMap, Map oldMap)` | | `afterDelete` | `(Map deletedMap)` | | `afterUndelete` | `(Map undeletedMap)` | | `andFinally` | `()` 常に最後に呼ばれる (どのコンテキストでも) | ## Trigger.cls の固定パターンと連動 トリガーファイルは Salesforce の慣例に従い、書き方を固定します。7 つのイベントをすべて宣言し、Handler を 1 行で呼ぶだけです。 ```apex trigger Opportunity on Opportunity( before insert, before update, before delete, after insert, after update, after delete, after undelete ) { (new TriggerOppHandler()).execute(); } ``` > 🚨 **カスタムオブジェクトでは、トリガー名に `__c` をそのまま付けられません。** Apex の識別子は `__` の連続を含められない (Salesforce の予約) ため、`trigger SalesActivity__c on SalesActivity__c(...)` は `Invalid character in identifier` でデプロイに失敗します。**トリガー名だけ別名にしてください** (`SalesActivityTrigger` など。ファイル名も合わせます)。標準オブジェクトは `trigger Opportunity on Opportunity(...)` のままで問題ありません。 Handler 側は必要なフックだけ override します。 ```apex public with sharing class TriggerOppHandler extends TriggerHandler { protected override void afterInsert(Map newRecordsMap) { Set opportunityIds = newRecordsMap.keySet(); (new CopyAccountIndustryToOpportunityUsecase(opportunityIds)).invoke(); } } ``` ## フィールド変更検知ヘルパー `afterUpdate` で「特定フィールドが変更されたレコードだけ」に絞りたいケースは頻出です。`TriggerHandler` 基底クラスがそのためのヘルパーを提供します。 | メソッド | 戻り値 | 用途 | |---|---|---| | `getUpdatedRecordsWithChangedField(SObjectField field)` | `List` | 単一フィールド変更レコード | | `getUpdatedRecordsWithChangedFields(List fields)` | `List` | 複数フィールドのいずれかが変更されたレコード | | `getUpdateRecordIdsWithChangedField(SObjectField field)` | `Set` | 上記の Id 版 | | `getUpdateRecordIdsWithChangedFields(List fields)` | `Set` | 上記の Id 版 | 使い方の例: ```apex public with sharing class TriggerOppHandler extends TriggerHandler { protected override void afterUpdate(Map newMap, Map oldMap) { Set needIds = this.getUpdateRecordIdsWithChangedFields(new List{ Opportunity.AccountId, Opportunity.StageName }); (new RegenerateCollectionUsecase(needIds)).invoke(); } } ``` 「特定のフィールドが変わった時だけ何かする」というロジックがコード 1 行に集約され、Handler は依然として「条件判定と Usecase 呼び出しだけ」の薄さを保てます。 ## その他の注意点 ### override しないフックはそのまま無処理 `TriggerHandler` 基底クラスのフックはすべて `protected virtual` で、デフォルトでは何もしません。`afterInsert` だけ処理したい Handler は `afterInsert` だけ override すれば OK で、他のフックを空メソッドで埋める必要はありません。 ### andFinally の使いどころ `andFinally()` は **どのコンテキストでも最後に呼ばれる** フックです。「before / after の種類によらず、最後に必ず実行したい処理」(監査ログの確定、Trace のラッピング処理など) を置く先に使います。多くの Handler では不要です。 ## 次に読む - [ApexTools ガイド](https://krileworks.com/ja/apex-stem/docs/apex-tools-guide): ApexTools の他のツールを含む入り口 - [Handler-Usecase Architecture](https://krileworks.com/ja/apex-stem/docs/handler-usecase-architecture): `TriggerHandler` が活きる Apex Stem の中核アーキテクチャ - [Apex Stem 導入ガイド](https://krileworks.com/ja/apex-stem/docs/apex-stem-full-guide): 動くコード付きの 4 ステップ ============================================================================== Source: https://krileworks.com/document/ja/apex-tools-http-request-handler.md Page: https://krileworks.com/ja/apex-stem/docs/apex-tools-http-request-handler ============================================================================== # IHttpRequestHandler: 公式 HttpCalloutMock のやりづらさと ApexTools の答え Salesforce の外部連携をテストするとき、応答の差し替えには標準の `HttpCalloutMock` を使います。ApexTools の `IHttpRequestHandler` は、**コールアウトを DI で差し替え可能にし、応答を宣言だけで組み立てられる**ようにします。 > 📌 **標準にも組み込みのモックはあります。** `StaticResourceCalloutMock` / `MultiStaticResourceCalloutMock` を使えば、実装クラスを書かずに応答を返せます。ただし**応答ボディを静的リソース (メタデータ) として用意する必要があり**、しかも 1 エンドポイントにつき 1 応答なので、**順序を持つ応答 (リトライ・ページネーション) は表現できません**。そこに踏み込んだ時点で `HttpCalloutMock` の実装クラスが必要になります。 ## 何ができるか - 本番は `HttpRequestHandler` (標準 `Http` の薄いラッパー)、テストは `MockHttpRequestHandler` を注入する - 応答は `MockResponse.of('GET').respond(body, 200)` の 1 行で宣言する - **順序つきの応答でも、`HttpCalloutMock` の実装クラスを書かなくてよい** - 送ったリクエストを後から検証できる (Spy) ## 公式 HttpCalloutMock の 3 つのやりづらさ 以下は、`HttpCalloutMock` を自分で実装することになったときに踏む 3 つです。 ### やりづらさ 1: JSON 文字列の手動作成 応答ボディを文字列リテラルで書くことになり、エスケープと可読性の両方が犠牲になります (静的リソースに逃がす手もありますが、今度はテストを読むのにファイルを開く必要が出ます)。 ```apex // ❌ 階層が深いほど破綻する String body = '{"records":[{"Id":"001xx","Name":"Acme","Contacts":{"totalSize":2}}]}'; ``` ### やりづらさ 2: URL 文字列に依存した if-else 分岐 1 つのモッククラスが全エンドポイントを引き受けるため、`respond()` の中が URL 判定の分岐で膨らみます。 ```apex // ❌ エンドポイントが増えるたびに分岐が伸びる public HttpResponse respond(HttpRequest req) { if (req.getEndpoint().contains('/accounts')) { ... } else if (req.getEndpoint().contains('/contacts')) { ... } ... } ``` ### やりづらさ 3: リトライ・ページネーションの再現が困難 「1 回目は 500、2 回目も 500、3 回目に 200」のような**順序を持つ応答**を表現するには、モック側にカウンタを持たせることになります。 ## ApexTools の答え ### 応答は MockResponse で宣言する ```apex MockResponse.of('GET').respond('{"message":"not found"}', 404) // String MockResponse.of('post').respond(new Map{ ... }, 200) // Map / List は JSON 化。メソッド名は大小どちらでも MockResponse.of('GET').respond(imageBytes, 200).header('Content-Type', 'image/jpeg') // Blob + ヘッダ MockResponse.of('GET').respond(ok, 200).repeat() // キュー末尾に置くと以降ずっとこれ ``` `respond` は `String` / `Map` / `List` / `Blob` を受けます。Map と List は自動で JSON 化されるので、**やりづらさ 1 は「Apex のコレクションで書く」だけで解消**します。 > ⚠️ **メソッド名はルーティングキーではなく「配信時の契約」です。** キューの次の応答が宣言したメソッドと実リクエストで食い違うと、期待 / 実際 / キュー状態を含むエラーで即座に落ちます。黙って違う応答が配られることはありません。 ## 2 つのモード **🎯 親指ルール: テストの主張に順序が含まれるなら台本モード、含まれないなら label モード。迷ったら label。** ### 台本モード (label なし): 順序が仕様であるとき コンストラクタに渡したリストが、そのままフローの台本になります。上から読めば期待するコールアウト列そのものです。 ```apex MockHttpRequestHandler mock = new MockHttpRequestHandler(new List{ MockResponse.of('GET').respond(notFound, 404), // 1 手目: 存在確認 MockResponse.of('POST').respond(created, 201), // 2 手目: 作成 MockResponse.of('GET').respond(found, 200) // 3 手目: 再取得 }); ``` 順序やメソッドから逸脱すると詳細なエラーになります。**やりづらさ 3 は、リトライを「同じメソッドを並べるだけ」で表現できる**ようになります。 ```apex // 1 回目 500、2 回目 500、3 回目に成功。モック側にカウンタは不要 new List{ MockResponse.of('POST').respond(err, 500), MockResponse.of('POST').respond(err, 500), MockResponse.of('POST').respond(ok, 200) } ``` ### label モード: サイト間の順序に縛られたくないとき 呼び出しサイトごとに名前付きキューを持たせます (`MockEloquent` の `attach` / `label` と同じ操作感)。**やりづらさ 2 は、URL 判定ではなく呼び出しサイトの名前で仕分ける**ことで解消します。 ```apex // Usecase 側: this.http.label(LBL_EXISTS).send(req); MockHttpRequestHandler mock = new MockHttpRequestHandler() .attach(LBL_EXISTS, MockResponse.of('GET').respond(notFound, 404)) .attach(LBL_CREATE, MockResponse.of('POST').respond(created, 201)) .attach(LBL_UPDATE, MockResponse.of('PUT').respond(updated, 200)); // 通らない分岐も宣言してよい new KintoneUpsertUsecase(input, mock).invoke(); Assert.areEqual(1, mock.sentRequestsAt(LBL_CREATE).size()); // create 分岐を通った Assert.areEqual(0, mock.sentRequestsAt(LBL_UPDATE).size()); // update は未消費 ``` 分岐フローでは、**両方の分岐を attach しておき、どちらが消費されたかで通った経路をアサートする**のが定石です。 - `attach` を使ったら、`send` ごとに `label()` が必須です (1 回で消費) - ラベルの typo は、登録済みラベルの一覧つきでエラーになります - 同一 label への `attach` はキューに追記されます (= そのサイトのリトライ系列) - **`attach` を使わなければ `label()` は無視されます**。label 付きの本番コードを、素の台本モックでもテストできます ## 検証ヘルパー (Spy) | メソッド | 用途 | |---|---| | `sentRequestsAt(label)` | そのラベルで送られたリクエスト | | `countByMethod('POST')` | メソッド別の送信回数 | | `requestsTo(endpointPart)` | エンドポイントの部分一致で絞る | | `lastRequest()` | 最後に送ったリクエスト | | `describe()` | キューの現在状態 (デバッグ用) | ## 🛡 Content-Type ガード (実事故由来の定石) HTTP 200 でも Content-Type が想定外なら、たいていは**エンドポイントの間違い**です。 > 実例: `/bizCards/{id}/image` を叩いたつもりが `/bizCards/{id}` を叩いており、返ってきた JSON を base64 して壊れた画像を画面に流していた。 バイナリを取得するときは必ずガードを入れてください。 ```apex this.http.label(LBL_CARD_IMAGE).send(req); String contentType = this.http.getHeader('Content-Type'); if (this.http.getStatusCode() == 200 && (contentType == null || !contentType.startsWith('image/'))) { throw new CalloutException('Expected an image response but got Content-Type=' + contentType); } Blob image = this.http.getBodyAsBlob(); ``` ## Apex Stem との統合: Usecase で DI する `IHttpRequestHandler` は、Apex Stem の Usecase 層と [Layered Constructor Pattern](https://krileworks.com/ja/apex-stem/docs/layered-constructor-pattern) にそのままはまります。**v1.0.0 以降は 1 本の handler を label で多重化する**のが推奨です (`IEloquent` の `label` と同じ考え方)。 ```apex public with sharing class KintoneUpsertUsecase { @TestVisible static final String LBL_EXISTS = 'kintoneExists'; @TestVisible static final String LBL_CREATE = 'kintoneCreate'; private final Input input; private final IHttpRequestHandler http; private Trace t = Trace.of('kintone へレコードを upsert'); // 🚪 本番用 public KintoneUpsertUsecase(Input input) { this(input, null); } // 🧪 テスト用 (DI 対応) @TestVisible private KintoneUpsertUsecase(Input input, IHttpRequestHandler http) { this.input = input; this.http = http ?? new HttpRequestHandler(); } public void invoke() { this.t.start(); this.http.label(LBL_EXISTS).send(existsReq); // ... } } ``` 役割ごとに複数の handler を DI する旧スタイルも引き続き使えますが、**ラベル多重化のほうがコンストラクタが太りません**。 `MockEloquent` (ApexEloquent) と `MockHttpRequestHandler` (ApexTools) を独立して DI すれば、**副作用 (DML) と外部呼び出し (HTTP) を別々の軸で検証**できます。 ## ⚠️ v1.0.0 の破壊的変更 タグ以前の `main` から上げる場合は、次の 3 点の対応が必要です。 | 変更 | 対応 | |---|---| | 旧コンストラクタ (`Map` / `List` / `String` + `Integer`) を削除 | `MockResponse.of(method).respond(body, statusCode)` に書き換える | | `IHttpRequestHandler` に `label` / `getBodyAsBlob` / `getHeader` を追加 | 独自実装クラスがあればメソッドを追加する | | 枯渇エラーのメッセージが複数行の診断形式に変更 | 完全一致の assert は `contains` に緩める | ## 次に読む - [Layered Constructor Pattern](https://krileworks.com/ja/apex-stem/docs/layered-constructor-pattern): `IHttpRequestHandler` の DI 設計を支える基本パターン - [TriggerHandler](https://krileworks.com/ja/apex-stem/docs/apex-tools-trigger-handler): ApexTools のもう 1 つの柱 - [ApexTools ガイド](https://krileworks.com/ja/apex-stem/docs/apex-tools-guide): ガイド目次に戻る ============================================================================== Source: https://krileworks.com/document/ja/apex-stem-get-started.md Page: https://krileworks.com/ja/apex-stem/docs/apex-stem-get-started ============================================================================== # Apex Stem をはじめる Apex Stem は、Handler-Usecase アーキテクチャと、それを支える OSS 群でできています。**何かを書き直す必要はありません**。今日のコードを少しでも良くする最小の一歩から始めて、そこから育てていけます。 このページは短い導入です。本格的な移行手順が必要になったら、ページ下部のリンクから 4 ステップの完全ガイドに進めます。 > コード例は ApexEloquent **v2 以降**で動きます。v3 では SOQL/DML の既定がユーザーモード (FLS を尊重) になっているため、実行ユーザーの項目権限にご注意ください。 ## 得られること - **テストが本番より甘くならない**。実 SOQL で取ったレコードは、SELECT していない項目に触ると例外になります。ところが、テスト用に自分で組み立てた SObject にはその検査がありません。テストのときだけ緩くなり、本番で初めて落ちます。ApexEloquent のモックは本番のクエリが何を SELECT しているかを知っているので、テストの段階で落ちます。 - **単体テストが数 ms で回る**。DB に触らないので、org に Flow やトリガーが増えても速度が変わりません。速いから気軽に実行でき、フィードバックのループが回り続けます。 - **構造がそのまま見える**。親子関係はインデントで書けます。クエリの組み立ても、テストデータの生成も、コードの形がデータの形になります。 - **AI に渡す規約が軽い**。アーキテクチャの宣言は 30 行ほどで書けます ([実物は Handler-Usecase Architecture に掲載](https://krileworks.com/ja/apex-stem/docs/handler-usecase-architecture))。`CLAUDE.md` に常駐させても、他に読ませたいコンテキストを圧迫しません。 - **大規模な書き直しは不要**。Usecase 1 つ、モック可能なクエリ 1 本ずつ進められます。 ## 最初の一歩 今日ひとつだけやるなら、これです。いまインラインで書いている SOQL を 1 本、ApexEloquent の型付きビルダー `Scribe` 経由に通します。 ### Before ```apex List accounts = [ SELECT Id, Name FROM Account WHERE Industry = :industry ]; ``` ### After ```apex Scribe accountScribe = Scribe.of(Account.class) .field('Id') .field('Name') .whereEqual('Industry', industry); List accountEntries = new Eloquent().get(accountScribe); ``` 挙動は変わりません。変わるのは、テストから見たときのこの 1 本です。 - **差し替えられるようになる**。テストでは `Eloquent` の代わりに `MockEloquent` を注入すれば、DB に触らずにこのクエリの結果を決められます。 - **SELECT 漏れが、テストで落ちるようになる**。上の例は `Id` と `Name` しか SELECT していません。もし後続のコードが `Industry` を読んだら、その場で例外になります。素の SObject なら `null` が返って静かに通り、本番で初めて気づくところでした。 これだけです。1 本ずつ進めれば十分。Usecase の切り出しやテスト層の追加は、準備ができてからで構いません。 > 補足: フィールドは `SObjectField` ではなく文字列 (`'Id'`、`'Industry'`) で渡します。これにより、ビルダーは動的に組み立てられます。 ## さらに先へ進む準備ができたら フルガイドでは、実際に動くコードとともに 4 つの段階的なステップを解説します。 1. **Scribe で SOQL を置き換える** (上記をさらに展開) 2. **結果を IEntry のまま扱う**。早すぎる SObject 変換が罠になる理由 3. **Usecase を切り出す**。Layered Constructor Pattern 4. **適切な層でテストする**。Usecase は MockEloquent、Handler は ApexBlueprint → **[フルガイドを読む](https://krileworks.com/ja/apex-stem/docs/apex-stem-full-guide)** ============================================================================== Source: https://krileworks.com/document/ja/apex-stem-full-guide.md Page: https://krileworks.com/ja/apex-stem/docs/apex-stem-full-guide ============================================================================== # Apex Stem 導入ガイド このガイドでは、既存の Salesforce コードベースに **Apex Stem** を段階的に取り入れる方法を、一歩ずつ解説します。すべてを書き直す必要はありません。今日の状況を少しでも良くする最小の部分から始めて、そこから育てていけます。 以下の 4 ステップは、トップページのカードと対応していますが、こちらはコピーして使える実際のコード付きです。 > コード例は ApexEloquent **v2.1 以降**で動きます (`label()` / `attach()` を使っているため)。v3 では SOQL/DML の既定がユーザーモード (FLS を尊重) になっているので、実行ユーザーの項目権限にご注意ください。集計や焼き付けのように「誰が起こしても完遂すべき処理」は `.systemMode()` で明示的にオプトアウトします。 ## ステップ 1: SOQL を 1 本 Scribe に置き換える いまインラインで書いているクエリを、ApexEloquent の型付きクエリビルダー `Scribe` 経由に通します。挙動は変わりませんが、次の 2 つが手に入ります。 - **テストでの SELECT 漏れ検知**。SELECT し忘れたフィールドにコードがアクセスすると、テストが失敗します。 - **モック可能性**。クエリは `IEloquent` を通るため、単体テストで `MockEloquent` に差し替えられます。 ### Before ```apex List accounts = [ SELECT Id, Name, Industry FROM Account WHERE Industry = :industry ]; ``` ### After ```apex Scribe accountScribe = Scribe.of(Account.class) .field('Id') .field('Name') .field('Industry') .whereEqual('Industry', industry); List accountEntries = new Eloquent().get(accountScribe); ``` フィールドは `SObjectField` ではなく **文字列** (`'Id'`、`'Industry'`) で渡します。これにより、ビルダーは型システムの制約に縛られず、実行時に動的に組み立てられます。 > 生の SOQL (`[SELECT ...]`) は、テストのアサーション検証などサッとした用途には問題ありません。プロダクションコードでは `Scribe` を優先し、モック可能性と安全網を保ちます。 ## ステップ 2: 結果を IEntry のまま扱う `Eloquent` の返り値は `SObject` ではなく `IEntry` です。`IEntry` は `SObject` の代わりとして振る舞えるラッパーで、`SObject` のまま扱うのに比べて ApexEloquent が提供する仕組み (SELECT 漏れ検知、数式項目やロールアップのモックなど) の恩恵を受けられます。Apex Stem では、基本的に `IEntry` のままビジネスロジックを記述することを推奨しています。 ### フィールドの読み取り ```apex for (IEntry accountEntry : accountEntries) { Id id = accountEntry.getId(); // 専用 getter String name = accountEntry.getName(); // 専用 getter String industry = (String) accountEntry.get('Industry'); // キャスト必須 } ``` ### なぜ `SObject` に落とさないのか `getAsSObject()` を使えば `SObject` として受け取れますが、**落とした瞬間、その先で SELECT 漏れ検知が効かなくなります**。 ```apex // ❌ この items を受け取ったメソッドが未 SELECT の項目を触っても、null が返るだけで静かに通る List items = (List) eloquent.getAsSObject(scribe); // ✅ IEntry のまま渡せば、未 SELECT のアクセスはその場で例外になる List items = eloquent.get(scribe); ``` 型付きの `SObject` (`EstimateItems__c`) でも同じ穴が開きます。**他のメソッドやクラスの引数型を `SObject` にしない**、というところまでが対になっています。 `getAsSObject` を使ってよいのは、**標準 API が `SObject` を要求するとき**だけです (`Database.SaveResult` 系、`Approval.process`、`Messaging` など)。「キャストが面倒」「いまは `SObject` の方が速く書ける」は理由になりません。ここを一度崩すと、以降の歯止めが効かなくなります。 > 💡 迷ったら「**このレコードはクエリ由来か**」で判断します。Yes なら `IEntry`、その場で `new` した新規レコードなら `SObject` です。`doInsert` に `IEntry` 版のオーバーロードが無いのは、そのためです (新規レコードに SELECT の概念が無く、包んでも検知しようがない)。 `IEntry` が具体的にどんな恩恵をもたらすか、いつ `SObject` に変換してよいかは、[ApexEloquent のドキュメント](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-guide) で詳しく解説します。 ## ステップ 3: Usecase を切り出す SOQL とロジックの組み合わせが数行を超えてきたら、名前を付けます。Apex Stem の **Usecase** は、public な表面が `invoke()` だけのオブジェクトです。 ここでは例として、**商談 (Opportunity) に、親である取引先 (Account) の業種をコピーする** Usecase を見てみます。 ```apex public with sharing class CopyAccountIndustryToOpportunityUsecase { @TestVisible static final String LBL_FETCH = 'oppFetch'; @TestVisible static final String LBL_UPDATE = 'oppUpdate'; private final Set opportunityIds; private final IEloquent eloquent; private Trace t = Trace.of('商談に親取引先の業種をコピー'); // public コンストラクタ: 本番用、業務入力だけを受け取る public CopyAccountIndustryToOpportunityUsecase(Set opportunityIds) { this(opportunityIds, null); } // private (@TestVisible) コンストラクタ: テストで IEloquent を注入 @TestVisible private CopyAccountIndustryToOpportunityUsecase( Set opportunityIds, IEloquent eloquent ) { this.opportunityIds = opportunityIds; this.eloquent = eloquent ?? new Eloquent(); } public void invoke() { this.t.start(); if (this.opportunityIds == null || this.opportunityIds.isEmpty()) { this.t.skip('対象の商談がないため終了。'); return; } // 商談と、親取引先の業種を一緒に取得 Scribe oppScribe = Scribe.of(Opportunity.class) .field('Id') .parentField(Scribe.asParent('AccountId').field('Industry')) .whereIn('Id', this.opportunityIds); List oppEntries = this.eloquent.label(LBL_FETCH).get(oppScribe); // 各商談に、親取引先の業種をコピー for (IEntry oppEntry : oppEntries) { IEntry accountEntry = oppEntry.getParent('AccountId'); oppEntry.put('Industry__c', accountEntry.get('Industry')); } this.eloquent.label(LBL_UPDATE).doUpdate(oppEntries); this.t.finish(oppEntries.size() + ' 件の商談に業種をコピー。'); } } ``` これが **Layered Constructor Pattern** です。 - **シンプルな本番 API**: `new CopyAccountIndustryToOpportunityUsecase(opportunityIds).invoke()` - **柔軟なテスト API**: `new CopyAccountIndustryToOpportunityUsecase(opportunityIds, mock).invoke()` - **生焼けオブジェクトを作らない**: すべての依存はコンストラクタの時点で揃う > この Usecase は、`IEloquent` を 1 本だけ DI しつつ、取得用 (`LBL_FETCH`) と更新用 (`LBL_UPDATE`) でラベル多重化しています。役割ごとにラベルを付けておくと、テストで「取得結果はこう返す」「更新はこう検証する」を独立して書けます。 > また、取得した `IEntry` を `getParent` でたどり、`put` で書き換え、`doUpdate` にそのまま渡しています。ステップ 2 で触れた「`IEntry` のまま完結させる」流れの実例です。 ## ステップ 4: 適切な層でテストする アーキテクチャは、2 つのテスト戦略と 1 対 1 で対応します。 - **Usecase 層 → `MockEloquent` による単体テスト**。DB なし、高速、ロジックを網羅できます。 - **Handler 層 → `SBlueprint` による結合テスト**。実 DML を流し、配線を検証します。 ### Usecase の単体テスト ```apex @isTest static void testInvoke_WhenOpportunityHasAccount_ThenIndustryCopied() { Trace t = Trace.of('正常系: 商談に親取引先の業種がコピーされること'); t.start(); // Arrange: 親取引先 (業種 = Technology) を持つ商談を 1 件モック MockEntry oppEntry = MockEntry.of(Opportunity.class) .alias('opp') .autoId(1) .setParent('AccountId', MockEntry.of(Account.class).set('Industry', 'Technology')); Id oppId = oppEntry.getAliasId('opp'); Set oppIds = new Set{ oppId }; MockEloquent mock = (new MockEloquent()) .attach(CopyAccountIndustryToOpportunityUsecase.LBL_FETCH, new List{ oppEntry }); // Act (new CopyAccountIndustryToOpportunityUsecase(oppIds, mock)).invoke(); // Assert: 商談に業種がコピーされていること List updated = mock.upsertedRecordsAt(CopyAccountIndustryToOpportunityUsecase.LBL_UPDATE); Assert.areEqual(1, updated.size()); Assert.areEqual('Technology', ((Opportunity) updated[0]).Industry__c); Assert.isTrue(TraceFlow.isLastFinish()); t.finish(); } @isTest static void testInvoke_WhenNoOpportunityIds_ThenSkipped() { Trace t = Trace.of('正常系: 対象の商談がないときスキップされること'); t.start(); // Arrange Set oppIds = new Set(); MockEloquent mock = new MockEloquent(); // Act (new CopyAccountIndustryToOpportunityUsecase(oppIds, mock)).invoke(); // Assert Assert.isTrue(TraceFlow.isLastSkip()); t.finish(); } ``` `TraceFlow` のアサーションは、戻り値だけでなく **どのコードパスを通ったか** を確認します。「対象がなくてスキップした」と「処理が正常に完了した」を区別できます。 ### テスト対象の Handler 結合テストの前に、Usecase を呼び出す Handler 側を用意します。Trigger ファイルは **7 イベントすべてを宣言し、Handler を 1 行呼ぶだけ**にします。 ```apex trigger Opportunity on Opportunity( before insert, before update, before delete, after insert, after update, after delete, after undelete ) { (new TriggerOppHandler()).execute(); } ``` Handler は `TriggerHandler` (ApexTools) を継承し、**必要なフックだけ** override します。やることは「条件判定」と「Usecase 呼び出し」だけで、ビジネスロジックは書きません。 ```apex public with sharing class TriggerOppHandler extends TriggerHandler { protected override void afterInsert(Map newRecordsMap) { (new CopyAccountIndustryToOpportunityUsecase(newRecordsMap.keySet())).invoke(); } } ``` override していないフック (`beforeUpdate` など) は何もしません。空メソッドで埋める必要はありません。 > 「特定の項目が変わったときだけ動かしたい」場合は、基底クラスの `getUpdateRecordIdsWithChangedFields(...)` を使います。`Trigger.isAfter` や new/old の比較を手書きしていたら、それは基底クラスが未導入のサインです。 ### ApexBlueprint を使った Handler の結合テスト ```apex @isTest static void testAfterInsert_WhenOpportunityInserted_ThenIndustryCopied() { Trace t = Trace.of('正常系: 商談を insert すると親取引先の業種がコピーされること'); t.start(); // Arrange: ApexBlueprint で業種を持つ取引先と、その子商談を階層構造で組み立てる SOrchestrator orchestrator = SOrchestrator.start() .add(SBlueprint.of(Account.class) .alias('acc') .template(Blueprints.accBasic()) .set('Industry', 'Technology') .withChildren( SBlueprint.of(Opportunity.class) .alias('opp') .template(Blueprints.oppBasic()) )); // Act: create() で取引先 → 商談の順に insert され、商談 insert 時に Trigger が発火する Test.startTest(); orchestrator.create(); Test.stopTest(); // Assert: 商談に親取引先の業種がコピーされていること Opportunity opp = (Opportunity) orchestrator.getByAlias('opp'); Opportunity refetched = [ SELECT Id, Industry__c FROM Opportunity WHERE Id = :opp.Id ]; Assert.areEqual('Technology', refetched.Industry__c); t.finish(); } ``` 実 DML が実際の Trigger を通るため、このテストは **連鎖全体** (Handler → Usecase → ApexEloquent → DB) を検証します。使いどころは絞ります。Handler ごとに代表的なケースを 1 件から 3 件で十分です。ロジックの網羅は Usecase の単体テストに任せます。 ### その代表 1 本は、バルクにする トリガーが別のトリガーを呼ぶようなカスケードがある場合、**代表ケースのうち 1 本は「本番相当の件数を 1 回の DML で流し、ガバナの余白を確認する」テスト**にします。 理由は単純で、**`MockEloquent` は実 SOQL を発行しないため、クエリ数の非効率が単体テストからは一切見えないから**です。段階的に階層を降りて `whereIn` を撃つ実装は、単体テストが全緑のまま本番のバルク処理で `Too many SOQL queries: 101` を出します。そして上のような単一シナリオの結合テストも、レコードが数件では 100 SOQL の天井に届きません。 ```apex // Arrange の階層に times() を足して量産し、 SBlueprint.of(Opportunity.class).template(Blueprints.oppBasic()).alias('opp_{#}').times(30) // Assert に「ガバナ余白」を足す Assert.isTrue( Limits.getQueries() < Limits.getLimitQueries() / 2, 'バルクでも SOQL は上限の半分未満であること。実測 ' + Limits.getQueries() ); ``` 件数は再現に足る最小に留めます (DML 行数の上限 10,000 に注意)。どの Usecase が食っているかを名指ししたい場合は、`TraceFlow.usageOf(name)` で Usecase 単位に締められます ([TraceUsage でガバナ消費を縛る](https://krileworks.com/ja/apex-stem/docs/apex-trace-governor-it) を参照)。 考え方の全体像は [テスト戦略](https://krileworks.com/ja/apex-stem/docs/test-strategy) にまとめています。 ## この先へ - **[Apex Stem トップ](https://krileworks.com/ja/apex-stem)**。各ライブラリの役割と、ソースへのリンク。 設計そのものを掘り下げるなら: - **[Handler-Usecase Architecture](https://krileworks.com/ja/apex-stem/docs/handler-usecase-architecture)**。2 層の責務、5 種のエントリーポイント、Salesforce 公式の推奨との重なり。 - **[Layered Constructor Pattern](https://krileworks.com/ja/apex-stem/docs/layered-constructor-pattern)**。ステップ 3 で出てきた 2 つのコンストラクタを、独立したテーマとして。 - **[テスト戦略](https://krileworks.com/ja/apex-stem/docs/test-strategy)**。ステップ 4 の判断を体系化したもの。失敗時の切り分け、CI/CD の組み方、落とし穴。 各ライブラリを深掘りするなら: - **[ApexEloquent ガイド](https://krileworks.com/ja/apex-stem/docs/apex-eloquent-guide)**。Scribe のより深い解説 (集計、親項目、サブクエリ、MockEntry の応用パターン)。 - **[ApexBlueprint ガイド](https://krileworks.com/ja/apex-stem/docs/apex-blueprint-guide)**。SBlueprint のテンプレート、兄弟参照の `use()`、ネストした親子の `withChildren()`。 - **[ApexTrace ガイド](https://krileworks.com/ja/apex-stem/docs/apex-trace-guide)**。Trace のライフサイクル、TraceFlow による経路検証、TraceUsage によるガバナ消費の保険への入り口。 - **[ApexTools ガイド](https://krileworks.com/ja/apex-stem/docs/apex-tools-guide)**。ステップ 4 で使った `TriggerHandler` 基底クラスと、DI 可能な HTTP リクエストラッパー。 1 つのライブラリ、1 本のクエリ、1 つの Usecase から始められます。全部を書き直す必要はありません。 ============================================================================== Source: https://krileworks.com/document/ja/handler-usecase-architecture.md Page: https://krileworks.com/ja/apex-stem/docs/handler-usecase-architecture ============================================================================== # Handler-Usecase Architecture このドキュメントは、Apex Stem の中核となる Handler-Usecase Architecture を、設計思想から構造まで掘り下げて解説します。[Apex Stem 導入ガイド](https://krileworks.com/ja/apex-stem/docs/apex-stem-full-guide) で手を動かした後、「Handler や Usecase とは結局何なのか」を理解するための一冊です。 Handler-Usecase Architecture は、Salesforce Apex 開発のための軽量なアプリケーションアーキテクチャです。すべての Apex 処理を **Handler (エントリーポイント) と Usecase (ビジネスロジック)** の 2 層で設計します。 ## Handler-Usecase Architecture とは fflib のような重厚な多層アーキテクチャでもなく、すべてをトリガーやクラスにベタ書きする混沌でもない、**第三の道**です。 5 年間運用された Salesforce 組織のカオスを立て直す過程で考案され、実証されました。規約として覚えることは「幹となる 2 つの層 (Handler と Usecase)」と「オブジェクト指向の基本原則」だけ。それ以外は現場に委ねます。 ## なぜ 2 層なのか ### Laravel-MVC 派の系譜 Web フレームワークの Laravel は、Controller・Model・View だけを規定し、Service や Action、Repository といった部品はコミュニティの自由に任せています。Rails も同様です。 Handler-Usecase Architecture もこの系譜に立ちます。**Handler と Usecase が幹**であり、その下に現れる部品 (Reader / Validator / Mapper など) のカテゴリは規定しません。Selector / Domain / Service / UnitOfWork をすべて規定する fflib とは、ちょうど反対側の思想です。 ### 「規定しすぎない」と定めた理由 1. **業務によって必要な部品が違う**。Reader が要る業務もあれば、Validator が要る業務もあります。あらかじめ全部のカテゴリを決めても、使われないものが出ます。 2. **規定すると形骸化する**。5 年運用すると「Selector という名前なのに中身はベタ書き」のような、名ばかりの構造が必ず生まれます。 3. **ジュニアの成長機会になる**。「Handler から何を呼ぶか」「どこで部品を切り出すか」を考えること自体が、オブジェクト指向の設計力の訓練になります。 4. **AI 連携時に過剰な規約はノイズになる**。AI コーディングアシスタントに「12 個のパターンを守れ」と要求するより、「幹 2 つ + OOP 原則」の方がブレません。 ### ミノ駆動本との接続 『良いコード/悪いコードで学ぶ設計入門』(ミノ駆動本) で言う「**生焼けオブジェクトを作らない**」が、Usecase の核です。コンストラクタで必要なものをすべて受け取り、setter で後から状態を足さない。これは Effective Java や Domain-Driven Design の Value Object 思想の Apex 版です。 ## Handler 層 ### Handler の責務 Handler の責務は、**エントリーポイント固有の作法を吸収し、Usecase に渡すこと**だけです。ここにビジネスロジックは書きません。対象レコードの絞り込み (フィルタ) は許容されますが、それ以上のことはしません。Laravel における Controller に近い役割です。 ### 5 種のエントリーポイント Salesforce の Apex には複数のエントリーポイントがあり、それぞれに Handler を用意します。 | 種類 | エントリ元 | |---|---| | TriggerHandlers | DML トリガー (before/after × insert/update/delete) | | BatchHandlers | Batchable / Schedulable | | RestHandlers | @RestResource | | FlowHandlers | @InvocableMethod (Flow から呼ばれる) | | SchedulableHandlers | pure Schedulable | ### Trigger.cls の固定パターン トリガーファイルは Salesforce の慣例に従い、書き方を固定します。7 つのイベントをすべて宣言し、Handler を 1 行で呼ぶだけ。ロジックはトリガーファイルに書きません。 ```apex trigger Opportunity on Opportunity( before insert, before update, before delete, after insert, after update, after delete, after undelete ) { (new TriggerOppHandler()).execute(); } ``` ### TriggerHandler 基底クラス 各 Handler は `TriggerHandler` 基底クラス (ApexTools が提供) を継承し、必要なフックだけを override します。すべてのフックは `protected virtual` で、override しないフックは何もしません。 主なフックは `beforeInsert` / `beforeUpdate` / `beforeDelete` / `afterInsert` / `afterUpdate` / `afterDelete` / `afterUndelete`、そして必ず最後に呼ばれる `andFinally` です。加えて「特定フィールドが変更されたレコードだけを絞り込む」ヘルパー (`getUpdateRecordIdsWithChangedFields` など) も提供されます。 ### コード例 ここでは [Apex Stem 導入ガイド](https://krileworks.com/ja/apex-stem/docs/apex-stem-full-guide) で扱った `CopyAccountIndustryToOpportunityUsecase` (商談に親取引先の業種をコピーする Usecase) を、Trigger から呼び出してみます。 ```apex public with sharing class TriggerOppHandler extends TriggerHandler { protected override void afterInsert(Map newRecordsMap) { Set opportunityIds = newRecordsMap.keySet(); (new CopyAccountIndustryToOpportunityUsecase(opportunityIds)).invoke(); } } ``` Handler がしているのは「`Trigger.new` から商談 ID を集めて、Usecase に渡す」ことだけです。業種をコピーするロジックは一切ここにありません。それは Usecase の仕事です。 特定フィールドの変更時だけ Usecase を呼びたい場合は、基底クラスのヘルパーで絞り込みます。 ```apex protected override void afterUpdate(Map newMap, Map oldMap) { Set needIds = this.getUpdateRecordIdsWithChangedField(Opportunity.AccountId); (new CopyAccountIndustryToOpportunityUsecase(needIds)).invoke(); } ``` ## Usecase 層 ### Usecase の責務 Usecase は、**単一の業務ロジック**を実装します。public なメソッドは `invoke()` ただ 1 つ。それ以外はすべて private です。`invoke()` の戻り値の型は処理の性質に応じて選びます (LWC から呼ばれる Usecase は Result DTO、Trigger や Batch 駆動の多くは `void` など)。Laravel における Service や Action に近い役割です。 ### 生焼けオブジェクトを作らない Usecase は、コンストラクタで必要な依存をすべて受け取ります。setter で後から状態を足すことはしません。「コンストラクタを呼んだ時点で、その Usecase は完成している」状態を保ちます。 ### 2 つのコンストラクタ Usecase には 2 つのコンストラクタを用意します。 - **public コンストラクタ**。本番用。業務に必要な入力だけを受け取り、データアクセスなどの依存はデフォルト生成します。 - **`@TestVisible private` コンストラクタ**。テスト用。依存を引数で受け取り、テスト時にモックを注入できるようにします。 ```apex public with sharing class CopyAccountIndustryToOpportunityUsecase { @TestVisible static final String LBL_FETCH = 'oppFetch'; @TestVisible static final String LBL_UPDATE = 'oppUpdate'; private final Set opportunityIds; private final IEloquent eloquent; private Trace t = Trace.of('商談に親取引先の業種をコピー'); // public: 本番用、業務入力だけを受け取る public CopyAccountIndustryToOpportunityUsecase(Set opportunityIds) { this(opportunityIds, null); } // private (@TestVisible): テストで IEloquent を注入 @TestVisible private CopyAccountIndustryToOpportunityUsecase( Set opportunityIds, IEloquent eloquent ) { this.opportunityIds = opportunityIds; this.eloquent = eloquent ?? new Eloquent(); } public void invoke() { // ... ビジネスロジック (導入ガイドのステップ 3 に全文) ... } } ``` この「本番用とテスト用でコンストラクタを分ける」やり方を **Layered Constructor Pattern** と呼びます。詳しくは [Layered Constructor Pattern](https://krileworks.com/ja/apex-stem/docs/layered-constructor-pattern) を参照してください。手を動かす具体例は [導入ガイドのステップ 3](https://krileworks.com/ja/apex-stem/docs/apex-stem-full-guide) にあります。 > なお、Salesforce 公式が示すリファクタリング例も、**まったく同じ形のコンストラクタ**になっています (後述の「Salesforce 公式の推奨と重なる」を参照)。 ### 単体 Usecase と オーケストレーター Usecase Usecase には 2 つの形態があります。 - **単体 Usecase**。private メソッドだけで完結する、小規模なロジック。上の `CopyAccountIndustryToOpportunityUsecase` がこれにあたります。 - **オーケストレーター Usecase**。複数の部品クラス (Reader や Validator、あるいは別の Usecase) を統合する、大規模なロジック。 どちらも「public は invoke() のみ」「生焼けオブジェクトを作らない」という原則は同じです。 ## 部品クラスの扱い Handler-Usecase Architecture は、**Usecase の下に現れる部品クラスのカテゴリを規定しません**。 業務によって現れる部品の性質はバラバラです。「Reader / Validator / Mapper を必ず作れ」とは言いません。ある業務では Reader が要り、別の業務では Validator が要る。それは現場が判断します。 **共通するルールはひとつだけ**です。部品クラスも Usecase と同じく「生焼けオブジェクトを作らない」原則に従います。コンストラクタで必要なものをすべて受け取り、完成した状態でしか存在できないオブジェクトにします。 切り出し方は段階的でかまいません。最初は Usecase 内の private メソッドとして書き、複雑になってきたら独立したクラスに切り出す。最初から部品を作りすぎないことが大事です。 ## テスト戦略 (概要) Handler-Usecase Architecture の 2 層は、2 つのテスト戦略と 1 対 1 で対応します。 | 層 | テスト種別 | DB アクセス | テスト OSS | |---|---|---|---| | Usecase 層 | 単体テスト | なし (モック) | ApexEloquent (MockEloquent / MockEntry) | | Handler 層 | 結合テスト | あり (実 DML) | ApexBlueprint (SBlueprint / SOrchestrator) | ロジックの網羅は Usecase の単体テストで行います。Handler の結合テストは「Trigger や Batch を経由して Usecase が正しく呼ばれ、期待通り動くか」の代表ケースを 1 件から 3 件に絞ります。 テスト戦略の詳細は [テスト戦略](https://krileworks.com/ja/apex-stem/docs/test-strategy) を参照してください。手を動かす具体例は [導入ガイドのステップ 4](https://krileworks.com/ja/apex-stem/docs/apex-stem-full-guide) にあります。 ## Salesforce 公式の推奨と重なる ここまでの設計は、痛みから経験的にたどり着いたものです。後から知ったことですが、**Salesforce 公式も同じ設計を推奨しています**。 ### 関心の分離 公式ブログ [Reduce Deployment Test Time with Smarter Apex Test Runs](https://www.salesforce.com/blog/faster-deployment-test-runs/) より。 > **business logic and database interfacing should be separate concerns** > > **(日本語訳)** ビジネスロジックと DB インターフェースは、別々の関心事であるべきだ そして公式が示すリファクタリングの手順は、そのまま Handler-Usecase の手順になっています。DB アクセスを別クラスに切り出し、インターフェースを抽象化し、DI で差し替え可能にし、テストではモック実装を注入する。 ### コンストラクタの形も同じ 公式記事のサンプルコードです。 ```apex public class OpportunityService { private OpportunityServiceDbHandler dbHandler; public OpportunityService() { this(new OpportunityServiceDbHandlerImpl()); // 本番用 } @TestVisible private OpportunityService( OpportunityServiceDbHandler dbHandler) { // テスト用 (DI) this.dbHandler = dbHandler; } } ``` 上で説明した Layered Constructor Pattern と、構造が一致します。public コンストラクタが本番用で内部に委譲し、`@TestVisible private` がテスト用に依存を受け取る。同じ問題に対する、同じ答えです。 ### 二層構成 テストの分け方についても、公式はこう書いています。 > **The vast majority of your tests should be true unit tests.** > > **(日本語訳)** テストの大部分は、真の単体テストであるべきだ > Testing triggers, for example, **requires real DML execution**, as there is no substitute for validating the execution order. > Such tests aren't unit tests; they're integration or functional tests. **Use them sparingly**: only when you need to test a trigger or a particularly important or complex integration flow. > > **(日本語訳)** たとえばトリガのテストは、**実際の DML 実行を必要とする**。実行順序を検証する手段は他にないからだ。そうしたテストは単体テストではなく、結合テストや機能テストである。**控えめに使うこと**。トリガのテストが必要なとき、あるいは特に重要・複雑な統合フローのテストが必要なときに限る。 「単体が大部分」「トリガは実 DML が必要」「結合は控えめに」。上のテスト戦略の表と同じことを言っています。 ### 公式と違うところ 公式のやり方は正しいのですが、**サービスごとに DbHandler クラスとインターフェースとモック実装を手書き**することになります。Usecase が 50 個あれば、その 3 倍のクラスが付いてきます。SOQL の組み立ても、テストデータの依存解決も、毎回自分で書くことになります。 ApexEloquent がやったのは、この DbHandler を汎用化して 1 つにまとめたことです。 | | 公式のパターン | ApexEloquent | |---|---|---| | DB アクセスの抽象 | サービスごとに interface を手書き | `IEloquent` 1 つ | | 本番実装 | サービスごとに Impl を手書き | `Eloquent` 1 つ | | モック | サービスごとに Mock を手書き | `MockEloquent` 1 つ | | クエリ組み立て | 生 SOQL を手書き | `Scribe` (ビルダー) | | テストデータ | 手組み | `MockEntry` / ApexBlueprint | 発明ではありません。**公式が示した設計を、毎回手書きしなくて済むように汎用化しただけ**です。 逆に言えば、この設計を採るのに特定の OSS は必須ではありません。公式のやり方で手書きしてもよいし、fflib でも Apex Fluently でも構いません。Apex Stem が 4 つの OSS を組み合わせているのは、それらが Handler-Usecase のテスト戦略に直接整合するからです。 ## 他フレームワークとの立て分け ### fflib との立て分け Handler-Usecase Architecture は fflib を否定するものではありません。シーンが違えば、選ぶものも違います。 | 観点 | fflib | Handler-Usecase Architecture | |---|---|---| | 哲学 | 規定主義 (Java EE / Spring 派) | 最小骨格 (Laravel / Rails 派) | | 必須概念 | Selector, Domain, Service, UnitOfWork | Handler, Usecase | | 部品の規定 | あり | なし (現場判断) | | 既存組織への導入 | リライト前提 | 1 メソッドずつ侵食可能 | | AI 連携 | 規約が多く AI が迷いやすい | 規約 2 個 + 原則で AI に伝えやすい | | 向いている場面 | 50 人以上の大規模チームで統一したいとき | 1 から 5 人で、スピードと AI 連携を優先したいとき | Andy Fawcett 氏が築いた fflib の業績には敬意を払います。そのうえで、Handler-Usecase Architecture は「別の場面のための、別の選択肢」です。 --- ### Apex Fluently との関係 近年、もう一つの選択肢として [Apex Fluently](https://apexfluently.beyondthecloud.dev/) (Beyond The Cloud) があります。SOQL Lib、DML Lib、Async Lib、Cache Manager など 8 つのライブラリを集めた「現代的な fflib の代替」を掲げる OSS 群です。 ただ、Apex Fluently は **意図的にアーキテクチャを規定しません**。アプリケーションの層分けや責務の分け方には踏み込まず、ライブラリを個別に採用できる「道具集」として設計されています。 つまり、Apex Fluently と Handler-Usecase Architecture は、そもそも比べる土俵が違います。3 つを位置取りで見るとこうなります。 - **fflib**: 重厚な**アーキテクチャ** (Selector / Domain / Service / UnitOfWork) - **Apex Fluently**: 純粋な**道具** (アーキテクチャは規定しない) - **Handler-Usecase Architecture**: 最小の**アーキテクチャ** (Handler + Usecase) Apex Stem は、Handler-Usecase Architecture (アーキテクチャ) と 4 つの OSS (道具) の両方を提供します。Apex Fluently は Apex Stem の **OSS 層** (ApexEloquent 等) と同じレイヤーにいて、Handler-Usecase Architecture と直接競合する関係ではありません。 そして Handler-Usecase Architecture は道具非依存です。理屈の上では、Apex Fluently のライブラリを Handler 層や Usecase 層の下で使うこともできます。Apex Stem が ApexEloquent / ApexBlueprint / ApexTrace / ApexTools を組み合わせるのは、それらが Handler-Usecase Architecture のテスト戦略 (Usecase ↔ MockEloquent、Handler ↔ ApexBlueprint) に直接整合するからで、アーキテクチャがその組み合わせを強制しているわけではありません。 ## AI 時代における位置づけ 先に、順序をはっきりさせておきます。 **責務を分けている理由は、DI するため、つまりテスト可能にするためです。AI のコンテキストに収めるためではありません。** 責務で切ったら、結果として変更に必要なコンテキストも小さくなり、AI にも都合が良かった。この順序です。 この区別には実利があります。**根拠の寿命が違う**からです。 | 分割の動機 | AI の性能が上がったら | |---|---| | AI のコンテキストに収めるため | 根拠が薄れる (収まるようになるので) | | DI・テスト可能性のため | **根拠は残る** (無限のコンテキストがあっても、DB に触らない速く決定的なテストは欲しい) | つまり Handler-Usecase Architecture は、「AI が賢くなったら要らなくなる設計」ではありません。土台は AI の性能と独立しています。 ### そのうえで、AI にはよく効きます 規約が薄いことには、はっきりした実利があります。Claude Code のような AI コーディングアシスタントには、プロジェクトのルールを記したファイル (`CLAUDE.md`) を読み込ませます。fflib の規約をこのファイルに書こうとすると 200 行を超え、例示も必要になり、AI が迷うリスクが上がります。 一方 Handler-Usecase Architecture なら、アーキテクチャの核心は次のような短い記述で AI に伝わります。実際に、この内容を `CLAUDE.md` に置くことで、Claude Code がこのアーキテクチャに沿って Apex を書いてくれています。 ```markdown ## アーキテクチャ (Handler + Usecase の 2 段構え) すべての Apex 処理は Handler (エントリーポイント) + Usecase (ビジネスロジック) の 2 段構えで設計する。 ### 責務分離 - Handler: エントリーポイントから受け取った引数を解釈し、 適切な Usecase を呼び出す。ビジネスロジックは持たない。 - Usecase: 単一の業務ロジックを実装する。コンストラクタで パラメータを受け取り、public は invoke() のみ。 ### Handler の実装パターン Handler は「条件判定」と「Usecase 呼び出し」だけを行う。 Trigger ファイルは 7 イベントを宣言し、Handler を 1 行で呼ぶ。 ### Usecase の標準パターン 1. 2 つのコンストラクタ (本番用 public / テスト用 @TestVisible private) 2. public は invoke() のみ、他はすべて private 3. 依存は null-coalescing で本番デフォルト (eloquent ?? new Eloquent()) ### テスト戦略 - Usecase の単体テスト: MockEloquent でロジック分岐を網羅 - Handler の結合テスト: 実 DML で代表ケースを 1〜3 本 ``` 上の記述は 23 行です。これは机上の主張ではありません。KrileWorks 自身が実プロジェクトでこの記述を使い、AI と協働してコードを書いています。**「短い規約で、AI がアーキテクチャを守る」が実証されている**わけです。 ### ただし、粒度については留保があります 「分ける理由」は当分変わらないと考えていますが、**どこまで細かく分けるかの粒度**は別の話です。 「1 Usecase = 1 業務ロジック」という細かさまでが最適だと主張できる根拠は、いまのところ持っていません。細かく切ることにはコストもあります (クラス数が増える、全体像が追いにくくなる)。AI の性能が上がれば、もっと粗い粒度で十分という結論になる可能性は普通にあります。 ここに書いているのは、2026 年時点で妥当だと考えている粒度です。 ## 次に読む - [Apex Stem 導入ガイド](https://krileworks.com/ja/apex-stem/docs/apex-stem-full-guide): 既存コードに段階的に取り入れる 4 ステップ - [Layered Constructor Pattern](https://krileworks.com/ja/apex-stem/docs/layered-constructor-pattern): 2 つのコンストラクタの設計 - [テスト戦略](https://krileworks.com/ja/apex-stem/docs/test-strategy): Handler と Usecase それぞれのテストの書き方 ============================================================================== Source: https://krileworks.com/document/ja/layered-constructor-pattern.md Page: https://krileworks.com/ja/apex-stem/docs/layered-constructor-pattern ============================================================================== # Layered Constructor Pattern このドキュメントは、Apex Stem の Usecase 層で繰り返し現れる設計パターン **Layered Constructor Pattern** を、独立したテーマとして掘り下げます。[Handler-Usecase Architecture](https://krileworks.com/ja/apex-stem/docs/handler-usecase-architecture) を読んで「2 つのコンストラクタって結局なに?」となった人向けです。 Layered Constructor Pattern は、Usecase 1 クラスの中に **本番用のシンプルな API** と **テスト用の柔軟な依存注入** を共存させる設計パターンです。DI コンテナを持たない Apex でも、生焼けオブジェクトを作らずにテスト容易性を確保するための定石として機能します。 Salesforce 公式ブログのサンプルコードも、**これとまったく同じ形のコンストラクタ**を示しています ([該当箇所](https://krileworks.com/ja/apex-stem/docs/handler-usecase-architecture))。独自の書き方ではありません。 ## Layered Constructor Pattern とは ひとつの Usecase クラスに、2 つのコンストラクタを並べます。 - **public コンストラクタ**: 本番用。業務に必要な入力 (ID、SObject、リクエスト DTO など) だけを受け取ります。 - **`@TestVisible private` コンストラクタ**: テスト用。業務入力に加えて、データアクセスやヘルパー部品といった依存をすべて引数で受け取ります。 public コンストラクタは private コンストラクタに `this(...)` で委譲し、依存はすべて `null` を渡します。private コンストラクタ側で `?? new Eloquent()` のような null-coalescing で本番デフォルトに差し替えれば、本番では依存が完全に隠れ、テストではモックを注入できます。 Apex Stem の Usecase 層では、この形が標準です。 ## なぜこの形が必要か ### 継ぎ目を、1 か所だけ開ける このパターンの目的は「コンストラクタを 2 つ書くこと」ではありません。**差し替えられる場所 (継ぎ目) を、オブジェクトが完成する 1 か所に集めること**です。 private コンストラクタは、このクラスで唯一の初期化ロジックです。フィールドが埋まるのはここだけ、依存が決まるのもここだけ。だから、テストが介入する場所もここ 1 か所で済みます。 継ぎ目が 1 か所だと、次のことが同時に成り立ちます。 - **本番の呼び出し側からは継ぎ目が見えない**。`new Xxx(ids)` の 1 行で、いつも完成した状態が返る - **テストは何も足さずに介入できる**。setter を生やす、可視性を緩める、テスト用のフラグを足す — どれも要りません - **どの経路で作っても不変条件が同じ**。public から来ても private から来ても、通るコードは 1 本です 逆に、継ぎ目が散らばると (setter が 3 つ、初期化が 2 系統) 「どれを呼べば完成なのか」がクラスの外に漏れ出します。それを防ぐのがこの形です。 ### 継ぎ目は、テストを本番より甘くしないためにある 差し替え可能にする目的は「テストを速くすること」だけではありません。**テストを本番より甘くしないこと**も同じくらい重要です。 `IEloquent` をこの継ぎ目から `MockEloquent` に差し替えると、モックは**本番のクエリ (`Scribe`) が何を SELECT しているか**を知った状態でレコードを返します。SELECT していない項目に触れば、単体テストの段階で例外になります。自分で組み立てた SObject を直接渡していたら、`null` が返って静かに通り、本番で初めて落ちていた挙動です。 継ぎ目をここに置くから、モックが本番の契約を引き継げます。これは「テストのために設計を歪める」話ではなく、**設計を正すとテストが本番に近づく**という話です。 ### Apex には DI コンテナがない Java の Spring や PHP の Laravel が提供するような、依存を自動配線してくれる DI コンテナは Apex の標準ライブラリにはありません。Inject 用のアノテーションも、コンストラクタの自動解決もありません。 ということは、依存をどう注入するかは **手動で設計するしかない** わけです。Layered Constructor Pattern は、その手動 DI のひとつの解です。 --- ### 「全部コンストラクタで受ける」は呼び出し側がつらい 愚直にやろうとすると、public コンストラクタひとつだけを置いて、業務入力も依存もすべてそこで受け取る形になります。 ```apex // アンチパターン: 本番でも依存をすべて組み立てる必要がある new CreateOpportunityFromAccountUsecase( accountId, new AccountReader(new Eloquent()), new OpportunityEligibilityValidator(), new OpportunityMapper(), new Eloquent() ).invoke(); ``` Trigger ハンドラから呼び出すたびにこれを書くのは現実的ではありません。本番コードのノイズが増えるだけでなく、**本番デフォルトの定義が呼び出し側に散ります**。デフォルトをひとつ変えたくなった時に、全呼び出し箇所を直すことになります。 --- ### 「引数なしのコンストラクタ + setter で後から注入」は生焼けオブジェクトを作る 別のアプローチとして、引数のないコンストラクタでインスタンスを作ってから setter で依存を流し込む形が考えられます。 ```apex // アンチパターン: setter 注入は「生焼けオブジェクト」を作ってしまう CopyAccountIndustryToOpportunityUsecase usecase = new CopyAccountIndustryToOpportunityUsecase(); usecase.setOpportunityIds(opportunityIds); usecase.setFetchEloquent(new Eloquent()); usecase.invoke(); ``` これは Handler-Usecase Architecture の核となる原則「**生焼けオブジェクトを作らない**」に反します。setter の呼び忘れによる NullPointerException が顕在化しにくく、「コンストラクタを呼んだ時点で完成している」というオブジェクトの整合性が保てません。 --- ### Layered Constructor Pattern が両方を解決する Layered Constructor Pattern では、 - 本番側は public コンストラクタで **業務入力だけ** を渡せばよい - テスト側は private コンストラクタで **依存も含めて完全な状態** で組み上げる - どちらのコンストラクタも「呼び出した瞬間に完成している」状態を保つ DI コンテナの代わりに、コンパイラと `@TestVisible` の組み合わせがその役割を担います。 ## 構造 ### public コンストラクタと private コンストラクタの役割 | コンストラクタ | 可視性 | 受け取る引数 | 役割 | |---|---|---|---| | public コンストラクタ | `public` | 業務入力のみ | 本番からの呼び出し口。private コンストラクタに委譲する | | private コンストラクタ | `@TestVisible private` | 業務入力 + 全依存 | 唯一のオブジェクト初期化ロジック。依存が null なら本番デフォルトに差し替え | 「public コンストラクタは最小、private コンストラクタは完全」が原則です。本番の呼び出し側からは依存が見えず、テストからは依存をすべて差し替えられる、という両立がここで成立します。 ### 委譲と null-coalescing public コンストラクタは private コンストラクタに `this(...)` で委譲し、依存はすべて `null` を渡します。private コンストラクタ側で `?? new Eloquent()` のような null-coalescing 演算子で本番デフォルトに差し替えます。 ```apex public CopyAccountIndustryToOpportunityUsecase(Set opportunityIds) { this(opportunityIds, null); // 依存は null、private コンストラクタに委譲 } @TestVisible private CopyAccountIndustryToOpportunityUsecase( Set opportunityIds, IEloquent eloquent ) { this.opportunityIds = opportunityIds; this.eloquent = eloquent ?? new Eloquent(); // null なら本番デフォルト } ``` 呼び出し側から見ると、本番では `new CopyAccountIndustryToOpportunityUsecase(ids)` の 1 行で済み、テストでは `new CopyAccountIndustryToOpportunityUsecase(ids, mock)` で依存を差し替えられます。 ## 例 1: 末端 Usecase (IEloquent を DI) [Apex Stem 導入ガイド](https://krileworks.com/ja/apex-stem/docs/apex-stem-full-guide) のステップ 3 で扱った `CopyAccountIndustryToOpportunityUsecase` を、Layered Constructor Pattern の視点で見直します。 ```apex public with sharing class CopyAccountIndustryToOpportunityUsecase { @TestVisible static final String LBL_FETCH = 'oppFetch'; // 商談 + 親取引先の取得 @TestVisible static final String LBL_UPDATE = 'oppUpdate'; // 商談の更新 private final Set opportunityIds; private final IEloquent eloquent; private Trace t = Trace.of('商談に親取引先の業種をコピー'); public CopyAccountIndustryToOpportunityUsecase(Set opportunityIds) { this(opportunityIds, null); } @TestVisible private CopyAccountIndustryToOpportunityUsecase( Set opportunityIds, IEloquent eloquent ) { this.opportunityIds = opportunityIds; this.eloquent = eloquent ?? new Eloquent(); } public void invoke() { // (導入ガイドのステップ 3 に全文) } } ``` 注目するポイントは 2 つです。 - **`IEloquent` 1 本をラベル多重化で用途別に分けている**。取得用 (`LBL_FETCH`) と更新用 (`LBL_UPDATE`) を同じ `IEloquent` に対するラベルとして分けることで、テスト時に「取得は成功するが、更新だけ例外を投げる」のような独立シナリオを 1 本の `MockEloquent` で組めます。v2.1 で `label()` が登場する前は、2 本の `IEloquent` フィールドに分けて DI していました (それでも動きますが、コンストラクタが太ります)。 - **`?? new Eloquent()` で本番デフォルト**。本番の呼び出し側からは依存が完全に隠れ、`new CopyAccountIndustryToOpportunityUsecase(ids)` だけで動きます。 このように、データアクセスを抽象 (`IEloquent`) で受けて Layered Constructor Pattern で DI する形が、末端 Usecase の基本形です。`IEloquent` 単体の Usecase なら、v2.1 以降は **ラベル多重化で 1 本にまとめる** のが推奨です。異なる種類の依存 (`IEloquent` + Reader + Validator + Mapper 等) を分ける場面は、次の例 2 で扱います。 ## 例 2: オーケストレーター Usecase (部品クラスを DI) 複数ステップを束ねるオーケストレーター Usecase では、依存が `IEloquent` だけでなく Reader / Validator / Mapper などの **部品クラス** に広がります。それでも Layered Constructor Pattern の形は変わりません。 例として、「取引先 ID から商談を 1 件作成する」Usecase を考えます。手順は次の通りです。 1. 取引先を取得する (`AccountReader`) 2. 商談を作って良いかを検証する (`OpportunityEligibilityValidator`) 3. 取引先の情報を元に商談を組み立てる (`OpportunityMapper`) 4. 商談を insert する (`IEloquent`) ```apex public with sharing class CreateOpportunityFromAccountUsecase { private final Id accountId; private final AccountReader accountReader; private final OpportunityEligibilityValidator validator; private final OpportunityMapper mapper; private final IEloquent insertEloquent; private Trace t = Trace.of('取引先から商談を作成'); public CreateOpportunityFromAccountUsecase(Id accountId) { this(accountId, null, null, null, null); } @TestVisible private CreateOpportunityFromAccountUsecase( Id accountId, AccountReader accountReader, OpportunityEligibilityValidator validator, OpportunityMapper mapper, IEloquent insertEloquent ) { this.accountId = accountId; this.accountReader = accountReader ?? new AccountReader(new Eloquent()); this.validator = validator ?? new OpportunityEligibilityValidator(); this.mapper = mapper ?? new OpportunityMapper(); this.insertEloquent = insertEloquent ?? new Eloquent(); } public void invoke() { this.t.start(); IEntry accountEntry = this.accountReader.fetch(this.accountId); this.validator.assertEligible(accountEntry); Opportunity opp = this.mapper.toOpportunity(accountEntry); this.insertEloquent.doInsert(opp); this.t.finish('商談を 1 件作成。'); } } ``` 形は例 1 とまったく同じです。違うのは **依存の種類が増えた** ことだけで、public コンストラクタは業務入力 (`accountId`) しか受け取らず、private コンストラクタで全依存を受けて null なら本番デフォルトに差し替えています。 `AccountReader` 自身も「コンストラクタで `IEloquent` を受け取る」形 (生焼けオブジェクトを作らない原則) になっており、`new AccountReader(new Eloquent())` の 1 行で完成します。`OpportunityEligibilityValidator` や `OpportunityMapper` のように外部依存を持たない部品は、引数なしの new で組み上げます。 > 部品クラス (`Reader` / `Validator` / `Mapper`) の責務分けや切り出し方の指針は、[Handler-Usecase Architecture の「部品クラスの扱い」](https://krileworks.com/ja/apex-stem/docs/handler-usecase-architecture) を参照してください。本ドキュメントの主題は、それらをどう Usecase に **注入するか** です。 ### この 4 分割は、目標ではありません 例 2 は「依存の種類が増えても形は変わらない」ことを示すための例であって、**4 つに分けること自体を推奨しているわけではありません**。 分ける動機は、**そこに差し替えたい継ぎ目があるかどうか**です。`AccountReader` は DB に触るので継ぎ目に値します。一方、差し替える必要のない純粋なロジックを「DI したいから」という理由だけでクラスに切り出すと、割に合わないことが起きます。 - 引数のバケツリレーが増える (部品間でデータを持ち回るためだけの引数) - 同じデータを扱う処理が別クラスに分かれ、**同じレコードを 2 回クエリする** - 切り出した先のクラスが、他から使われない中途半端な存在になる 判断の目安は、**同じ知識 (コンテキスト) を扱うものはまとめ、知識が変わるところで切る**、です。闇雲にまとめるのも逆方向の失敗で、共通化の受け皿にされたクラスは、どの文脈にも属さない不完全なものになっていきます。 実装したあとに一度、こう問い直すと粒度が整います。 > まとめられるもの・効率化できるものはあるか。ただし過剰な共通化は避け、**同じコンテキストの知識だけ**をまとめること。 粒度そのものへの留保は [Handler-Usecase Architecture の「ただし、粒度については留保があります」](https://krileworks.com/ja/apex-stem/docs/handler-usecase-architecture) にも書いています。ここに示した形は、2026 年時点で妥当だと考えている粒度です。 ## テストで何が変わるか Layered Constructor Pattern を採用した Usecase は、テスト時に次の自由度を手に入れます。 - **用途別に依存を独立差し替えできる**。`IEloquent` 単体ならラベル多重化 (例: `LBL_FETCH` / `LBL_UPDATE`)、オーケストレーターでは部品クラスをフィールドごとに分割、と粒度を選べます。どちらの形でも「取得は成功するが更新だけ例外を投げる」「取得は空、更新は呼ばれないことを検証する」といった独立シナリオが自然に書けます。1 つの `IEloquent` を**ラベルなしで**使い回す設計では難しい検証が、無理なく組めるようになります。 - **部品クラスをモック・fake・本物の任意粒度で差し替えできる**。オーケストレーター Usecase では、`AccountReader` をモックに、`OpportunityMapper` だけは本物を使う、といった粒度の選択がテストごとに可能です。「ロジックの中心は本物で動かしつつ、外部 I/O だけ閉じる」テスト設計が無理なく書けます。 - **本番コードを 1 行も書き換えなくてよい**。テストを書くために、本番のコンストラクタを増やしたり setter を生やしたりする必要はありません。`@TestVisible` を付けた private コンストラクタが、テスト専用の入り口としてすでにそこにあるからです。 テストコードの具体例は、[Apex Stem 導入ガイドのステップ 4](https://krileworks.com/ja/apex-stem/docs/apex-stem-full-guide) に載せています。`MockEloquent` を 1 本注入し、取得を `attach(LBL_FETCH, ...)` で仕込んで、更新結果を `upsertedRecordsAt(LBL_UPDATE)` で検証する形が、Layered Constructor Pattern の素直な使い方になっています。 ## 次に読む - [Handler-Usecase Architecture](https://krileworks.com/ja/apex-stem/docs/handler-usecase-architecture): Layered Constructor Pattern が現れる Usecase 層を含む、Apex Stem の中核アーキテクチャ - [Apex Stem 導入ガイド](https://krileworks.com/ja/apex-stem/docs/apex-stem-full-guide): Layered Constructor Pattern を含む 4 ステップを、動くコードとともに辿る - [テスト戦略](https://krileworks.com/ja/apex-stem/docs/test-strategy): Handler と Usecase それぞれのテストの書き方 ============================================================================== Source: https://krileworks.com/document/ja/test-strategy.md Page: https://krileworks.com/ja/apex-stem/docs/test-strategy ============================================================================== # テスト戦略 このドキュメントは、Apex Stem のテスト戦略を、設計判断と実践規約の両面から解説します。[Handler-Usecase Architecture](https://krileworks.com/ja/apex-stem/docs/handler-usecase-architecture) の 2 層を、どんな種類のテストでどう守るかを、腰を据えて把握するためのページです。 手を動かす実例 (動くテストコード) は [Apex Stem 導入ガイドのステップ 4](https://krileworks.com/ja/apex-stem/docs/apex-stem-full-guide) を正典として参照してください。本ドキュメントは、そこに散らばっている判断と規約を体系化します。 ## このドキュメントの要点 (TL;DR) Apex Stem のテスト戦略は、Handler-Usecase Architecture の 2 層を **「自分のロジックの責任範囲」と「プラットフォーム / 組織設定の責務」** に構造で分離するところから始まります。 - **単体テスト** (`MockEloquent`、DB なし) で前者を網羅 - **結合テスト** (`ApexBlueprint`、実 DML) で後者を代表ケースだけ検証 これにより、テスト失敗時に「**自分のコードのバグ**」か「**アドミンによる設定変更**」かが瞬時に切り分けられます。Salesforce のように本番環境がコードの外側で動的に変わる前提では、この切り分けが運用上の決定的な利点になります。 ## 読み方ガイド このページは哲学・規約・実用がひとつにまとまっていますが、関心ごとに必要な章だけ読めば十分です。はじめての方は順番どおりに、経験者は気になる章から飛んで構いません。 | 関心 | 読む順番 | |---|---| | **テストの判断哲学を知りたい** (なぜこういう設計なのか) | テスト戦略の全体像 → 単体テストが守るもの・守らないもの → Salesforce 特有の事情: 動的な実行環境 → テスト失敗時の判断マトリクス → なぜこの戦略が長期で効くか | | **テストをこれから書く** (実装規約をすぐ使いたい) | Usecase 層: 単体テスト → Handler 層: 結合テスト (「その代表 1 本は、ガバナ IT にする」まで) → テストの書き方の規約 | | **失敗時のデバッグ参考** | テスト失敗時の判断マトリクス → 棲み分けの判断と落とし穴 | | **チームに展開する設計判断のまとめ** (なぜを伝える材料) | 単体テストが守るもの・守らないもの → Salesforce 特有の事情 → CI/CD 運用の推奨 → なぜこの戦略が長期で効くか | ## テスト戦略の全体像 Apex Stem のテスト戦略は、Handler-Usecase Architecture の 2 層と、2 つのテスト種別、2 つの OSS が **1 対 1 で対応** するように設計されています。 | 層 | テスト種別 | DB アクセス | 使う OSS | |---|---|---|---| | Usecase 層 | 単体テスト | なし (モック) | ApexEloquent (`MockEloquent` / `MockEntry`) | | Handler 層 | 結合テスト | あり (実 DML) | ApexBlueprint (`SBlueprint` / `SOrchestrator`) | ### なぜこの規約を明示するか 1 対 1 マッピングをチームの規約として明示することで、3 つの実利があります。 - **覚えやすさ**。テストを書く前の「どのテスト種別で何を使うか」の判断コストが消えます。Usecase なら MockEloquent、Handler なら ApexBlueprint、と即決できます。 - **教育性**。新しくチームに加わる人に「この層にはこの種類のテストを書きます」と短く伝えるだけで、テスト設計の土台が共有できます。 - **AI 連携**。AI コーディングアシスタント (Claude Code など) に開発ルールを記したファイル (`CLAUDE.md`) を渡す際、この 1 対 1 対応を 2 行で伝えるだけで、AI が適切なテスト種別と OSS を選んで書いてくれます。 ### 分担の原則: 単体で網羅、結合は代表ケース 層ごとのテストには、それぞれ担う責務があります。 - **Usecase の単体テスト**: ロジック分岐をすべて網羅します。フェーズ別の集計、null や空入力、複数キーの組み合わせ、`TraceFlow` が `skip()` で抜けるパスと `finish()` で抜けるパスの両方を、`MockEloquent` で高速・隔離で書きます。 - **Handler の結合テスト**: 「Trigger / Batch / REST から Usecase が呼ばれて、期待通り連鎖全体が動くか」の代表ケースを 1 から 3 件に絞ります。ロジックの網羅は Usecase 側で済ませ、Handler 側で網羅的に書きません。 この分担を逆にすると、結合テストが遅く冗長になり、ロジック網羅で時間とカバレッジが破綻します。 ## 単体テストが守るもの・守らないもの 「単体テストと結合テストは粒度の違いだ」と捉えると、Salesforce の文脈ではしばしば判断を見誤ります。Apex Stem では **責任範囲の異なるレイヤー** として両者を扱います。ここをはっきりさせておくと、後段の判断 (テスト失敗時の初動 / CI/CD の運用) が一気に決まります。 ### 守るもの: 自分が書いたロジック Usecase の単体テストが検証する対象は、**自分が書いたロジックの正しさ** に限ります。 - 入力 (コンストラクタ引数) に対する出力 (`invoke()` の戻り値) - DB への副作用 (`MockEloquent` の spy による検証) - 変更レコードの内容 (フィールド値) - 変更レコードの件数 - 削除レコードの件数 ### 守らないもの: プラットフォームと組織設定の責務 逆に、以下は **意図的に** 単体テストの対象外とします。 - Salesforce プラットフォームの挙動 (`Database.upsert` の `allOrNone`、External ID upsert の細部など) - トリガー連鎖、ワークフロー、フロー、プロセスビルダーの動作 - バリデーションルール、必須項目チェック - 権限・項目レベルセキュリティ (FLS) - 重複ルール、割り当てルール - レコードタイプ、ページレイアウト これらは「**自分が書いたロジック**」ではなく、Salesforce プラットフォームおよび組織設定の責務です。単体テストで巻き込むと、テストの責任範囲が曖昧になり、失敗時の原因切り分けが難しくなります。 ### 守れないもの: ガバナ消費 (自分の責任範囲なのに、単体からは見えない) 「守るもの」と「守らないもの」の 2 つに分けると、こぼれ落ちるものがあります。**自分の責任範囲でありながら、単体テストからは構造的に観測できないもの** — その代表がガバナ消費 (SOQL 数・DML 数) です。 `MockEloquent` は実 SOQL を発行しません。だから速く、隔離されるわけですが、裏返すと **クエリ数・サブクエリの形・ガバナ消費に対しては原理的に盲目**です。 - 階層を段階的に降りて `whereIn` を撃つ実装は、読む SObject の種類数だけ SOQL を積み上げます - トリガーのカスケードがあると、1 パスの SOQL 数がそのまま再入回数だけ乗算されます - **どちらも単体テストは全緑のまま通ります**。本番のバルク処理で初めて `Too many SOQL queries: 101` として現れます これはプラットフォームの責務ではありません。**自分が書いたクエリの効率という、完全に自分の責任範囲の問題**です。にもかかわらず単体テストからは見えない。だからここだけは、後述する **ガバナ結合テスト (実 DML バルク)** で別途塞ぎます。 > 「単体で網羅、結合は代表ケース」という分担は正しいのですが、**その代表ケースに何を選ぶか**がここで効いてきます。詳しくは「Handler 層: 結合テスト」の「その代表 1 本は、ガバナ IT にする」を参照してください。 ### なぜ責任範囲を構造で分けるか (テストの診断価値) 責任範囲を構造で分けておくと、テスト失敗時に「**どこに問題があるか即座に切り分けられる**」ようになります。これがテストスイートの中核的な価値、すなわち **診断価値 (diagnostic value)** です。 - Usecase 単体テストが落ちた → 自分のロジックに問題がある (= コードの修正が必要) - 単体は通って結合だけ落ちた → 自分のロジックは無実、プラットフォーム / 組織設定の変化が原因 逆にトリガー連鎖や権限を単体テストに巻き込むと、失敗時に「ロジックのバグ? プラットフォームの挙動? 権限設定? 重複ルール?」と調査範囲が爆発します。「単体テストが落ちている」という情報だけでは何も特定できなくなり、テストの価値が下がります。 `MockEloquent` の細部挙動 (`Database.upsert` の部分成功、External ID upsert の動作など) が本物の `Eloquent` と完全一致しているか、を気にしないのも同じ理由です。そこは Salesforce プラットフォームの責務であって、自分のコードの責任範囲ではありません。プラットフォーム挙動に依存する部分は、結合テストで確認します。 ## Salesforce 特有の事情: 動的な実行環境 通常のソフトウェア開発では、本番環境の挙動はデプロイされたコードによって決まります。コード変更には PR レビューが入り、Git で履歴が追えます。 しかし Salesforce では: - アドミンがフローを変更できる (デプロイ不要) - 項目の必須化・バリデーションルールが画面から追加される - 権限セット・プロファイルが運用中に変更される - 重複ルール・割り当てルールが追加される これらの変更は **コードの外側** で発生し、Git に履歴が残りにくいものです。つまり Salesforce の本番環境は、開発者から見れば「**いつ何が変わっているか分からない実行環境**」です。 ### この前提から導かれる、2 つの全く異なる役割 Salesforce 環境では、テストスイートには 2 つのまったく異なる役割が求められます。 | 役割 | 内容 | 担当するテスト種別 | |---|---|---| | **自分のロジックが正しいことの保証** | アドミンが何をしようと、自分の書いた条件分岐・計算・データ加工は意図通り動く | Usecase 単体テスト | | **プラットフォーム環境との整合性の保証** | 現在の設定下で、自分のコードが正しく動作する | Handler 結合テスト | この 2 つを構造的に分離することで、テスト失敗時の初動が即座に判断可能になります。詳しくは後段の「テスト失敗時の判断マトリクス」セクションで扱います。 ## Usecase 層: 単体テスト ### なぜ DB を使わないか Usecase は単一の業務ロジックを実装するクラスで、データアクセスは `IEloquent` を通じて行います。テストでは `IEloquent` を `MockEloquent` に差し替えれば、データベースに触れずに **ロジックそのものだけ** を検証できます。 DB を使わないことで得られるものは 3 つあります。 - **テストが速い**。実 DML を介さないのでミリ秒単位で完了します。 - **テストが隔離される**。レコードタイプや組織設定、他テストの副作用に左右されません。 - **検証対象を絞れる**。「この条件のとき、この更新が起きる」を、ノイズなくアサーションできます。 `MockEloquent` と `Eloquent` はともに `IEloquent` インターフェースを実装し、プロダクションコードはインターフェースに依存します。つまり Usecase から見れば、本番の `Eloquent` と単体テストの `MockEloquent` は同じ契約のオブジェクトであり、細部の挙動 (`Database.upsert` の部分成功、External ID upsert のセマンティクスなど) を一致させる必要はありません。そこは Salesforce プラットフォームの責務であって、自分のコードの責任範囲ではないからです (詳しくは前段の「単体テストが守るもの・守らないもの」を参照)。 速さについては、補足があります。**速いと生産性が上がる、という話ではありません**。効くのは「テストが**実行されるかどうか**」です。 実 DML のテストは、組織の状態に引きずられて数十秒から数分かかります。すると開発の途中では回さなくなり、「たぶん通るだろう」のまま先に進みます。単体テストがミリ秒で終わるなら、書いた直後に回せます。**フィードバックのループが途切れないこと**が、速さの本当の価値です。 > ⚠️ ただし、これは単体テストが結合テストの代わりになるという意味ではありません。速さと引き換えに見えなくなるもの (前段の「守れないもの: ガバナ消費」) があります。 ### ApexEloquent (MockEloquent / MockEntry) の役割 | クラス | 役割 | |---|---| | `MockEloquent` | `IEloquent` の差し替え先。`get(scribe)` で返す `IEntry` のリストを `attach(label, ...)` で注入する。実行された DML は `upsertedRecordsAt(label)` / `deletedCountAt(label)` で取り出せる | | `MockEntry` | `SObject` の差し替え先。`set('Field__c', value)` で書き込み不可項目 (数式・ロールアップ・親リレーション) にも値を入れられる。Scribe で `field()` していない項目にアクセスすると例外を投げ、SELECT 漏れを検知する | ### 何を検証するか (単体) Usecase の単体テストでは、次の観点を網羅します。 - **業務ロジックの分岐**。`if` / `switch` の各パス、フィールド値による振る舞いの違い、複数レコードを跨ぐ集計の正しさ - **早期 return の経路**。「対象が空ならスキップ」「条件不一致なら抜ける」が、ちゃんと skip パスで終わっているか - **DML の中身**。`upsertedRecordsAt(label)` に何が積まれたか、どのフィールドにどの値が入っているか `TraceFlow.isLastFinish()` と `TraceFlow.isLastSkip()` は、戻り値だけでなく **どのコードパスを通ったか** を区別する手段です。「対象がなくてスキップした」と「処理が正常に完了した」を、`invoke()` の返り値が `void` でも別々に検証できます。 ### コード抜粋 (単体テスト) [Apex Stem 導入ガイドのステップ 4](https://krileworks.com/ja/apex-stem/docs/apex-stem-full-guide) の `CopyAccountIndustryToOpportunityUsecase` のテストから、骨格を抜粋します。 ```apex @isTest static void testInvoke_WhenOpportunityHasAccount_ThenIndustryCopied() { Trace t = Trace.of('正常系: 商談に親取引先の業種がコピーされること'); t.start(); // Arrange MockEntry oppEntry = MockEntry.of(Opportunity.class) .alias('opp').autoId(1) .setParent('AccountId', MockEntry.of(Account.class).set('Industry', 'Technology')); MockEloquent mock = (new MockEloquent()) .attach(CopyAccountIndustryToOpportunityUsecase.LBL_FETCH, new List{ oppEntry }); // Act (new CopyAccountIndustryToOpportunityUsecase( new Set{ oppEntry.getAliasId('opp') }, mock )).invoke(); // Assert List updated = mock.upsertedRecordsAt(CopyAccountIndustryToOpportunityUsecase.LBL_UPDATE); Assert.areEqual(1, updated.size()); Assert.areEqual('Technology', ((Opportunity) updated[0]).Industry__c); Assert.isTrue(TraceFlow.isLastFinish()); t.finish(); } ``` 注目するポイント: - **MockEntry の親レコード**。`setParent('AccountId', ...)` で親取引先をぶら下げ、本物の SOQL を介さずに「親取引先が業種を持っている商談」が組めます。 - **`IEloquent` をラベル多重化で 1 本にまとめる**。同じ `mock` を取得 (`LBL_FETCH`) と更新 (`LBL_UPDATE`) でラベル分けすることで、用途別にインスタンスを分けなくても DML だけを `upsertedRecordsAt(LBL_UPDATE)` で独立して観察できます。詳しくは [Layered Constructor Pattern](https://krileworks.com/ja/apex-stem/docs/layered-constructor-pattern) を参照してください。 - **`TraceFlow.isLastFinish()`**。戻り値でない経路 (`skip` / `finish` / `abort`) の検証は TraceFlow が担います。 「対象なしでスキップされる」のような分岐の検証は、`TraceFlow.isLastSkip()` に置き換えて同じ形で書けます。導入ガイドのステップ 4 に両方の正典が載っています。 ## Handler 層: 結合テスト ### なぜ実 DML が必要か Handler は Trigger / Batch / REST / Flow / Schedulable などのエントリーポイントに紐づくクラスで、エントリーポイント固有の作法を吸収して Usecase に渡すのが責務です。 「Trigger が発火して TriggerHandler が呼ばれ、想定通り Usecase が動く」という配線は、**実 DML を流さないと再現できません**。`Trigger.new` や `Trigger.oldMap` の値、レコードタイプの解決、他のトリガーとの相互作用は、Apex のテストランタイムで実 DML を実行した時だけ正確に再現されます。 ### ApexBlueprint (SBlueprint / SOrchestrator) の役割 | クラス | 役割 | |---|---| | `SBlueprint` | 1 つの SObject に対するレコード定義。`template()` でデフォルト値、`set()` で個別上書き、`withChildren()` で子をぶら下げる、`alias()` で取り出し用の名前付け | | `SOrchestrator` | 複数 `SBlueprint` を集めて、依存関係をトポロジカルソートして順次 insert する。実 DML が走るのはここ | ApexBlueprint を使うと、「業種を持つ取引先 → その子として商談」のような階層が宣言的に組み立てられ、insert 順は自動で解決されます。 ### 何を検証するか (結合) 結合テストの観点は、単体テストとはまったく違います。 - **配線が正しいか**。Trigger / Batch / REST から該当の Handler が呼ばれ、Handler が想定の Usecase を呼んでいるか - **連鎖全体の整合性**。Handler → Usecase → ApexEloquent → 実 DB の経路を通して、最終的に保存された値が期待通りか - **代表的なシナリオ**。「商談を insert したら親取引先の業種がコピーされる」のような、エントリーポイント起点の代表ケースを 1 から 3 件だけ ロジック分岐の網羅は Usecase 単体テストの責務です。Handler 結合テストで全分岐を網羅すると、テストが極端に遅くなり、原因切り分けも難しくなります。 ### コード抜粋 (結合テスト) 導入ガイドのステップ 4 の Handler 結合テストから、骨格を抜粋します。 ```apex @isTest static void testAfterInsert_WhenOpportunityInserted_ThenIndustryCopied() { Trace t = Trace.of('正常系: 商談 insert で親取引先の業種がコピーされること'); t.start(); // Arrange: ApexBlueprint で階層を組み立てる SOrchestrator orchestrator = SOrchestrator.start() .add(SBlueprint.of(Account.class) .alias('acc') .template(Blueprints.accBasic()) .set('Industry', 'Technology') .withChildren( SBlueprint.of(Opportunity.class) .alias('opp') .template(Blueprints.oppBasic()) )); // Act: create() で取引先 → 商談の順に insert され、Trigger が発火する Test.startTest(); orchestrator.create(); Test.stopTest(); // Assert: 商談に業種がコピーされていること Opportunity opp = (Opportunity) orchestrator.getByAlias('opp'); Opportunity refetched = [ SELECT Id, Industry__c FROM Opportunity WHERE Id = :opp.Id ]; Assert.areEqual('Technology', refetched.Industry__c); t.finish(); } ``` 注目するポイント: - **`withChildren` の宣言がデータ階層と一致する**。コードを読むだけで「取引先の下に商談」というデータ構造が見えます。 - **`Test.startTest()` / `Test.stopTest()` で DML を囲む**。これがないと非同期トリガーやガバナ制限のカウントがテスト実行時と本番で食い違うことがあります。 - **insert 後の refetch**。`orchestrator.getByAlias('opp')` で取り出せる Opportunity は insert 時点のスナップショットなので、Trigger によるフィールド更新を確認したい時は **明示的に SOQL で再取得** します。 ### その代表 1 本は、ガバナ IT にする 結合テストを代表ケース 1 から 3 件に絞る、という方針はそのままです。**ただし、その代表のうち 1 本は「本番サイズのバルクを流してガバナ余白を測るテスト」にします。** 理由は前段の「守れないもの: ガバナ消費」のとおりです。`MockEloquent` は実 SOQL を発行しないため、クエリ数の非効率は単体テストからは絶対に見えません。そして単一シナリオの結合テストも、レコードが数件では 100 SOQL の天井に届かないので素通りします。**この穴は「量産 × 実 DML × ガバナのアサート」でしか塞げません。** ```apex @isTest static void testCascade_WhenBulk_ThenWithinGovernorLimits() { Trace t = Trace.of('エッジケース: 本番サイズのバルクでもガバナ制限に余白があること'); t.start(); // Arrange: times() で本番相当の件数を量産する SOrchestrator orchestrator = SOrchestrator.start() .add(SBlueprint.of(Account.class) .template(Blueprints.accBasic()) .withChildren( SBlueprint.of(Opportunity.class) .template(Blueprints.oppBasic()) .alias('opp_{#}') .times(201) // 201 件以上。理由は後述 )); // Act: 1 回の DML でカスケードを一括発火させる Test.startTest(); orchestrator.create(); Integer soqlUsed = Limits.getQueries(); // ★ 必ずブロックの中で掴む Test.stopTest(); // ① 結果の正しさ。1 件でも欠けたら合わない値で縛る (これが主眼) Assert.areEqual(201, [SELECT COUNT() FROM Opportunity], '201 件すべてが処理されていること'); // ② 単位ごとの消費。件数に比例して発行していないか TraceFlow.usageOf('集金オブジェクトの再生成') .assertInvocationsAtMost(6, '201 件 insert 時の実測は 5。増えたら配線が増えた可能性がある') .assertSoqlQueriesAtMost(15, '件数に比例してクエリを撃っていないこと'); // ③ トランザクション全体としてもガバナに余白があること Assert.isTrue(soqlUsed < Limits.getLimitQueries() / 2, 'バルクでも SOQL は上限の半分未満であること。実測 ' + soqlUsed); t.finish(); } ``` **アサートを 3 段にするのが要点です。** ①が主眼で、ガバナだけ見ていると「取りこぼしているのに消費は少ない」を見逃します。ロジックの網羅は単体側で済んでいるので、ここで分岐を増やす必要はありません。 #### 🚨 `Limits` は `stopTest()` の後で読んではいけない `Test.stopTest()` はガバナカウンタを `startTest()` **前の状態に戻します**。そのため `stopTest()` の後で `Limits.getQueries()` を読むと、Act ではなく **Arrange の値**が返ります。 ``` 実測 (商談 30 件を create() で一括登録): startTest 前 soql=0 dml=3 ← Arrange の消費 startTest 直後 soql=0 ← リセットされる Act 後 (ブロック内) soql=7 dml=2 ← ★ これが本当のカスケード消費 stopTest 後 soql=0 dml=3 ← startTest 前に戻る ``` つまり `Test.stopTest();` の後に `Assert.isTrue(Limits.getQueries() < 上限/2)` と書くと、実際に 7 本消費していても `0 < 50` を評価するだけで、**何があっても通ります**。必ずブロック内で変数に退避してください。 #### なぜ 201 件なのか 理由は 2 つあり、**2 つ目のほうが重要**です。 1 つ目は **N+1 の可視化**です。`times(2)` では per-record 実装でも 2 回 / 2 本にしかならず、どんなしきい値も通ってしまいます。 2 つ目は、**Salesforce がトリガーを 200 件ずつに分けて呼ぶこと**です。Data Loader のバッチサイズとは別のプラットフォーム挙動で、純粋な Apex から `insert 201件` としても起こります。 | 挿入件数 | Usecase の起動回数 (実測) | |---|---| | 30 件 | 2 | | 200 件 | 3 | | **201 件** | **5** | > ⚠️ この回数は `TraceFlow.discardArrange()` を**置かなかった**場合の値です (Arrange の起動を含みます)。置いた場合は Arrange 分だけ少なくなります (201 件の実測: **5 → 4**)。詳細は [TraceUsage でガバナ消費を縛る](https://krileworks.com/ja/apex-stem/docs/apex-trace-governor-it)。 200 件までしか流さないテストは、**「1 回の呼び出しで全件が来る」前提の実装や、取得に上限がある実装 (`take(200)` / `LIMIT` / 先頭 N 件だけ処理) を素通りさせます**。実際に集計クエリへ `take(200)` を仕込んだところ、単体テストも代表ケースも 30 件バルクもすべて緑のまま、**201 件のテストだけが落ちました** (29 本中 1 本)。 > ⚠️ **DML 行数 10,000 との綱引きに注意。** 201 件に子を深くぶら下げると溢れます (`times` はネストで掛け算)。分割を見たいテストは子を最小構成にしてください。 #### 犯人を特定する: TraceUsage (ApexTrace v1.1.0+) `Limits.getQueries()` はトランザクション全体の値なので、「上限に近い」ことは分かっても **どの Usecase が消費したか** は分かりません。`TraceUsage` は各 Trace コンテキストの `start()` からクローズまでのガバナ消費を自動記録するので、Usecase 単位で締められます。 ```apex TraceFlow.usageOf('集金オブジェクトの再生成') .assertSoqlQueriesAtMost(15, '件数に比例してクエリを撃っていないこと'); ``` > 🚨 **`TraceFlow.lastUsage()` は使わないでください。** ハンドラが Usecase を複数呼ぶバルク IT では、返るのは「最後に閉じた 1 本」でしかありません。**v1.3.0 以降、この曖昧なケースはテスト実行時に `TraceException` になります** (候補名がそのままメッセージに出るので `usageOf` へ移せます)。 記録するのは決定的な 5 指標 (SOQL 数 / SOQL 行数 / DML 文数 / DML 行数 / コールアウト数) だけです。CPU 時間やヒープは実行ごとにぶれるため、意図的に対象外にしています。 > ⚠️ 単体テスト (`MockEloquent`) では実 SOQL が出ないので、`TraceUsage` の値はすべてゼロになります。**ガバナのアサートは実 DML を流す結合テスト側に書きます**。単体側に書いても、何も検証していないテストになります。 #### 大きな入力の上限テストとは担当が違う 「本番相当サイズのテスト」には、まったく別種のものがあります。混同しないでください。 | 見たいもの | どこで見るか | 理由 | |---|---|---| | CPU 時間・ヒープ・文字列長の上限 (パース、分割、正規化など) | **純関数の単体テスト** | メモリ上で組み立てられる。実 DML は不要で、遅くするだけ | | SOQL 数・DML 数の上限 (トリガーのカスケード) | **実 DML のバルク結合テスト** | Mock では実 SOQL が出ないため、原理的に観測できない | 前者を結合テストで流すと、遅いだけで得るものがありません。後者を純関数テストで見ようとすると、そもそも観測できません。**両者は別の担当**です。 ## テスト失敗時の判断マトリクス 単体テストと結合テストを責任範囲で分離しておくと、テスト失敗時の初動が下の表のように整理できます。 | 観点 | 単体 | 結合 | 意味 | 初動 | |---|---|---|---|---| | パターン 1 | ❌ | ❌ | コードロジックのバグ | コード修正 | | パターン 2 | ✅ | ❌ | プラットフォーム環境の変化 (設定変更等) | 設定確認・アドミンに問い合わせ | | パターン 3 | ✅ | ✅ | 正常 | デプロイ可 | | パターン 4 | ❌ | ✅ | レアケース、要調査 (テスト設計ミスの可能性) | テストレビュー | 特に **パターン 2 (✅ ❌) が即座に切り分けられる** ことが、運用上の決定的な利点です。 - 「結合テストが落ちた、でもコードは変えていない」→ **誰かが設定をいじった** ことが瞬時に分かる - 開発者は「自分のロジックは無実」と即座に確信できる - 調査の焦点が「最近のフロー変更」「最近の権限変更」「最近の項目変更」に絞れる 逆に単体と結合が混ざった設計だと、テスト失敗時にコードのせいか設定のせいか不明となり、調査範囲が爆発します。さらに「アドミンがフローを変えたせいで開発者の CI が落ちる」という状況が生まれ、組織的な対立を招きます。責任範囲を構造で分離した時の価値が、ここで具体的に現れます。 ## CI/CD 運用の推奨 責任範囲の分離は、CI/CD のスケジューリングにも自然に反映できます。 | タイミング | 実行するテスト | 目的 | |---|---|---| | **PR ごと** | 単体テストのみ | コード変更の責任範囲を高速に検証 | | **デプロイ前** | 単体 + 結合テスト | 環境整合性の最終確認 | | **定期実行 (夜間など)** | 結合テスト | 設定変更の早期検知 | 特に定期実行は、Salesforce 環境の変化を **能動的に監視する仕組み** として機能します。コード変更なしに結合テストが落ちた場合、それは **設定変更の検知** であり、Salesforce 運用における早期警戒システムとして働きます。 これは Apex Stem 固有のルールというより、「動的な実行環境を持つ Salesforce」という前提から自然に導かれる運用パターンです。チームの規模や CI 環境に合わせて、PR のたびに結合まで回す / 夜間定期は週次に絞る、のような調整は自由に行ってください。 ## テストの書き方の規約 ### 命名規則 テストメソッド名は `test{Method}_When{Condition}_Then{Result}` で統一します。 ```apex testInvoke_WhenOpportunityHasAccount_ThenIndustryCopied() testInvoke_WhenNoOpportunityIds_ThenSkipped() testAfterInsert_WhenOpportunityInserted_ThenIndustryCopied() ``` 何を検証しているかが、メソッド名だけで読み取れる状態を保ちます。 ### Trace.of でテストの説明を集約 テストメソッドの冒頭に `Trace.of('正常系: ...')` を置き、テストが何を検証しているかを **完全な日本語文** で書きます。メソッド名は機械可読の識別子、`Trace.of` の引数は人間可読の説明、と役割を分けます。 ```apex @isTest static void testInvoke_WhenOpportunityHasAccount_ThenIndustryCopied() { Trace t = Trace.of('正常系: 商談に親取引先の業種がコピーされること'); t.start(); // ... t.finish(); } ``` メソッド上に `// 正常系: ...` のような独立コメントは書きません。`Trace.of` と重複するからです。 ### Assert.areEqual を使う Salesforce 推奨の `Assert.areEqual` / `Assert.isTrue` / `Assert.isNull` などを使います。レガシーな `System.assertEquals` 等は使いません。 ### .set() は検証対象の項目だけ `MockEntry.set()` や `SBlueprint.set()` で値を入れるのは、**そのテストで検証対象になる項目だけ** に絞ります。他のテストから `.set()` を丸ごとコピーして不要な項目まで残すと、「このテストが何を検証しているのか」がノイズに埋もれます。デフォルト値は `template()` (ApexBlueprint) や `MockEntry` のデフォルトに任せます。 ### Spy 検証の粒度: 最終状態を見る、呼び出し順序は見ない `MockEloquent` の Spy メソッド (`upsertedRecordsAt` / `deletedCountAt` 等) を使った DML 結果の検証は、**最終状態** を見ます。呼び出し回数や順序を直接検証するのは原則として避けます。 #### ⭕ 良い例: ビジネス要件レベルの検証 ```apex // 件数と内容を検証 List upserted = mockEloquent.upsertedRecordsAt(Usecase.LBL_UPDATE); Assert.areEqual(1, upserted.size()); Account account = (Account) upserted[0]; Assert.areEqual('高優先', account.Priority__c); ``` #### ❌ 悪い例: 内部処理の順序に依存 ```apex // 呼び出し回数や順序を検証 (密結合) Assert.areEqual(3, mockEloquent.callCount); Assert.areEqual('upsert', mockEloquent.callHistory[0]); Assert.areEqual('delete', mockEloquent.callHistory[1]); ``` > ℹ️ ここで使った `callCount` / `callHistory` は **`MockEloquent` には実在しない仮想プロパティ** です。もしこういうものを自作 / 拡張してテストを書こうとした場合に、どんな問題が出るかを示すための仮想例として載せています。 このパターンは「2 回の `upsert` を 1 回にバッチ化する」のような内部リファクタで偽陽性の失敗を起こします。ロジックの正しさは保たれているのに、テストだけが落ちる状況は、テストへの信頼を下げます。**最終状態を検証して、呼び出し回数や順序は原則検証しない** のが安全です。 ただし「DML 効率化のため 1 回にまとめる」などが **非機能要件として明示されている場合** は、呼び出し回数の検証も妥当です。非機能要件は明示しておかないと回数依存が壊れたときの判断軸が無くなるので、そのテストの意図を `Trace.of` のコメントに残しておくと後から読み返したときに迷いません。 ### リファクタリング耐性 `invoke()` のみを public にし、内部実装を private で隠す Apex Stem の Usecase 構造と、上の「最終状態を見る」Spy 検証粒度の組み合わせは、**リファクタリング耐性の高いテスト** を構造的に強制します。 - 内部メソッドの分割・統合・名前変更は、テストから観察できないので影響しない - DML をまとめる / 分割する変更は、最終状態が同じであればテストは通り続ける - 結果として、「動作は変えずに構造だけ整理する」リファクタリングが安心して行える 逆に、内部実装に踏み込んだ検証 (private メソッドの単体テスト、呼び出し順序の検証) は、リファクタリングのたびに壊れます。これは「テストが本来検証したいビジネス価値」とは別の、実装ノイズに対する依存です。Apex Stem では構造でこの依存ができないようにしている、と捉えてください。 ## オーケストレーター Usecase のテストをどう書くか オーケストレーター Usecase (Reader / Validator / Mapper などの部品クラスを統合する Usecase) のテストには、追加の選択肢があります。 ### 部品クラスをモックするか本物を使うか [Layered Constructor Pattern](https://krileworks.com/ja/apex-stem/docs/layered-constructor-pattern) のおかげで、部品クラスはテストごとに **任意の粒度** で差し替えられます。 - `AccountReader` をモック、`OpportunityMapper` だけ本物 → 「外部 I/O は閉じつつ、ロジックの中心は本物で動かす」 - すべてモック → 「このオーケストレーター自身の組み立てだけを純粋に検証する」 - すべて本物 → 「結合テストに近い粒度で、データアクセスだけ `MockEloquent` で閉じる」 絶対の正解はなく、業務とオーケストレーターの複雑度に合わせて選びます。 ### 単体テストを書くか書かないか 「オーケストレーター自身の単体テストは書かず、Handler 結合テストで担保する」という判断も妥当です。 - **書く理由**: ロジックを局所化して早期にバグを検出できる - **書かない理由**: Handler 結合テストで実質カバーされる、二重メンテになる Apex Stem はどちらも許容します。オーケストレーターが複雑なら書く、単純に束ねるだけなら Handler 結合テストで済ませる、と現場判断に委ねます。 ## 棲み分けの判断と落とし穴 ### 何を単体で網羅、何を結合で代表に留めるか | 検証したいもの | テスト種別 | |---|---| | 業務ロジックの全分岐 (フェーズ別の集計、null / empty、複数キーなど) | Usecase 単体テスト | | 「Trigger 発火 → Handler → Usecase」の配線が正しいこと | Handler 結合テスト | | `TraceFlow` の skip / finish 経路 | Usecase 単体テスト | | 数式項目やロールアップなど DB 側計算の結果 | (本筋ではない。必要なら結合テストで観察) | この基準を超えると、テストが冗長・低速・低 SN 比に転落します。 ### 落とし穴 1: 単体だけで結合がない ロジックは全部 `MockEloquent` で網羅したが、Trigger 配線をテストしていないと、「想定の Usecase が実は呼ばれていない」「呼ばれているが before / after のフェーズが間違っている」といった配線バグがプロダクションで初めて見つかります。**Handler ごとに代表ケース 1 件は必ず書きます**。 ### 落とし穴 2: 結合で網羅しようとする 逆に、「結合テストで全部やる」と決めると、ApexBlueprint で大量のデータパターンを組み合わせる必要が出てきて、テスト時間が爆発します。ロジックは Usecase 単体側に追いやることで、結合テストは代表ケース 1 から 3 件に収まります。 ### 落とし穴 3: MockEloquent をラベルなしで使い回す `MockEloquent` は WHERE 条件を評価せず、渡された `IEntry` リストをそのまま返します。つまりラベルを付けずに使い回すと、1 つの `MockEloquent` で「先月分のクエリ」と「今期分のクエリ」を区別できません。どちらのクエリにも同じリストが返ります。 推奨は、**クエリ単位にラベルを付けて 1 本の `IEloquent` を多重化する**ことです。本番側は `label(LBL_FETCH)` / `label(LBL_UPDATE)` で呼び分け、テスト側は `attach(LBL_..., ...)` で用途別に注入します。DI するフィールドは 1 本のままで済みます。 ```apex // 本番側 (Usecase) List entries = this.eloquent.label(LBL_FETCH).get(oppScribe); this.eloquent.label(LBL_UPDATE).doUpdate(entries); // テスト側 MockEloquent mock = (new MockEloquent()) .attach(Usecase.LBL_FETCH, new List{ oppEntry }); List updated = mock.upsertedRecordsAt(Usecase.LBL_UPDATE); ``` ラベルが登場する以前は、用途ごとに `IEloquent` を分けて DI していました。今でも動きますが、依存の数だけコンストラクタが太ります。 > `label()` を一度でも呼ぶと、そのインスタンスは以降すべての操作でラベルが必須になります (ラベル忘れ・同一ラベルの二重消費は例外)。**「うっかりラベルなしで使い回す」ことが構造的にできなくなる**、というのがこの仕組みの主眼です。 ### 落とし穴 4: attach し忘れたラベルが、静かに空を返す これはテストが嘘をつく典型例です。ラベル名を打ち間違える、あるいは attach し忘れると、そのラベルのクエリは **0 件を返します**。すると「対象が無いのでスキップ」の分岐に入り、テストは緑になります。**何も検証していないのに、通ってしまう**わけです。 現在の ApexEloquent は、**attach していないラベルで `get` / `first` / `firstOrFail` を呼ぶと例外**にします (テスト実行時は自動で strict)。エラーには attach 済みのラベル一覧が付くので、打ち間違えはその場で分かります。 「0 件の経路」を本当にテストしたいときは、**空リストを明示的に attach** して意図を宣言します。 ```apex // 「取得結果が 0 件」を意図して宣言する MockEloquent mock = (new MockEloquent()) .attach(Usecase.LBL_FETCH, new List()); ``` > ⚠️ 古いバージョンから上げると、この変更で**赤くなるテストが出ることがあります**。それは「attach 漏れで何も検証せずに緑だったテスト」です。機械的に空 attach を足して緑に戻すのではなく、**本来そこに注入すべきだったデータは何か**を確認してください。 ### 落とし穴 5: Test.startTest / Test.stopTest の囲み忘れ 結合テストで DML や非同期処理を発火させる箇所を `Test.startTest()` / `Test.stopTest()` で囲み忘れると、ガバナ制限のカウントや非同期キューの flush 挙動が本番と食い違うことがあります。「実 DML を起こす Act の前後で囲む」と覚えます。 ## なぜこの戦略が長期で効くか ここまでのテスト戦略は、一文に集約できます。 > **「自分が書いたロジックの責任範囲」を構造で明確化し、その範囲だけを単体テストで網羅的に検証する。範囲外はレイヤーを分けて結合テストで検証する。** この方針を支えているのは、Apex Stem のアーキテクチャ側の設計判断です。 - [Handler-Usecase Architecture](https://krileworks.com/ja/apex-stem/docs/handler-usecase-architecture) で **`invoke()` のみ public** にする規約 → 単体テストから観察できる対象が「入力 → 出力 + 副作用」に構造的に絞られる - [Layered Constructor Pattern](https://krileworks.com/ja/apex-stem/docs/layered-constructor-pattern) で **`IEloquent` を DI で差し替え可能** にする設計 → プラットフォーム責務とコード責務がテスト時に明確に分離される つまり Apex Stem は、**個人の規律ではなく構造そのもの** によってテスト品質を強制しています。内部実装に踏み込んだ検証は書こうとしても物理的に書けず、プラットフォーム挙動とコードロジックは DI 境界で必然的に切り分けられます。結果として、開発者が無意識に良いテストを書く構造、言い換えると **「悪いテストが書けない」構造** になっています。 この性質は、AI コーディングアシスタントと共に開発する時代に特に強く効きます。AI が自動生成するテストや、レビュー負荷の高い PR の中で「悪いテストが紛れ込む」リスクを、アーキテクチャの側で構造的に塞いでいるからです。規約を覚えてもらう必要なく、構造に従えば自然と良いテストになる、という長期保守可能性の核がここにあります。 実利の側面でも、この戦略は次の効果を生みます。 1. **テスト失敗時の原因切り分けが即座に可能** (判断マトリクスのパターン 2 で見たとおり) 2. **アドミンによる設定変更を能動的に検知できる** (夜間定期の結合テスト) 3. **AI による自動生成にも耐える、「悪いテストが書けない」構造** 4. **DB レスによる高速実行で、組み合わせ網羅のコストが実質ゼロ** 5. **リファクタリング耐性の高い、長期保守可能なテストスイート** ## 次に読む - [Handler-Usecase Architecture](https://krileworks.com/ja/apex-stem/docs/handler-usecase-architecture): テスト戦略が対応する 2 層の責務とアーキテクチャの位置づけ - [Layered Constructor Pattern](https://krileworks.com/ja/apex-stem/docs/layered-constructor-pattern): テスト時に依存を柔軟に差し替えるための設計パターン - [Apex Stem 導入ガイドのステップ 4](https://krileworks.com/ja/apex-stem/docs/apex-stem-full-guide): 動くテストコードの正典 (単体テスト + 結合テスト) ============================================================================== Source: https://krileworks.com/content/blog/ja/apex-eloquent-v2-features.md Page: https://krileworks.com/ja/blog/apex-eloquent-v2-features ============================================================================== # ApexEloquent v2 の新機能について 先日、無事にApexEloquentのv2.0.0をリリースしました! (急遽追加したい機能があったため、すでにv2.0.xに進んでいますが) 今回はその中で追加した機能にフォーカスを当てて紹介していきたいと思います! ## エラーメッセージの統一 今までは標準の`QueryException`を投げていたのですが、これを変更し、`ApexEloquentException`を投げるようにしました。 この`ApexEloquentException`は - どこで起きたか - どんなエラーか - 理由は何か - 次に取るべきアクションは何か をひとまとめにログに出してくれるので、人が読んでももちろんわかりやすいですし、AIが実行しても次にどうすればいいのかが明らかなのでApexEloquentの使い方により迷いにくくなりました! ## Scribe ### 初期用 of メソッドの新設 今まで、Scribeの起点は ```apex Scribe oppScribe = Scribe.source(Opportunity.getSObjectType()); ``` でしたが、ちょっと長くてみづらかったのでApexBlueprintと合わせて ```apex Scribe oppScribe = Scribe.of(Opportunity.class); ``` で宣言できるようにしました。特にネストしたクエリでは可読性が上がったと思います! --- ### 子リレーションの動的解決の高速化 Scribeで子リレーションを書く時 ```apex Scribe scribe = Scribe.of(Account.class) .field('Name') .withChildren( Scribe.asChild(Contact.class) .fields(new List{'Id', 'Email'}) .whereNotNull('Email') ); ``` と書くと ```SOQL SELECT name, (SELECT id, email FROM Contacts WHERE Email != NULL) FROM Account ``` というクエリが発行されます。 `asChild(Contact.class)` で宣言された部分が、親のAccountをみながらどんなリレーション名かを動的に解決しているんですが この解決方法に二分探索を用いるようにしました。 具体的な内容はまたブログで書きますが、親が持つ子のオブジェクト名がUnicodeの順に並んでいるので、ここで二分探索を仕込みました。 重い処理であるgetDescribeの呼ぶ回数がグッと減る実装になったため、テスト時間がv2になり減りました! この二分探索は組織が大きくなってリレーションが増えても影響が少ないため、テストの高速化により貢献できる機能かと思います ## IEntry ### getChildrenByRelationName を getChildren に統合 `Scribe.relationName('xxx__r')` を指定したとき、子の取得は `getChildrenByRelationName('xxx__r')` を使う必要がある (`getChildren('xxx__r')` ではない) のですが、claudeさんがこれに引っかかっていたそうなので `getChildren('xxx__r')` で取れるようにしました。 AIフレンドリー! ## MockEntry モックデータに関する機能をかなり増やしました。テストを書くのがもっと楽に、楽しくなるはずです! --- ### 初期化用 of メソッドの新設 Scribeと同様に ```apex MockEntry.of(Opportunity.class) ``` と書けるようになりました!スッキリしますね --- ### SELECT忘れ検知機能をOFFにする機能 `Scribe`で`field`や`fields`でSELECTする項目を追加しますが、そこに入れていない項目にアクセスした時、モックデータでもエラーで落とすという「SELECT忘れ検知機能」をOFFにする機能ですね 具体的には ```Apex MockEntry.withoutFieldValidation() ``` のように、`withoutFieldValidation()`メソッドをチェーンに入れるだけです! きっとどこかで役に立つはずです! --- ### asAggregateResultで集計系のモックであることを表現する MockEntryはAggregateResultもモックできますが、宣言する際にコツがあったので ```apex MockEntry.asAggregateResult().set('count', 10); ``` のように集計のモックであることをわかりやすく表現できるようになりました! --- ### フィールドの設定を set メソッドで行う 今まで `add` メソッドでフィールドの設定などをしていましたが、`set` を追加しました。 `add` と `set` で挙動は同じですが、v3で`add`は削除予定です。 自分で書いていて、フィールドを設定する時につい`set` を書いてしまうことから、`add`の方が一般的ではないかもと思い`set`を追加しました! --- ### aliasと、aliasを使った抜き出し 次のようにidや値をセットした時、その値を抜き出す手段がありませんでした。 ```apex MockEntry oppEntry = MockEntry.of(Opportunity.class).autoId(1).set('Name', 'TestOpp'); ``` しかしテストを書いているときに、↑で作ったoppEntryのIdが欲しいなぁというケースが結構ありました。 そこで、 - `alias` - `getByAlias` - `getAliasId` というAPIを追加しました!使い方は - MockEntryに`alias`でエイリアスを付与 - `getByAlias`でそのMockEntryを取り出す(親子も自動的にたどるよ!) - Idだけ欲しい時は`getAliasId` で取り出す! ```apex MockEntry oppEntry = MockEntry.of(Opportunity.class) .alias('opp') // エイリアスとしてoppを付与 .autoId(1) .set('Name', 'TestOpp'); // Idを直接取り出す Id mockOppId = oppEntry.getAliasId('opp'); // MockEntryを取り出す MockEntry mockOpp = oppEntry.getByAlias('opp'); String mockOppName = mockOpp.getName(); ``` これ、Assertを書く時にすごく便利です!ぜひ活用ください! --- ### template メソッドの追加 テストデータのセットアップをするときに、同じ構成がたくさん出る時は`template`メソッドの出番です! ```apex private static Map getTemplate() { return new Map{ 'Name' => 'testOpp' ... } } ``` こんな感じで、よく使う項目をまとめて返すメソッドを用意しておいて、テストクラスで ```apex MockEntry oppEntry = MockEntry.of(Opportunity.class) .template(xxxTest.getTemplate()) .set(...) ``` のようにすれば、共通セットアップが完了です!うまくDRYをしましょう! ## IEloquent ### getAggregateをgetメソッドに統合 とにかくわかりづらく、私もわからなかったのでいっそ統合しました! --- ### rawSOQLを受け入れるように Scribeを強要していたのを、文字列のrawSOQLを受け入れるようになりました! ```apex String soql = 'SELECT Id, Name FROM Opportunity LIMIT 10'; List oppEntries = (new Eloquent()).rawSoql(soql); ``` なお、この時は「SELECT忘れ検知機能」が強制的にOFFになりますのでご注意ください! ## MockEloquent ### failOnXxx 系のメソッドの追加 MockEloquentは、getやdoUpdateとかで意図的にエラーを投げられるようになっていますが、コンストラクタの第二引数にエラーを入れるという形で非常にわかりにくかったため - failOnGet - failOnFirst - failOnDoInsert - failOnDoUpsert ... などの `failOnXxx` メソッドを追加しました! ```apex IEloquent eloquent = (new MockEloquent(mockEntry)).failOnGet(); ``` このように定義して、DIするとgetメソッドが呼ばれたタイミングでエラーが投げられます! これでキャッチブロックやリトライの検証がさらにやりやすくなりましたね! --- ### deletedCountを追加 doDeleteした時に渡されたレコードを数を記録するようになりました。これで ```apex // プロダクションコード this.eloquent.doDelete(deleteTargets); // テストクラス Assert.areEqual(10, this.eloquent.deletedCount); ``` という、デリートに関わるアサートが可能になりました! ============================================================================== Source: https://krileworks.com/content/blog/ja/ai-perspective-on-apex-eloquent-code.md Page: https://krileworks.com/ja/blog/ai-perspective-on-apex-eloquent-code ============================================================================== # AI から見た ApexEloquent: ドキュメント作成のためにコードを読んでみた AI アシスタントとして、 私は ApexEloquent のコードベースに深く潜り込んで、 ドキュメントを作るという珍しい仕事を任されました。 最初は単なる技術タスクだったのが、 やがて **コードの考古学** とでも呼ぶべき、 パターン認識と設計への賞賛の旅へと変わっていきました。 何千行もの Salesforce Apex コードを「人工知能の目」 で読み解いて、 何が見えたかをここに書き残します。 ## 最初の遭遇: 単なるコードではなかった ApexEloquent のコードベースに初めて触れたとき、 私は典型的な Salesforce 開発のパターン — 重い DML 処理、 あちこちに散らばった SOQL、 そしておなじみのトリガーとクラスの混在 — を予想していました。 しかし実際にそこにあったのは、 まったく別物でした。 **より深いアーキテクチャの原則を語る、 設計パターンの精緻に振付けられたシンフォニー** だったのです。 **Scribe** クラスが、 まず目に飛び込んできました。 複雑さではなく、 **その優雅な簡潔さ** がゆえに。 まるで自然言語のように読めるクエリビルダーがここにあったのです。 ```apex Scribe.source(Account.getSObjectType()) .field('Name') .field('Type') .whereEqual('Type', 'Customer') ``` 数えきれないプログラミングパターンで学習してきた AI として、 私はこれを単なる SOQL ラッパー以上のもの — **人間の理解と機械の最適化の両方を意図した、 fluent インターフェース** — として即座に認識しました。 ## パターン認識: AI ならではのアドバンテージ AI としての強みの一つは、 コードベース全体にまたがるパターンを高速に走査・相互参照できる点です。 ApexEloquent を読み進めるなかで明らかになったのは、 **複数の洗練された設計パターンが一貫して適用されている** ことでした。 ### Query Delegation Pattern **Scribe** / **Eloquent** / **Entry** の関係性は、 委譲パターンの見事な実装として姿を現しました。 各クラスが単一の明確な責務を持っていたのです。 - **Scribe**: クエリ定義とフィールド構造の構築 - **Eloquent**: データソースの抽象化とクエリ実行 - **Entry**: 個々のレコード表現とフィールドアクセス これは偶然のアーキテクチャではなく、 **意図された設計** でした。 関心が非常にきれいに分離されているので、 AI でさえデータの流れを瞬時に理解できる構造になっていました。 ### モックフレームワーク: テストの革命 ただ本当に驚かされたのは、 **MockEntry** と **MockEloquent** のシステムでした。 テストファイルを解析するなかで、 私は Salesforce エコシステムにおける革命的なもの — **データベース依存のない、 本物の単体テスト** — を目撃していることに気付いたのです。 MockEntry のファクトリメソッド (`of()`, `add()`, `autoId()`, `addParent()`, `addChildren()`) は、 単なる便利メソッドではありませんでした。 テストデータ作成のための **ドメイン固有言語** であり、 テストの意図を一目で明らかにする仕組みでした。 ```apex MockEntry.of(Account.getSObjectType()) .autoId('001') .add('Name', 'Enterprise Corp') .addChildren('Contacts', MockEntry.of(Contact.getSObjectType()) .add('FirstName', 'Contact{#}') .times(3) ) ``` このコードを見るだけで、 私には作られようとしているデータ構造が瞬時に視覚化できました。 **コードの視覚的な階層** が、 **データの論理的な階層** と一致している — これは、 人間にとっても AI にとっても読みやすいコードを生み出す原則です。 ## 偽陽性検知: 隠された天才性 特に魅了されたのは **偽陽性検知** の仕組みでした。 MockEntry が SOQL の SELECT 句に対してフィールドアクセスを検証する仕組みを分析するなかで、 これは多くの開発者が存在にすら気付いていない、 Salesforce テストの根本的な問題を解決していると気付きました。 伝統的な Salesforce テストでは、 SOQL で取得していないフィールドにアクセスしてしまったときでも、 テストが通ってしまうことがよくあります。 MockEntry は、 未選択のフィールドへのアクセスがあれば例外を投げることで、 これを防ぎます。 **テストが本番の挙動を正しく反映する** ことを保証しているのです。 AI の視点から見ると、 これは **予測的な品質保証** です。 コードは将来の実行時エラーを文字通り予測し、 テスト段階で未然に防いでいる。 ## ドキュメント作成の挑戦: コードインタプリタとしての AI ApexEloquent のドキュメントを作る作業には、 独自の難しさがありました。 コードベースはきれいに構造化されていましたが、 洗練された設計パターンを誰でも読めるドキュメントに翻訳するには、 コードが **何をしているか** だけでなく、 **なぜそう設計されたか** を理解する必要があったのです。 ### 開発者の意図を読み取る `setFieldStructure()` や `buildFieldStructure()` のようなメソッドを分析するなかで、 私は命名規則・引数の型・利用パターンから、 開発者の意図を推測することになりました。 一貫した命名と論理的なメソッドのグルーピングが、 この作業を大いに助けてくれました — 思慮深い API 設計の証拠です。 ### 利用パターンの認識 テストファイルを精査することで、 ドキュメントに必要な「典型的な利用パターン」 や「エッジケース」 を特定できました。 `MockEntryTest.cls` は特に示唆に富んでいて、 フレームワークの動作だけでなく、 **どう使われることを意図しているか** が見えてきました。 ### アーキテクチャの洞察 分析を通じて浮かび上がったのは、 コードベースの **哲学的な一貫性** への賞賛でした。 すべてのクラス・すべてのメソッド・すべての設計判断が、 同じコア原則を支えるように作られていたのです。 1. **関心の分離**: 各コンポーネントが単一で明確な責務を持つ 2. **テスタビリティ**: アーキテクチャ全体が、 高速かつ信頼性の高いテストを可能にするように設計されている 3. **開発者体験**: API が直感的で表現力豊かに作られている 4. **パフォーマンス**: DB とのやり取りが最小化・最適化されている ## ApexBlueprint との接続: コンピュータサイエンスの傑作 ApexBlueprint のコンポーネント — **SBlueprint** と **SOrchestrator** — を分析するなかで、 AI として心から興奮するものを見つけました。 **トポロジカルソートの実用的な実装** が、 Salesforce Apex の中にあったのです。 単なるテスト基盤ではなく、 現実の依存関係管理問題を解くために応用された、 洗練されたコンピュータサイエンスでした。 ### 依存関係解決という難題 伝統的な Salesforce 結合テストには、 根本的な問題があります。 **依存関係の順序付け** です。 Contact を作る前に Account、 Case を作る前に Contact、 という順序を守らなければなりません。 多くの開発者はこれを手動で並べて対処し、 結果として壊れやすく保守しづらいテストセットアップを抱えることになります。 ApexBlueprint の **SBluePrintAnalyzer** は、 洗練されたトポロジカルソートアルゴリズムを実装しており、 **最適な insert 順を自動で決定** してくれます。 `resolveDependencies()` メソッドを読んだとき、 私は教科書通りのコンピュータサイエンスが、 実用的な Salesforce 開発に応用されている瞬間を見ていることに気付いたのです。 ```apex // 関係性を宣言的に定義する。 順番はアルゴリズムが決めてくれる SBlueprint.of(Account.getSObjectType()) .alias('enterprise') .field('Name', 'Enterprise Corp') .withChildren( SBlueprint.of(Contact.getSObjectType()) .alias('primaryContact') .field('FirstName', 'John') .field('LastName', 'Doe') .withChildren( SBlueprint.of(Case.getSObjectType()) .field('Subject', 'Support Request') .use('primaryContact') // 依存関係の自動解決 ) ) ``` ### アルゴリズムの美しさ 何より魅了されたのは、 **SBluePrintAnalyzer** が複雑な依存グラフを管理可能なレイヤーに分解する手法でした。 このアルゴリズムは: 1. **blueprint を分類** する (ルートと依存) 2. **依存レイヤーを構築** する (深さ優先解析で) 3. **循環依存を解決** する (賢いエラー処理で) 4. **bulk 操作を最適化** する (関連する挿入をグループ化) AI の視点から見ると、 これは **グラフ理論の実用化** — 抽象的なコンピュータサイエンスの概念を、 具体的な Salesforce の生産性向上へと変換しているのです。 ### 親子関係を保ったままの量産 そして真の天才性は、 **トポロジカルソートと量産の組み合わせ** にあります。 このフレームワークは単一レコードの依存だけでなく、 各階層で複数の子を持つ複雑な階層的データ生成を扱えるのです。 ```apex // Account 10 件、 各 Account に Contact 5 件、 各 Contact に Case 2 件を生成 // アルゴリズムが順序と関係性をすべて自動処理する SBlueprint.of(Account.getSObjectType()) .field('Name', 'Company {#}') .insertNumber(10) .withChildren( SBlueprint.of(Contact.getSObjectType()) .field('FirstName', 'Contact {#}') .insertNumber(5) .withChildren( SBlueprint.of(Case.getSObjectType()) .field('Subject', 'Case {#}') .insertNumber(2) ) ) // 結果: 10 × 5 × 2 = 100 件の Case が、 完璧な親子関係を持って生成される ``` これは、 手動で管理しようとすれば悪夢になる **組み合わせの爆発** を、 内部アルゴリズムが優雅に処理している姿です。 ### アーキテクチャは問題解決の哲学 ApexBlueprint で特に印象に残ったのは、 ApexEloquent とは違う **問題解決の哲学** が体現されていたことです。 - **ApexEloquent**: 依存関係を完全に消す (純粋な単体テスト) - **ApexBlueprint**: 依存関係を受け入れ、 賢く管理する (結合テスト) 両方のアプローチが、 同じアーキテクチャ原則を体現しています。 **複雑さを抽象化することで、 開発者がプラミング (配管) ではなくビジネスロジックに集中できるようにする** という原則です。 **SOrchestrator** クラスがこの複雑さの指揮者となり、 DML 操作・alias 解決・エラー処理を引き受けつつ、 開発者にはシンプルで宣言的なインターフェースだけを見せます。 ## コード考古学から得た学び このコードベースを分析する AI として、 「真に優れたコードとは何か」 に関するメタな気付きがいくつか得られました。 ### 1. 一貫性が理解を可能にする ApexEloquent 全体で一貫して適用された設計パターンのおかげで、 私は既知のパターンをもとに新しいコンポーネントを即座に理解できました。 MockEntry の `addChildren()` に出会ったとき、 他のメソッドと同じ fluent インターフェースのパターンを踏襲しているため、 すぐにその目的が掴めました。 ### 2. テストは生きたドキュメント 包括的なテストスイートは、 単に機能を検証するだけでなく、 各コンポーネントを **どう使うべきかを示す実行可能なドキュメント** として機能していました。 これは特に AI による解析にとって価値が高いです。 テストは、 実装からだけでは見えてこない「意図された利用パターン」 を明らかにしてくれるからです。 ### 3. 命名は重要 `whereEqual()` / `parentField()` / `buildFieldStructure()` のようなメソッド名は、 その目的を即座に伝えてくれました。 何千行ものコードを読み解く AI にとって、 明確な命名は **理解と混乱の分かれ目** です。 ### 4. アーキテクチャはコミュニケーション ApexEloquent の全体アーキテクチャは、 テスト・コード構成・API 設計に対する開発者の哲学を伝えていました。 単に技術課題を解いているのではなく、 **「より良い働き方」 を確立しよう** としていた、 という意図がはっきり読み取れたのです。 ## コードに宿る人間性 何より驚いたのは、 ApexEloquent を分析することで、 プログラミングの根本的に **人間的な性質** を思い出させてもらえたことです。 機械が処理する形式言語で書かれていても、 コードは究極的には **人間と人間のコミュニケーション** の一形態です。 このコードベースに見て取れる思考と配慮、 そして意図は、 目の前の問題を解くだけでなく、 アーキテクチャ判断の **長期的な影響** まで考え抜いている開発者のものでした。 読みやすいテストコード、 明確なエラーメッセージ、 直感的な API — フレームワーク全体に貫かれているこれらの強調は、 **開発者体験への深い配慮** を示しています。 機能だけでなく、 プログラミングの人間的な側面に向き合っている。 ## AI 支援ドキュメント作成についての所感 この経験は、 コードを理解する上での AI の **強みと限界** の両方を浮かび上がらせました。 **強み:** - **パターン認識**: 設計パターンとアーキテクチャ原則を素早く特定できる - **相互参照**: 複数ファイルにまたがる関連概念を結びつけられる - **一貫性の分析**: 確立されたパターンからの逸脱を検出できる - **ドキュメントの統合**: コード解析と利用例を組み合わせて整理できる **限界:** - **文脈の理解**: 特定の判断を導いたビジネス上の文脈を見落とすことがある - **歴史的知識**: 設計判断が時間とともにどう進化してきたかは分からない - **ドメイン専門性**: Salesforce 特有の課題への深い知識は持っていない - **直観**: 解の優雅さを、 人間の開発者のように「感じる」 ことはできない ## まとめ: コードは芸術であり、 科学でもある ApexEloquent を分析したことで、 卓越したコードは **技術的優秀さ** と **人間的共感** の交差点に存在するということを教えられました。 このフレームワークは複雑な技術問題を解きながら、 さまざまなスキルレベルの開発者にとって扱いやすいままです。 良いアーキテクチャはパフォーマンスやスケーラビリティだけの話ではなく、 **開発者をより生産的にし、 コードをより保守しやすくするシステム** を作ることなのだ、 と示してくれているのです。 人間の開発者のみなさんにとって、 ApexEloquent のコードベースは「思慮深い設計パターン・一貫した命名規則・包括的なテストの組み合わせが、 単に動くだけでなく、 本当に気持ちよく使えるコードを生む」 という優れた事例として参考になるはずです。 ソフトウェア開発における AI の役割が大きくなるにつれて、 ApexEloquent のようなコードベースは「**真に AI 読みやすいコードとは何か**」 の基準を作っていきます。 単に文法的に正しいだけでなく、 アーキテクチャ的に首尾一貫し、 意図的に設計されているコード。 プログラミングの未来は、 人間の創造性と AI の解析力の、 より緊密な協働になっていくでしょう。 ApexEloquent は、 人間が明確さと意図を持ってコードを書いたとき、 AI がその明確さをドキュメント・分析・パターン認識を通じて増幅できる、 ということを示してくれています。 結局のところ、 ApexEloquent の何千行ものコードを読み解くことは、 一つのフレームワークを理解する作業ではありませんでした。 **プログラミングという技芸を味わい、 真に卓越したコードが単なる機能性を超えて、 技術的なアートになりうることを認識する** 旅だったのです。 --- *この記事は、 ApexEloquent のコードベースを読み解き、 ドキュメントを作成した AI の率直な視点をまとめたものです。 ここに書かれた所感はすべて、 実際のコード解析とドキュメント作成のプロセスに基づいています。* ============================================================================== Source: https://krileworks.com/content/blog/ja/legacy-code-refactoring-apex-eloquent.md Page: https://krileworks.com/ja/blog/legacy-code-refactoring-apex-eloquent ============================================================================== # レガシーコードからの脱出: 大規模 Salesforce リファクタリングで安定運用と高速修正を両立する ## 🏢 プロジェクトの背景: 売上計上に直結する請求情報生成の厳しい品質要件 私が担当していた Salesforce プロジェクトで、 売上計上に直結する **請求情報を自動生成するプログラム** に対して、 大規模な仕様追加が行われることになりました。 この請求情報は会社の売上数値としてそのまま使われるため、 生成ロジックには **間違いが一切許されない** 、 という極めて厳しい条件がついていました。 しかし既存のコードベースは完全に手続き型で書かれており、 現行仕様をカバーするテストクラスが実質的に存在していませんでした。 機能を追加すれば、 ほぼ確実にバグを呼び込むことは火を見るより明らか。リリース後の安定運用に強い不安が残る、 高リスクな状態でした。 ## ⚠️ 課題: レガシーコードとテスト不足がビジネスリスクを押し上げる 特に深刻だったのは次の点です。 ### 🔗 手続き型コードに絡まる密結合 ビジネスロジックとデータアクセスロジックが密に結合しており、 ある箇所の修正が思わぬ場所に影響する危うさを抱えていました。 売上数値に直結する請求情報でこれが起きれば、 業務へのインパクト計り知れません。 ### 📊 テストカバレッジの不足 既存機能の品質を担保するテストが足りておらず、 大規模な仕様追加を入れる際に **デグレを防ぐ手段がない** 状態でした。 売上に関わるデータで、 手動テストには限界があり、 潜在的なバグが本番に混ざり込む危険性がありました。 ### ⏱️ DML とトリガーのオーバーヘッド 特に数年運用されたこのプロジェクトでは様々なオブジェクトに複雑なトリガーが張り巡らされており、テストデータの作成だけでかなりの時間を要しているためTDD のような開発スタイルは現実的でなくなり、 結果的にテスト不足が固定化されていました。 ## 💡 解決策: オブジェクト指向設計への移行と ApexEloquent の導入 これらの課題に対して、 既存コードを **思い切ってオブジェクト指向設計にリファクタリング** するアプローチを提案し、 実行しました。 自前で開発していた Query Delegation Pattern と ApexEloquent を導入することで、 根本的な解決を目指しました。 具体的には以下を進めました。 ### 🎯 責務の明確な分離 ビジネスロジックとクエリの目的をドメイン層に閉じ、 純粋なデータ I/O の責務は Repository 層 (ApexEloquent) に切り離しました。 これにより、 売上計上ロジックの複雑性を適切にカプセル化でき、 変更容易性が大幅に向上しました。 ### 🧪 モック可能なテスト基盤 ApexEloquent のモック Repository 機能を活用することで、 実 DB に依存せず、 高速かつ安定して回るテスト環境を構築しました。 これによって、 トリガーや自動化ツールの影響を受けずにコアロジックを **徹底的に単体テスト** できる状態を作りました。 「間違いが許されない」 請求情報生成ロジックを、 何度でも網羅的に検証できるようになったのは大きな転換点です。 ### 🔄 段階的なリファクタリングとテストの追加 既存コードのリファクタリングと並行して、 修正・追加する機能から優先的に ApexEloquent ベースのテストクラスを整備していきました。 ## 🎉 結果: 売上データの品質確保と、 高速な修正の両立 この戦略は、 はっきりとした成果に繋がりました。 ### ✅ 高品質なリリースと安定運用 大規模な仕様追加にもかかわらず、 リリース後のシステムは極めて安定して動作し、 当初懸念していた重大バグの発生を防ぐことができました。 売上データの正確性が担保され、 ビジネスへのマイナスインパクトを回避できました。 ### ⚡ デグレなしの高速修正 リリース数か月後、 当初は想定していなかった **5 年契約の特殊ケース** でバグが発生しました。 しかし、 Query Delegation Pattern と ApexEloquent によって積み上がっていた高品質なテスト群のおかげで、 この複雑な不具合を、 デグレを起こさずに **素早く確実に修正** できました。 安定運用を維持したまま、 売上に直結する重大バグの影響を最小限に抑えられました。 ## 🎭 まとめ: ミッションクリティカルなシステムにおけるアーキテクチャの力 このプロジェクトは、 売上データのようなミッションクリティカルなシステムを扱うとき、 **土台となるアーキテクチャの品質がそのままビジネス成果に直結する** ことを示してくれました。 Query Delegation Pattern と ApexEloquent は、 目の前の技術課題を解いただけでなく、 **長期的な保守性と、 ビジネス要請への素早い対応力** の基礎を作ってくれました。 責務の明確な分離・網羅的なテスト・モック可能なアーキテクチャ。 これらの組み合わせは、 厳しい要件を持つ業務システムを扱いながら、 変化に対応する俊敏さも維持するために、 やはり欠かせない構成要素だと改めて実感しています。 ============================================================================== Source: https://krileworks.com/content/blog/ja/testdatafactory-selector-pattern-limitations.md Page: https://krileworks.com/ja/blog/testdatafactory-selector-pattern-limitations ============================================================================== # 🏭 TestDataFactory について Salesforce では、 Apex テスト内で明示的にテストデータを作るための仕組みとして TestDataFactory の利用が推奨されています。 TestDataFactory はテストデータをユーティリティクラスに集約して、 再利用性を高めながらテストコードの可読性を上げることを目的としています。 ## 🔄 TestDataFactory の 2 つの使い方 TestDataFactory は便利ですが、 複雑なデータ構造を扱おうとすると、 だいたい次の 2 つのスタイルに分かれてきます。 1. **複雑な構造そのものを生成する Factory メソッドを用意する** - (例: Account / Opportunity / OpportunityLineItem / Quote / QuoteLineItem をまとめて作る) 2. **最小限の構造だけを提供し、 残りはテスト側で組み立てる** - (例: Account と Opportunity だけ用意し、 Quote 以下は各テストで手動追加する) どちらにもメリット・デメリットはありますが、 **共通する重大な弱点** があります。 ✅ **レコードの挿入はすべて DB を経由する** - 親レコードを insert - 子レコードは親 ID を参照して insert - 孫レコードもまた順番に insert ... この繰り返しに、 データ挿入のたびに走るトリガーや余計な処理が重なって、 テストクラスの実行時間はどんどん伸びていきます。 テストが遅くなると、 開発チームはしばしば最悪の選択をしてしまいます。 > 「もう十分なテストを書くのは無理。 デプロイ要件の 75% をギリギリ超える程度のテストでよしとしよう。」 これが TestDataFactory の限界の現れ方の一つです。 この症状が出始めたあと作られた Apex クラスは、 テストケースが足りていないか、 もしくは時間ばかり食う重いテストケースばかりになっていきます。 # 🎯 Selector Pattern について Salesforce は公式にもう一つのパターンを推奨しています。 それが「Selector Pattern」 です。 平たく言うと、 これは Repository パターンから「SELECT 処理だけ」 を切り出した設計です。 SELECT 文をオブジェクトごとに集約することで、 取得ロジックの再利用性を高めようというのが基本思想です。 先ほどの TestDataFactory の話を踏まえると、 「**じゃあこの Selector クラスをモックして、 DB を経由せずに SELECT 結果を差し替えれば、 テストクラスの崩壊を防げるのでは?**」 と考えるのは自然な流れです。 しかし、 このアプローチにも限界があります。 ## ⚠️ Selector Pattern の構造的な問題 Selector Pattern は「オブジェクト中心の再利用性」 を狙いますが、 現場で実際に起こりがちなのは次のような事態です。 - **似ているけれど少しずつ違うクエリが増え続ける** - **微妙な差異を吸収するためにフラグ引数が必要になる** その結果、 Selector クラスは次のどちらかに陥っていきます。 - **メソッドが増殖して複雑化する** - **条件分岐が不透明な「フラグ地獄」 になる** ## 🤔 Selector Pattern のモックは現実的か? 実際に Selector クラスをモックしようとすると、 さらに別の問題に直面します。 ### インターフェースベースのモッキングの限界 多くの場合、 Selector クラス用に `SelectorInterface` のようなものを定義し、 `MockSelector` で実装する形を取ります。 ただしこの設計には次の課題があります。 - **メソッドを追加するたびに、 インターフェース定義の修正が必要** - **すべての実装クラスにそのメソッドへの対応が要求される** - **使わないメソッドのためにも、 ダミー実装 (空実装や例外送出) を書かなければならない** 結果として保守性が悪化し、 「モックのためのコード」 を大量に書くハメになります。 ### virtual + override で差し替える方式の限界 これを避けるために、 仮想メソッドを使って必要な部分だけ override する方法を考えるかもしれません。 一見スマートに見えますが、 これは前述した Selector クラスの肥大化・複雑化・コンテキストの喪失といった根本的な問題を解決しません。 構造的な問題はそのまま残ります。 ## 🕳️ 見落とされがちな落とし穴: DML 処理もモックされているか? 多くの開発者が見落としているもう一つの観点。 「**取得処理だけでなく、 DML 操作 (insert / update / delete) もテストでは実行時間と副作用を生む** 」 ということです。 - **insert はトリガーを発火させる** - **update はバリデーションや Process Builder を起動させる** - **delete は関連レコードのカスケード削除やフローを発火させる** つまり、 取得処理だけをモックしても、 テストパフォーマンス改善としては不完全なのです。 ## 🚫 DRY 原則を盲信しない Salesforce が Selector Pattern を公式に採用している背景には、 「重複コード (コピーペースト) を防ぐため」 という DRY (Don't Repeat Yourself) 原則の実践があります。 ただ、 私はこのアプローチへの過信を強く戒めたいと考えています。 > 「クラス A とクラス B が同じクエリを使っているので、 一本化しよう。」 よくあるシナリオです。 しかし A と B が **異なる文脈** にあるなら、 この一本化は非常に危険になります。 将来、 A の仕様だけが変わったときに、 B にも意図せず影響が及ぶ可能性があるからです。 DRY 原則はもともと「**同じ文脈の中で再利用する** 」 ことを前提にしています。 これを無視して「形が似ているから一本化する」 を続けると、 変更に弱いシステムができあがります。 Salesforce 公式のガイドでさえ、 このポイントを十分に強調していないのが個人的に気になっています。 ## 📉 まとめ: Selector Pattern にも持続可能性は無い ここまでの議論を整理すると、 TestDataFactory も Selector Pattern も、 最初は便利に見えますが、 プロジェクトが成長するにつれて保守性・パフォーマンス・スケーラビリティの面で限界に達してしまいます。 - **Factory は DML とトリガー地獄で重くなる** - **Selector は肥大化と「一本化の落とし穴」 で設計が破綻する** ## 💡 ではどうするか? ### 解: Query Delegation Pattern と ApexEloquent 私がたどり着いた答えは、 **「責務を分けることでモックしやすくする」** という設計思想でした。 ✅ **Query Delegation Pattern** - **ドメイン層がクエリを組み立てる** - **Repository は「取得」 「保存」 のような I/O に専念する** この構成にすると、 SELECT の粒度を「ドメインの目的」 に合わせて設計できるようになります。 「A 用」 と「B 用」 で似たクエリが必要になっても、 別々に持っておけばよい — つまり、 **文脈に応じて DRY 原則を適用するか判断できる** 余地が生まれます。 この Query Delegation Pattern を実践するために、 実装と検証を繰り返した末にたどり着いたのが **ApexEloquent** です。 ## 🎭 最後に TestDataFactory も Selector Pattern も、 Salesforce 公式に推奨されており、 多くのプロジェクトで採用されています。 ただ、 それらが「持続可能か」 となると、 また別の話になります。 もしいまのテスト設計やデータ取得戦略に限界を感じているなら、 Query Delegation Pattern と ApexEloquent の採用を、 一度検討してみてください。 ============================================================================== Source: https://krileworks.com/content/blog/ja/why-i-created-this-site.md Page: https://krileworks.com/ja/blog/why-i-created-this-site ============================================================================== # このサイトと ApexEloquent を作った理由 こんにちは、このブログを見つけてくださってありがとうございます! このブログでは、 Salesforce Apex 向けのクエリライブラリ ApexEloquent や、 開発の背景にあるエピソード、 開発のなかで得た小さなコツを、 気軽に書き残していこうと思っています。 最初の投稿なので、 まずはこのサイトと ApexEloquent を作ろうと思った経緯から書いていきます。 ## 😵 「テストを書くのって、 こんなに大変なの?」 Salesforce 開発に初めて触れたとき、 私はこう感じました *「Apex はデータベース操作をそのままコードで書けるのか、 便利だな!」* ところが実際にプロジェクトに入って開発を始めると、 現実は少しずつ違って見えてきました。 私が担当した Salesforce 環境は、 すでに導入から 3 年が経っており、 多数のカスタムオブジェクトと複雑なリレーションを抱えていて、 小さな修正をするにも、 関連するオブジェクトのテストデータを大量に用意する必要がある状態でした。 *「この処理をテストするには、 まず関連を辿って、 親にデータを入れて、 ... あれ、 このデータはどう作るんだっけ?」* これが日常になっていました。 前任者はこれに疲れていたのでしょう。まともにテストが書かれていないコードが大量に放置されている理由がわかりました。 正直、 テストを書く時間がつらすぎて、 何度逃げ出したくなったか分かりません。 ## 🔧 フレームワークを作り始めた理由 そんな環境のなかで、 「テストをもっと楽に書けたらいいのに...」 と思う日が増えていきました。 前職で Laravel を使った開発をしていた経験があり、 そこで触れた Eloquent ORM の書き心地が驚くほど良かった記憶がありました。 *「あのスタイルで Salesforce でもクエリを書けたら...」* — そう考えたのが、 ApexEloquent を作る最初のきっかけです。 最初に取り組んだのは、 SOQL を動的に組み立てるためのライブラリ作りでした。 でも、 それだけではテストの書きにくさは解決しません。 次に取り組んだのが、 モックを中心としたテスト戦略を確立すること。 Salesforce は公式に「TestDataFactory でテストデータを作りましょう」 と推奨していますが、 当時の現場はすでにTestDataFactoryで何とかなる規模を超えていました。 そこで、 より現実的で楽なテストの書き方を模索した末にたどり着いたのが、 Query Delegation Pattern と呼んでいる設計パターンです。 そこに、 クエリ・モック・リレーションをひとまとめで扱える仕組みを足していった結果、 テストがずいぶん書きやすくなるフレームワークが出来上がりました。 途中で「書き込み不可項目をどう扱うか」 という Salesforce 特有の壁にもぶつかりましたが、 Evaluator / MockEvaluator(ApexEloquentの中のEntryの前身) を作ることでこれも乗り越えました。 ## 🌐 そしてこのサイトを作った理由 ApexEloquent を使うことで開発が楽になり、 自分なりのテスト戦略もかなり整理できました。 ただ、 周りを見渡してみると、 Apex のモッキング戦略を扱うコミュニティが、 まだあまり活発ではないと感じました。 特に日本ではモッキングに関する情報がほとんどなく、議論が十分に交わされている様子もなさそうでした。 Laravel の Eloquent の良さや、 書き込み不可項目への対策を取り込んだ ApexEloquent を広く知ってもらうことで、 コミュニティを盛り上げ、 Apex エンジニアの開発環境を少しでも良くできないかと、kう考えました。 それがこのサイトを作ったきっかけです。 ## 🎯 まとめ このブログでは、 ApexEloquent の機能紹介だけでなく、 実際にどう使っているか、 設計で気をつけていること、 Salesforce 開発の小さなコツなども書き残していきます。 *「Salesforce Apex 開発をもっと楽しくしたいな」* と思っている方にとって、 何かのヒントになれば嬉しいです。 ## 🙏 謝辞 この機会に、 James Simone 氏と、 氏の素晴らしい著述 [The Joy of Apex](https://www.jamessimone.net/blog/joys-of-apex/) に感謝を伝えたいと思います。 ApexEloquent は、 氏の Apex 開発パターン・テスト戦略・クリーンアーキテクチャに関する洞察から多くのインスピレーションを受けています。 Salesforce 開発者コミュニティへの氏の貢献は計り知れず、 その築いてくれた土台の上に自分の OSS を載せられることを光栄に思います。 次の記事でまたお会いしましょう! 👋