親項目・子サブクエリ・多対多

Apex Stem ドキュメント
Apex StemApexEloquentScribeRelationsJunctionMockEntrySalesforceApex
親リレーション、子サブクエリ、Junction Object 経由の多対多を Scribe で組み立てる方法と、MockEntry で親子をモックする方法を解説します。

このドキュメントは、ApexEloquent でリレーションを扱う方法をまとめます。Scribe でのクエリ組み立ては Scribe でクエリを組み立てるIEloquent / IEntry の基本は データ取得と DML、IEntry、Mock を参照してください。

親項目を取得する

「商談から親取引先の業種を取りたい」のように、親オブジェクトのフィールドを 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<IEntry> 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 で取らないと例外

ScribeparentField していない親項目に 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<IEntry> accountEntries = this.fetchEloquent.get(accountScribe);
 
for(IEntry accountEntry : accountEntries) {
  List<IEntry> 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 名として解決する順です。

ただし MockEntryScribe に宣言したキーで照合する ので、テストを通したいなら 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.AccountIdOpportunity.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 で組み込みます。

parentConditionSELECT には親項目を含めない、ただ条件としてだけ使う 形になります。親項目も 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 以上を使ってください。

parentFieldparentCondition は両立し、それぞれ SELECT 句と WHERE 句に独立して反映されます。

多対多 (Junction Object)

Salesforce で多対多関係を表現する Junction Object を経由した取得には asThrough + through を使います。

標準オブジェクトでわかりやすい例として、注文 (Order) と商品 (Product2) の関係を考えます。両者の間には注文明細 (OrderItem) が Junction として挟まり、OrderItem.Product2IdProduct2 を、OrderItem.OrderIdOrder を参照します。

「ある注文が扱う商品を取りたい」場合:

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<IEntry> orderEntries = this.fetchEloquent.get(scribe);
for(IEntry orderEntry : orderEntries) {
  List<IEntry> 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.setParentMockEntry.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<IEntry> oppEntries = accountEntry.getChildren('Opportunity');
Assert.areEqual(3, oppEntries.size());

setChildren の第 1 引数は、Scribe 側で relationName を指定したならその名前、指定していないなら オブジェクト名 ('Opportunity' のようにそのまま、複数形変換も __r も付けない) で揃えます。

⚠️ Scribe の登録キーと setChildren の key は揃えてください。

MockEntry.getChildren は、Scribe が登録した子リレーション名 (relationName 未指定ならオブジェクト名) と照合します。ズレていると ApexEloquentException が投げられます

CODE
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>{
    MockEntry.of(Opportunity.class).autoId(1).set('Name', 'Opp A'),
    MockEntry.of(Opportunity.class).autoId(2).set('Name', 'Opp B')
  });

次に読む