ApexEloquent: Parent fields, child subqueries, and many-to-many

Apex Stem Docs
Apex StemApexEloquentScribeRelationsJunctionMockEntrySalesforceApex
Building queries that span relationships in ApexEloquent: parentField, withChildren, parentCondition, and asThrough for junction objects, plus how to mock parent and child records with MockEntry.

This document covers how to work with relations in ApexEloquent. For query assembly via Scribe, see Building Queries with Scribe. For the basics of IEloquent / IEntry, see Data Access, DML, IEntry, and Mock.

Retrieving Parent Fields

To pull a parent object's field into the SELECT — like "from an Opportunity, fetch the parent Account's industry" — use 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');
}
  • Create a parent-relation Scribe with Scribe.asParent('AccountId') and specify the parent fields via .field(...)
  • Pass it to parentField(...), and the SOQL SELECT clause expands to Account.Name and friends
  • After fetching, retrieve the parent IEntry via IEntry.getParent('AccountId')

Accessing a Non-parentField'd Parent Throws

Calling IEntry.getParent('AccountId') on a parent that wasn't declared with parentField in Scribe throws at test time (via MockEntry). This is the mechanism that surfaces "parent-field SELECT omissions" in tests rather than in production.

Retrieving Child Subqueries

To pull child records via a subquery — like "from an Account, fetch its list of child Opportunities" — use 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) {
    // ...
  }
}
  • Create a child-subquery Scribe with Scribe.asChild(Opportunity.class)
  • Pass it to withChildren(...), and the SOQL SELECT clause expands as a subquery
  • After fetching, retrieve the child IEntry list via IEntry.getChildren('Opportunity')

In production, Entry resolves the getChildren argument as either an object name or a relationship name ('Opportunity' / 'Opportunities') — object name first, falling back to relationship name.

That said, MockEntry checks against the key you declared on the Scribe, so call it with the name you used there if you want the test to pass (see "Mocking child records" below). Anything that passes in tests will pass in production; the reverse is not guaranteed.

Fetching Multiple Child Subqueries in Parallel

Multiple child objects can be fetched in parallel under the same parent. Chain withChildren twice and each becomes an independent child subquery.

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'

Nested Child Subqueries (Children of Children)

Calling withChildren(...) inside a Scribe.asChild(...) gives you nested subqueries that include grandchildren (up to four levels deep).

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'

On the consumer side, walk through nested getChildren calls.

APEX
for(IEntry accountEntry : accountEntries) {
  for(IEntry contactEntry : accountEntry.getChildren('Contact')) {
    for(IEntry oppEntry : contactEntry.getChildren('Opportunity')) {
      // ...
    }
  }
}

Make relationName Explicit When the Child Relation Is Ambiguous

When multiple lookup fields reference the same object (e.g. Opportunity.AccountId and Opportunity.CustomAccount__c both pointing at Account), the child subquery becomes ambiguous about which relation to return, and Scribe errors out as-is.

In that case, use relationName(...) to disambiguate.

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
 
// Use the same relation name on the consumer side
accountEntry.getChildren('CustomOpportunities__r');

Filtering by Parent Conditions

To filter a child object based on the parent's condition, use parentCondition.

APEX
// Fetch OpportunityLineItem where the parent Opportunity's Name starts with "Test%"
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%'

Chain WHERE-family methods onto Scribe.asParent('OpportunityId') and embed it via parentCondition.

parentCondition does not include parent fields in the SELECT — it uses the parent only as a condition. If you also want parent fields in the SELECT, combine with parentField.

Joining Parent Conditions with OR

orCondition() is usable inside the Scribe.asParent(...) of a parentCondition, so conditions like "the parent's Name or the parent's Type matches" work fine.

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')

A parentCondition holding two or more conditions is wrapped in parentheses as a single structural unit. Without them the SOQL would read A AND B OR C — AND and OR mixed at one nesting level — which SOQL rejects with unexpected token: OR.

⚠️ Before v3.5.0 the parentheses were missing and the query failed at run time. toSoql() succeeded while only the real query died, which made it hard to spot. Use v3.5.0 or later if you combine a multi-condition parentCondition with any other condition.

parentField and parentCondition coexist and reflect independently in the SELECT and WHERE clauses.

Many-to-Many (Junction Object)

For retrieval through a junction object — Salesforce's way of expressing many-to-many — use asThrough + through.

A clear standard-object example: the relationship between an Order (Order) and a Product (Product2). Between them sits the order item (OrderItem) as a junction, referencing Product2 via OrderItem.Product2Id and Order via OrderItem.OrderId.

"Fetch the products an order handles":

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

Points:

  • Scribe.asThrough(OrderItem.class, 'Product2Id') declares "through OrderItem, fetch what Product2Id points to (= Product2)"
  • .field('Name') / .field('ProductCode')write field names of the destination (Product2). The generated SOQL auto-expands them to Product2.Name / Product2.ProductCode
  • .whereEqual('IsActive', true) etc. are also based on the destination (Product2). The SOQL becomes WHERE Product2.IsActive = true

On the consumer side, retrieve via IEntry.getThrough(junctionName, relatedKey). As with getChildren, the first argument resolves as either the junction's object name or its relationship name.

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');
  }
}

Even via a junction, you can use relationName(...) to disambiguate when the relation is ambiguous — same idea as with child subqueries. Useful when the same parent has multiple lookups.

Mocking Parent-Child with MockEntry

To assemble parent-child relationships as test data, use MockEntry.setParent and MockEntry.setChildren.

Mocking the Parent

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')
  );
 
// In the test, hand it off to the consumer code and access via getParent
IEntry accountEntry = oppEntry.getParent('AccountId');
Assert.areEqual('Technology', (String) accountEntry.get('Industry'));

setParent('AccountId', ...) hangs an Account MockEntry as "the parent via the Opportunity's AccountId".

Mocking Children

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)
  );
 
// In the test, access via getChildren
List<IEntry> oppEntries = accountEntry.getChildren('Opportunity');
Assert.areEqual(3, oppEntries.size());

For the first argument of setChildren, use the same name you specified in Scribe with relationName if you did; otherwise use the object name as-is ('Opportunity' — no pluralization, no __r).

⚠️ Keep the Scribe key and the setChildren key aligned.

MockEntry.getChildren checks against the child relation name the Scribe registered (the object name unless relationName was set). Diverge and an ApexEloquentException is thrown.

CODE
The specified child Object Name `Opportunities` is not set in Scribe. parent object name: Account

So while production Entry also resolves relationship names, in mocks you must call with the key declared on the Scribe. It never quietly returns an empty list — a mismatched key fails the test right there.

Define Child MockEntry Inline Inside the setChildren Argument

If you extract the child MockEntry into a variable, the reader has to anticipate "is this variable used somewhere else later?". Unless there's a clear reason to reuse it via getAliasId(...) or similar, inline the definition inside the setChildren argument for better readability.

APEX
// Good: inline (the structure is visually obvious)
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')
  });