RelatedQuery Recipes
A RelatedQuery provider loads children, siblings or any other records with one query per handler per run, and every predicate, action and Finalizer reads them from memory. For parents, use a ParentQuery instead.
A Complete Example
A provider has two methods. query(records) returns rows, and keyOf(row) returns the key each row is stored under. The handler returns its providers by name and reads them with record.getRelated('<name>'):
public with sharing class AccountColdRatingValidator implements BeforeUpdate.Validator, BeforeUpdate.RelatedQuery {
public Map<String, BeforeUpdate.RecordsProvider> queryRelatedOnBeforeUpdate() {
return new Map<String, BeforeUpdate.RecordsProvider>{ 'openOpportunities' => new OpenOpportunities() };
}
public Boolean addErrorOnBeforeUpdateWhen(TriggerTypes.UpdateRecord record) {
return record.isChangedTo(Account.Rating, 'Cold') && !record.getRelated('openOpportunities').getAllWhereKeyEquals(record.getId()).isEmpty();
}
public void addErrorOnBeforeUpdate(TriggerTypes.RejectableUpdateRecord record) {
record.addError(Account.Rating, 'This account has open opportunities and cannot be rated Cold.');
}
private without sharing class OpenOpportunities implements BeforeUpdate.RecordsProvider {
public List<SObject> query(TriggerTypes.UpdateRecords records) {
return [SELECT Id, AccountId FROM Opportunity WHERE AccountId IN :records.getIds() AND IsClosed = FALSE];
}
public String keyOf(SObject row) {
return ((Opportunity) row).AccountId;
}
}
}public with sharing class AccountAddressCascadeWriter implements AfterUpdate.Writer, AfterUpdate.RelatedQuery, AfterUpdate.ContinueOnError, AfterUpdate.RecursionGuard {
public Integer maxRunsPerRecordOnAfterUpdate() {
return 1;
}
public Map<String, AfterUpdate.RecordsProvider> queryRelatedOnAfterUpdate() {
return new Map<String, AfterUpdate.RecordsProvider>{ 'contacts' => new AccountContactsProvider() };
}
public Boolean writeOnAfterUpdateWhen(TriggerTypes.UpdateRecord record) {
return record.isAnyChanged(Account.BillingStreet, Account.BillingCity, Account.BillingState, Account.BillingPostalCode, Account.BillingCountry);
}
public void writeOnAfterUpdate(TriggerTypes.UpdateRecord record, TriggerTypes.UnitOfWork unitOfWork) {
Account newAccount = (Account) record.getNewSObject();
Account oldAccount = (Account) record.getOldSObject();
for (SObject row : record.getRelated('contacts').getAllWhereKeyEquals(record.getId())) {
Contact contact = (Contact) row;
if (!this.mailingFollowedAccount(contact, oldAccount)) {
continue;
}
unitOfWork.toUpdate(
new Contact(
Id = contact.Id,
MailingStreet = newAccount.BillingStreet,
MailingCity = newAccount.BillingCity,
MailingState = newAccount.BillingState,
MailingPostalCode = newAccount.BillingPostalCode,
MailingCountry = newAccount.BillingCountry
)
);
}
}
private Boolean mailingFollowedAccount(Contact contact, Account oldAccount) {
if (String.isBlank(contact.MailingStreet) && String.isBlank(contact.MailingCity)) {
return true;
}
return contact.MailingStreet == oldAccount.BillingStreet &&
contact.MailingCity == oldAccount.BillingCity &&
contact.MailingPostalCode == oldAccount.BillingPostalCode &&
contact.MailingCountry == oldAccount.BillingCountry;
}
private with sharing class AccountContactsProvider implements AfterUpdate.RecordsProvider {
public List<SObject> query(TriggerTypes.UpdateRecords records) {
return [
SELECT Id, AccountId, MailingStreet, MailingCity, MailingState, MailingPostalCode, MailingCountry
FROM Contact
WHERE AccountId IN :records.getIds()
];
}
public String keyOf(SObject row) {
return ((Contact) row).AccountId;
}
}
}Recipes
Siblings
Other records under the same parent. Exclude the trigger records, so a record never finds itself:
public List<SObject> query(TriggerTypes.UpdateRecords records) {
return [
SELECT Id, AccountId, CloseDate
FROM Opportunity
WHERE AccountId IN :records.getIdsOf(Opportunity.AccountId) AND Id NOT IN :records.getIds() AND IsClosed = FALSE
ORDER BY CloseDate
];
}
public String keyOf(SObject row) {
return ((Opportunity) row).AccountId;
}The ORDER BY decides which row getFirstWhereKeyEquals returns.
Records Under the Previous Parent
In the update contexts, getOldIdsOf reads the lookup values from before the save:
public List<SObject> query(TriggerTypes.UpdateRecords records) {
return [SELECT Id, AccountId FROM Contact WHERE AccountId IN :records.getOldIdsOf(Contact.AccountId) AND Id NOT IN :records.getIds()];
}Matching on a Text Value
Keys match exactly, case included. Normalize both sides the same way:
public List<SObject> query(TriggerTypes.InsertRecords records) {
return [SELECT Id, Email FROM Contact WHERE Email IN :records.getValuesOf(Contact.Email)];
}
public String keyOf(SObject row) {
return ((Contact) row).Email?.toLowerCase();
}String email = ((Contact) record.getNewSObject()).Email;
Boolean isDuplicate = email != null && record.getRelated('sameEmail').getFirstWhereKeyEquals(email.toLowerCase()) != null;A Key Built From Two Fields
Build the key in one static method, so the provider and the handler agree:
public static String key(Id opportunityId, Id productId) {
return opportunityId + '|' + productId;
}
public String keyOf(SObject row) {
OpportunityLineItem lineItem = (OpportunityLineItem) row;
return OpportunityLineItemsProvider.key(lineItem.OpportunityId, lineItem.Product2Id);
}Configuration
Rows that do not depend on the trigger records, such as custom metadata:
public List<SObject> query(TriggerTypes.InsertRecords records) {
return [SELECT Country__c, Region__c FROM RegionMapping__mdt];
}
public String keyOf(SObject row) {
return ((RegionMapping__mdt) row).Country__c;
}A Declared Parent's Field
Parents load before providers run. Declare Account.OwnerId with a ParentQuery, then collect it by the relationship name:
public List<SObject> query(TriggerTypes.InsertRecords records) {
return [SELECT Id, OwnerId FROM Account WHERE OwnerId IN :records.getIdsOf('Account', Account.OwnerId)];
}The Trigger Records Themselves
Formula, roll-up and system fields that the trigger rows do not carry:
public List<SObject> query(TriggerTypes.UpdateRecords records) {
return [SELECT Id, ExpectedRevenue FROM Opportunity WHERE Id IN :records.getIds()];
}
public String keyOf(SObject row) {
return row.Id;
}In after insert, update and undelete the query returns the new values. In before update and before delete it returns the values from before the save.
One Provider, Several Contexts
Implement each context's RecordsProvider and add one query overload per collection type:
public without sharing class AccountOpenOpportunitiesProvider implements BeforeUpdate.RecordsProvider, BeforeDelete.RecordsProvider {
public List<SObject> query(TriggerTypes.UpdateRecords records) {
return this.openOpportunitiesOf(records.getIds());
}
public List<SObject> query(TriggerTypes.DeleteRecords records) {
return this.openOpportunitiesOf(records.getIds());
}
public String keyOf(SObject row) {
return ((Opportunity) row).AccountId;
}
private List<SObject> openOpportunitiesOf(Set<Id> accountIds) {
return [SELECT Id, AccountId FROM Opportunity WHERE AccountId IN :accountIds AND IsClosed = FALSE];
}
}Rules
- Only the qualified records need it? Query once in the Finalizer instead.
