Strongly Typed Parent & Child-level Queries
Posted: February 21, 2022

Strongly Typed Parent & Child-level Queries

Table of Contents:

  • Adding Parent-Level Field Support
  • Adding Support For Subselects, Or Child Queries
  • Wrapping Up Parent/Child Query Support

One of the best thing about using strongly typed query builders is the type-checking that you get — for free — while using them. Type-checking not only prevents you, as a developer, from making a mistake — it also prevents admins from updating the API name for a field or from being able to delete it while it’s referenced within your Apex code. Even if there were no other use for the Schema.SObjectField class, those two reasons alone would make it worth our while when deciding when to use these field-level tokens in our code. 1 Let’s look at how to take full advantage of those strongly typed field tokens in our existing query builder!

Despite the incredible utility of Schema.SObjectField, there are two places where its utility falls a bit short of being fully usable across the possible spectrum of Salesforce object fields:

  • parent-level fields can be referenced (which is great!) but they require a bit more work before they can be passed into queries
  • lists of children fields aren’t supported

This is … a bit of a let-down, to say the least. For parent-level fields:

// this is supported, but it only prints "Name"
// NOT "Account.Name", which is what we would need
// when building a query dynamically with this token
Opportunity.Account.Name.getDescribe().getName();

And the equivalent child-level code:

Account.Opportunities.getDescribe() // not supported
new Account().Opportunities.getSObjectType(); // supported! ... but not as useful

So from a technical perspective, the hurdle we need to clear for parent-level field tokens is the result of Schema.DescribeFieldResult not including a getSObjectType() method (in other words, we can get describe tokens for parent-level fields, but there’s no dynamic reference to Account if we were using the example from above). For child-level fields, we have access to the getChildRelationships() method off of the parent’s Schema.DescribeSObjectResult, but this returns a List<Schema.ChildRelationship> that we then have to iterate through ourselves (awkward). Regardless — it’s eminently possible (and not that hard) to add parent/child-level query support to the Repository Pattern.

Update — as of Spring ‘23, Schema.DescribeFieldResult does have a getSObjectType() method on it! However this doesn’t quite work for our use-case, as complex Schema.SObjectField references (like Opportunity.Account.Owner.Name) will only have the top-level User object reference, leaving us unable to fully recreate the hierarchy relationship name. Read on to see the full solve:

Adding Parent-Level Field Support

Since we are practicing Test Driven Development, let’s start with a failing test:

@IsTest
private class RepositoryTests {
    @IsTest
  static void parentFieldsAreSupported() {
    IRepository repo = new OpportunityRepo();
    // more on why the first arg
    // is a list in a second
    repo.addParentFields(new List<SObjectType>{ Account.SObjectType }, new List<SObjectField>{ Opportunity.Account.Id });

    Account acc = new Account(Name = 'parent');
    insert acc;

    insert new Opportunity(AccountId = acc.Id, StageName = 'testing', CloseDate = System.today(), Name = 'Child');

    Opportunity retrievedOpp = (Opportunity) repo.getAll()[0];
    System.assertNotEquals(null, retrievedOpp.Account.Id);
  }

  private class OpportunityRepo extends Repository {
    public OpportunityRepo() {
      super(Opportunity.SObjectType, new List<SObjectField>{ Opportunity.Id }, new RepoFactoryMock());
    }
  }
}

Note that we’ll need to stub out the implementation in order to be able to save and run the test:

public interface IRepository extends IDML {
  List<SObject> get(Query query);
  List<SObject> get(List<Query> queries);
  List<SObject> getAll();

  // adds the method to the interface - we want the first arg
  // to be a list because you can go up multiple parents
  void addParentFields(List<SObjectType> parentType, List<SObjectField> parentFields);
}

// and then in Repository.cls:
public void addParentFields(List<SObjectType> parentTypes, List<SObjectField> parentFields) {
}

Running the test, we of course get a failure: System.SObjectException: SObject row was retrieved via SOQL without querying the requested field: Opportunity.Account. Perfect! This is just the failure we need in order to proceed! This is also a chance to add in a non-obvious optimization: previously, the list of fields being queried for was being dynamically appended within Repository any time a query is made — but the selection fields are “static”, in the sense that they don’t change. Since we’ll need to add to them while still having the base fields passed to each repository instance, this is a sensible time to move the initial creation of the base selection fields to the constructor:

public virtual without sharing class Repository implements IRepository {
  // ...
  private final Set<String> selectFields;

  public Repository(Schema.SObjectType repoType, List<Schema.SObjectField> queryFields, RepoFactory repoFactory) {
    this(repoFactory);
    this.queryFields = queryFields;
    this.repoType = repoType;
    // this method already existed - it was just being executed
    // any time a query was made, before
    this.selectFields = this.addSelectFields();
  }

  public void addParentFields(List<SObjectType> parentTypes, List<SObjectField> parentFields) {
    String parentBase = '';
    for (SObjectType parentType : parentTypes) {
      parentBase += parentType.getDescribe().getName() + '.';
    }
    for (SObjectField parentField : parentFields) {
      this.selectFields.add(parentBase + parentField.getDescribe().getName());
    }
  }
  // ... etc
}

We’ve moved one line of code and added eight more. That’s all that it takes for the test to pass. This is a good indication that the Repository conforms to the Open-Closed Principle. Not shown here — but perhaps a fun exercise for the reader — would be how we could easily check the List<SObjectType> parentTypes list prior to adding parent fields, to ensure nobody was ever trying to navigate more than 5 objects “upwards.”

Something else to note — one of the reasons that the Repository class that I end up showing off employs the usage of a RepoFactory is specifically to aid and abet with consolidating where things like formulating the SELECT part of SOQL statements is done. The combination of the repository pattern with the factory pattern is a powerful one; this is in contrast to something like the Selector pattern, which typically espouses separate selector classes for each queryable concern. Said another way — Repository encapsulates CRUD for any given object, whereas Selector more specifically encapsulates only the R in CRUD — read access to an object. It’s too “thin” of an abstraction for my taste.

Here’s what the RepoFactory looks like, if we move our example repository there:

public virtual class RepoFactory {
  public virtual IAggregateRepository getOppRepo() {
    List<SObjectField> queryFields = new List<SObjectField>{
      Opportunity.IsWon,
      Opportunity.StageName
      // etc ...
    };
    IAggregateRepository oppRepo = new AggregateRepository(Opportunity.SObjectType, queryFields, this);
    oppRepo.addParentFields(
      new List<SObjectType>{ Account.SObjectType },
      new List<SObjectField>{ Opportunity.Account.Id }
    );
    return oppRepo;
  }

  // etc ...
}

In this way, when the times comes to add new fields, you can do so in one place; this also is the “magic” behind being able to easily stub out query results (which I’ll talk more about after covering child queries)!

Adding Support For Subselects, Or Child Queries

It only took 8 lines of code to add parent-level support — let’s see what sort of changes are due to the Repository in order to add subselect, or child queries. As always, we’ll start by writing a failing test:

// in RepositoryTests.cls
@IsTest
static void childFieldsAreSupported() {
  // luckily, outside of directly testing the repository
  // we rarely need to insert hierarchies of data like this
  Account acc = new Account(Name = 'parent');
  insert acc;
  Contact con = new Contact(AccountId = acc.Id, LastName = 'Child field');
  insert con;

  IRepository repo = new AccountRepo();
  repo.addChildFields(Contact.SObjectType, new List<SObjectField>{ Contact.LastName });

  acc = (Account) repo.getAll()[0];
  // the test fails on the below line with:
  // "System.ListException: List index out of bounds: 0"
  System.assertEquals(con.LastName, acc.Contacts[0].LastName);
}

private class AccountRepo extends Repository {
  public AccountRepo() {
    super(Account.SObjectType, new List<SObjectField>{ Account.Id }, new RepoFactoryMock());
  }
}

I can’t stress enough the comment I’ve left there at the top of this test method — this is an extremely unusual situation, in the sense that it’s rare for me to actually be inserting data within tests because the Repository pattern allows — everywhere that repositories are used — easy access to stubbing out the return values needed in any given context. With that off my chest, let’s move on to the implementation:

public interface IRepository extends IDML {
  List<SObject> get(Query query);
  List<SObject> get(List<Query> queries);
  List<SObject> getAll();

  void addParentFields(List<SObjectType> parentTypes, List<SObjectField> parentFields);
  // adding the method signature to the interface
  void addChildFields(SObjectType childType, List<SObjectField> childFields);
}

public virtual without sharing class Repository implements IRepository {
  // ... other instance properties
  private final Set<String> selectFields;
  private final Map<SObjectType, String> childToRelationshipNames;

  public Repository(Schema.SObjectType repoType, List<Schema.SObjectField> queryFields, RepoFactory repoFactory) {
    this(repoFactory);
    this.queryFields = queryFields;
    this.repoType = repoType;
    this.selectFields = this.addSelectFields();
    this.childToRelationshipNames = this.getChildRelationshipNames(repoType);
  }

  // showing the private method called by the constructor first
  // for clarity:
  private Map<SObjectType, String> getChildRelationshipNames(Schema.SObjectType repoType) {
    Map<SObjectType, String> localChildToRelationshipNames = new Map<SObjectType, String>();
    for (Schema.ChildRelationship childRelationship : repoType.getDescribe().getChildRelationships()) {
      localChildToRelationshipNames.put(childRelationship.getChildSObject(), childRelationship.getRelationshipName());
    }
    return localChildToRelationshipNames;
  }

  // and then the implementation
  public void addChildFields(SObjectType childType, List<SObjectField> childFields) {
    if (this.childToRelationshipNames.containsKey(childType)) {
      String baseSubselect = '(SELECT {0} FROM {1})';
      Set<String> childFieldNames = new Set<String>{ 'Id' };
      for (SObjectField childField : childFields) {
        childFieldNames.add(childField.getDescribe().getName());
      }
      this.selectFields.add(
        String.format(
          baseSubselect,
          new List<String>{ String.join(new List<String>(childFieldNames), ','), this.childToRelationshipNames.get(childType) }
        )
      );
    }
  }

  // ... etc, rest of the class
}

So — not quite as easy to add in as our parent-level support, weighing in at a frightening twenty-three lines of code added. As is always the case, too, there’s plenty of potential for additional extension — for example, adding Query support to our addChildFields method (to allow for filtering on the subselect). That opens the door to things like LIMITing the results, ordering them, and so on. It’s my hope that showing how easy it is to add this functionality will enable others to make use of Repository as a truly one-stop-shop for any and all CRUD needs in your Salesforce codebase when it comes to:

  • strongly typed queries (which in and of itself deserves calling out the positive benefits like developer intellisense, platform validation of field API names, and all that good stuff)
  • easily setting up test data without having to setup and act on complex hierarchical record trees. Simply use the RepoFactoryMock class (and its @TestVisible properties) to easily stub out all CRUD operations

Wrapping Up Parent/Child Query Support

I think I set a land-speed record for finishing a Joys Of Apex article while writing this, and I’d like to thank Joseph Mason for providing the inspiration that spurred me to do so. He first commented on You Need A Strongly Typed Query Builder, and then later was good enough to digitally meet up so that we could review some instances where adding in support for child relationship queries would make a big difference. I was glad to have the chance to quickly expand upon the existing functionality in the hopes that it will end up serving others well. In addition to the ever-present code examples hosted on the Apex Mocks Stress Test repo — which now includes the code examples shown here — the functionality shown off here is also hosted on the official DML/Repository repo.

Thanks, as always, for taking the time to read the Joys Of Apex — it means a lot to me, and hopefully it proves helpful for you! Till next time.


  1. Of course, there is an exception to every rule and that exception - use of truly String-based queries - comes when creating packages that need to be able to dynamically reference SObjects and fields that may/may not exist in a given org where the package will be installed. In Apex Rollup, for example, there is a section of the code that behaves differently in the event that it's being run in a multi-currency org. Because orgs where multi-currency isn't enabled don't contain objects like CurrencyType, the only way to reference them is in a string-based query

    ↩ go back from whence you came!
In the past three years, hundreds of thousands of you have come to read & enjoy the Joys Of Apex. Over that time period, I've remained staunchly opposed to advertising on the site, but I've made a Patreon account in the event that you'd like to show your support there. Know that the content here will always remain free. Thanks again for reading — see you next time!