ApexEloquent: Parent fields, child subqueries, and many-to-many
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.
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 toAccount.Nameand friends - After fetching, retrieve the parent
IEntryviaIEntry.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.
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
ScribewithScribe.asChild(Opportunity.class) - Pass it to
withChildren(...), and the SOQL SELECT clause expands as a subquery - After fetching, retrieve the child
IEntrylist viaIEntry.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.
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).
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.
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.
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.
// 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.
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-conditionparentConditionwith 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":
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 "throughOrderItem, fetch whatProduct2Idpoints to (=Product2)".field('Name')/.field('ProductCode')— write field names of the destination (Product2). The generated SOQL auto-expands them toProduct2.Name/Product2.ProductCode.whereEqual('IsActive', true)etc. are also based on the destination (Product2). The SOQL becomesWHERE 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.
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
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
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
Scribekey and thesetChildrenkey aligned.
MockEntry.getChildrenchecks against the child relation name theScriberegistered (the object name unlessrelationNamewas set). Diverge and anApexEloquentExceptionis thrown.CODEThe specified child Object Name `Opportunities` is not set in Scribe. parent object name: AccountSo while production
Entryalso resolves relationship names, in mocks you must call with the key declared on theScribe. 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.
// 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')
});
Read Next
- Building Queries with Scribe: the full picture of the query builder
- Data Access, DML, IEntry, and Mock: how to use
IEloquentandMockEloquent - ApexEloquent Guide: back to the ApexEloquent Guide index