親項目・子サブクエリ・多対多
このドキュメントは、ApexEloquent でリレーションを扱う方法をまとめます。Scribe でのクエリ組み立ては Scribe でクエリを組み立てる、IEloquent / IEntry の基本は データ取得と DML、IEntry、Mock を参照してください。
親項目を取得する
「商談から親取引先の業種を取りたい」のように、親オブジェクトのフィールドを SELECT に含めるには parentField を使います。
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 で取らないと例外
Scribe で parentField していない親項目に IEntry.getParent('AccountId') でアクセスすると、テスト時 (MockEntry 経由) に例外が出ます。これは「親項目の SELECT 漏れ」を本番ではなくテストで検出するための仕組みです。
子サブクエリを取得する
「取引先からその子商談一覧を取りたい」のように、子レコードをサブクエリで取るには withChildren を使います。
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 名として解決する順です。
ただし MockEntry は Scribe に宣言したキーで照合する ので、テストを通したいなら Scribe と揃えた名前で呼んでください (後述の「子レコードのモック」を参照)。テストが通る書き方は本番でも通りますが、逆は保証されません。
並列に複数の子サブクエリを取る
同じ親に対して 複数の子オブジェクトを並列に 取得することもできます。withChildren を 2 回続ければ、それぞれが独立した子サブクエリになります。
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 レベルまで)。
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 をネストして辿ります。
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(...) で明示します。
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 を使います。
// 親 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 が一致する」のような条件が書けます。
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 を参照します。
「ある注文が扱う商品を取りたい」場合:
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 名のどちらでも解決されます。
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.setParent と MockEntry.setChildren を使います。
親レコードのモック
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 をぶら下げます。
子レコードのモック
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が投げられます。CODEThe specified child Object Name `Opportunities` is not set in Scribe. parent object name: Accountつまり、本番の
Entryは relationship 名でも解決しますが、モックではScribeに宣言したキーで呼ぶ必要があります。黙って空リストが返ることはないので、キーがズレていればテストがその場で落ちます。
子 MockEntry は setChildren の引数内でインライン定義する
子の MockEntry を事前に変数化すると、「この変数が後でどこか別の場所で使われるのか?」と読み手が予測しないといけなくなります。getAliasId(...) などで再利用する明確な理由がない限り、setChildren の引数の中で インラインで書き下す のが読みやすくなります。
// ✅ インライン (構造が視覚的に見える)
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')
});
次に読む
- Scribe でクエリを組み立てる: クエリビルダーの全体像
- データ取得と DML、IEntry、Mock:
IEloquentとMockEloquentの使い方 - ApexEloquent ガイド: ApexEloquent ガイドの目次に戻る