# SOQL Field Coverage Rules This guide documents field coverage validation rules for SOQL queries — ensuring that all fields accessed in Apex code are actually queried. This is a common source of runtime errors, especially in AI-assisted code generation. --- ## Table of Contents 1. [The Field Coverage Problem](#the-field-coverage-problem) 2. [Direct Field Access](#direct-field-access) 3. [Relationship Field Access](#relationship-field-access) 4. [Dynamic Field Access](#dynamic-field-access) 5. [Aggregate Queries](#aggregate-queries) 6. [Subquery Fields](#subquery-fields) 7. [Validation Patterns](#validation-patterns) --- ## The Field Coverage Problem When you query an sObject, only the fields in the SELECT clause are populated. Accessing any other field results in a runtime error: ``` System.SObjectException: SObject row was retrieved via SOQL without querying the requested field: Account.Industry ``` This error is particularly common in LLM-generated code because the LLM may: 1. Query some fields but access others in subsequent code 2. Forget to include relationship fields (e.g., `Account.Name` on Contact) 3. Access fields in conditional logic that weren't anticipated in the query --- ## Direct Field Access ### Rule: Every field accessed must be in the SELECT clause ### BAD: Accessing Unqueried Fields ```apex // Query only includes Id and Name List accounts = [SELECT Id, Name FROM Account]; for (Account acc : accounts) { // RUNTIME ERROR: Industry was not queried if (acc.Industry == 'Technology') { // RUNTIME ERROR: Description was not queried acc.Description = 'Tech company'; } // RUNTIME ERROR: AnnualRevenue was not queried Decimal revenue = acc.AnnualRevenue; } ``` ### GOOD: Query All Accessed Fields ```apex // Query ALL fields that will be accessed List accounts = [ SELECT Id, Name, Industry, Description, AnnualRevenue FROM Account ]; for (Account acc : accounts) { if (acc.Industry == 'Technology') { acc.Description = 'Tech company'; // OK - queried } Decimal revenue = acc.AnnualRevenue; // OK - queried } ``` ### Field Access Locations to Check Fields can be accessed in many places—ensure coverage for all: | Access Location | Example | Must Query | |-----------------|---------|------------| | Conditional (`if`) | `if (acc.Industry == 'Tech')` | `Industry` | | Assignment | `acc.Description = 'Text'` | `Description` | | Variable assignment | `String name = acc.Name` | `Name` | | Method argument | `sendEmail(acc.Email__c)` | `Email__c` | | Collection key | `map.put(acc.Name, acc)` | `Name` | | String interpolation | `'Hello ' + acc.Name` | `Name` | | SOQL bind | `[SELECT Id FROM Contact WHERE AccountId = :acc.Id]` | `Id` (usually included) | --- ## Relationship Field Access ### Rule: Parent relationship fields require dot notation in SELECT ### BAD: Missing Relationship Fields ```apex // Contact query without Account relationship fields List contacts = [SELECT Id, Name, AccountId FROM Contact]; for (Contact c : contacts) { // RUNTIME ERROR: Account.Name was not queried String accountName = c.Account.Name; // RUNTIME ERROR: Account.Industry was not queried if (c.Account.Industry == 'Technology') { // ... } } ``` ### GOOD: Include Relationship Fields ```apex // Use dot notation to include parent fields List contacts = [ SELECT Id, Name, AccountId, Account.Name, // Parent field Account.Industry, // Parent field Account.Owner.Name // Grandparent field (up to 5 levels) FROM Contact ]; for (Contact c : contacts) { String accountName = c.Account.Name; // OK - queried if (c.Account.Industry == 'Technology') { // OK - queried String ownerName = c.Account.Owner.Name; // OK - queried } } ``` ### Relationship Traversal Limits | Direction | Limit | Example | |-----------|-------|---------| | Parent (lookup/master-detail) | 5 levels | `Contact.Account.Owner.Manager.Name` | | Child (subquery) | 1 level | `Account -> Contacts` (cannot nest subqueries) | ### BAD: Assuming Relationship is Populated ```apex List contacts = [SELECT Id, AccountId FROM Contact]; for (Contact c : contacts) { // AccountId is queried, but Account object is NOT populated // This will throw: Account.Name not queried if (c.Account != null) { String name = c.Account.Name; // ERROR! } } ``` ### GOOD: Query Relationship or Use Separate Query ```apex // Option 1: Include relationship field List contacts = [SELECT Id, AccountId, Account.Name FROM Contact]; for (Contact c : contacts) { if (c.Account != null) { String name = c.Account.Name; // OK } } // Option 2: Separate query using collected IDs List contacts = [SELECT Id, AccountId FROM Contact]; Set accountIds = new Set(); for (Contact c : contacts) { if (c.AccountId != null) { accountIds.add(c.AccountId); } } Map accountMap = new Map( [SELECT Id, Name FROM Account WHERE Id IN :accountIds] ); for (Contact c : contacts) { Account acc = accountMap.get(c.AccountId); if (acc != null) { String name = acc.Name; // OK } } ``` --- ## Dynamic Field Access ### Rule: Dynamic field access (using `get()`) also requires queried fields ### BAD: Dynamic Access to Unqueried Field ```apex List accounts = [SELECT Id, Name FROM Account]; String fieldName = 'Industry'; // Dynamic field name for (Account acc : accounts) { // RUNTIME ERROR: Industry was not queried Object value = acc.get(fieldName); } ``` ### GOOD: Query Fields Used Dynamically ```apex // If you know which fields will be accessed dynamically, query them List accounts = [SELECT Id, Name, Industry FROM Account]; String fieldName = 'Industry'; for (Account acc : accounts) { Object value = acc.get(fieldName); // OK - Industry is queried } ``` ### GOOD: Build Dynamic Query ```apex // For truly dynamic scenarios, build the query dynamically Set fieldsToQuery = new Set{'Id', 'Name'}; fieldsToQuery.addAll(dynamicFieldList); // Add dynamic fields String query = 'SELECT ' + String.join(new List(fieldsToQuery), ', ') + ' FROM Account WHERE Id IN :accountIds'; List accounts = Database.query(query); ``` --- ## Aggregate Queries ### Rule: Aggregate queries return `AggregateResult`, not sObjects ### BAD: Treating Aggregate as sObject ```apex // This returns AggregateResult, not Account List accounts = [ SELECT Industry, COUNT(Id) cnt FROM Account GROUP BY Industry ]; // COMPILE ERROR - wrong type // Even with correct type, can't access normal fields AggregateResult[] results = [ SELECT Industry, COUNT(Id) cnt FROM Account GROUP BY Industry ]; for (AggregateResult ar : results) { // Cannot access like sObject fields String industry = ar.Industry; // COMPILE ERROR } ``` ### GOOD: Use get() for Aggregate Results ```apex AggregateResult[] results = [ SELECT Industry, COUNT(Id) cnt, SUM(AnnualRevenue) totalRevenue FROM Account GROUP BY Industry ]; for (AggregateResult ar : results) { // Use get() with field alias String industry = (String) ar.get('Industry'); Integer count = (Integer) ar.get('cnt'); Decimal totalRevenue = (Decimal) ar.get('totalRevenue'); System.debug(industry + ': ' + count + ' accounts, $' + totalRevenue); } ``` ### Aggregate Field Aliases | Function | Default Alias | Example | |----------|---------------|---------| | `COUNT(Field)` | `expr0`, `expr1`, etc. | Use explicit alias: `COUNT(Id) cnt` | | `SUM(Field)` | `expr0`, `expr1`, etc. | Use explicit alias: `SUM(Amount) total` | | `AVG(Field)` | `expr0`, `expr1`, etc. | Use explicit alias: `AVG(Age) avgAge` | | `MIN(Field)` | `expr0`, `expr1`, etc. | Use explicit alias: `MIN(CreatedDate) earliest` | | `MAX(Field)` | `expr0`, `expr1`, etc. | Use explicit alias: `MAX(Amount) largest` | | `GROUP BY Field` | Field API name | Access with field name: `ar.get('Industry')` | --- ## Subquery Fields ### Rule: Child relationship subqueries create nested lists ### BAD: Accessing Subquery Fields Incorrectly ```apex // Query with contact subquery List accounts = [ SELECT Id, Name, (SELECT Id, Name FROM Contacts) FROM Account ]; for (Account acc : accounts) { // ERROR: Contacts is a List, not a single Contact String contactName = acc.Contacts.Name; // ERROR: Cannot access unqueried field from subquery for (Contact c : acc.Contacts) { String email = c.Email; // Email not in subquery SELECT! } } ``` ### GOOD: Proper Subquery Field Access ```apex // Query all needed fields in subquery List accounts = [ SELECT Id, Name, (SELECT Id, Name, Email, Phone FROM Contacts) FROM Account ]; for (Account acc : accounts) { // Contacts is a List List contacts = acc.Contacts; if (contacts != null && !contacts.isEmpty()) { for (Contact c : contacts) { String name = c.Name; // OK - in subquery SELECT String email = c.Email; // OK - in subquery SELECT String phone = c.Phone; // OK - in subquery SELECT } } } ``` ### Subquery Null Safety ```apex List accounts = [ SELECT Id, (SELECT Id FROM Contacts) FROM Account ]; for (Account acc : accounts) { // Subquery result can be null if no child records if (acc.Contacts != null) { for (Contact c : acc.Contacts) { // Process contact } } // Or use null-safe size check Integer contactCount = acc.Contacts?.size() ?? 0; } ``` --- ## Validation Patterns ### Pattern 1: Field-to-Query Mapping Create a systematic approach to track field usage: ```apex public class AccountProcessor { // Document required fields at the top private static final Set REQUIRED_FIELDS = new Set{ 'Id', 'Name', 'Industry', 'Description', 'AnnualRevenue', 'OwnerId', 'Owner.Name', 'Owner.Email' }; // Single method for consistent querying public static List queryAccounts(Set accountIds) { return [ SELECT Id, Name, Industry, Description, AnnualRevenue, OwnerId, Owner.Name, Owner.Email FROM Account WHERE Id IN :accountIds ]; } public static void processAccounts(List accounts) { for (Account acc : accounts) { // All fields in REQUIRED_FIELDS are safe to access if (acc.Industry == 'Technology') { acc.Description = 'Tech: ' + acc.Name; } } } } ``` ### Pattern 2: Selector Layer Use a selector pattern to centralize query field management: ```apex public class AccountSelector { // Default fields for most operations private static final List DEFAULT_FIELDS = new List{ 'Id', 'Name', 'Industry', 'Type', 'OwnerId' }; // Extended fields for detailed views private static final List DETAIL_FIELDS = new List{ 'Id', 'Name', 'Industry', 'Type', 'OwnerId', 'Description', 'AnnualRevenue', 'NumberOfEmployees', 'BillingCity', 'BillingState', 'BillingCountry', 'Owner.Name', 'Owner.Email' }; public List selectById(Set ids) { return selectByIdWithFields(ids, DEFAULT_FIELDS); } public List selectByIdDetailed(Set ids) { return selectByIdWithFields(ids, DETAIL_FIELDS); } private List selectByIdWithFields(Set ids, List fields) { String query = 'SELECT ' + String.join(fields, ', ') + ' FROM Account WHERE Id IN :ids'; return Database.query(query); } } ``` ### Pattern 3: Field Validation Helper ```apex public class SObjectFieldValidator { /** * Check if a field was queried on an sObject * @param obj The sObject to check * @param fieldName The API name of the field * @return true if the field is populated (was queried) */ public static Boolean isFieldPopulated(SObject obj, String fieldName) { try { obj.get(fieldName); return true; } catch (SObjectException e) { return false; } } /** * Get field value with default if not queried * @param obj The sObject * @param fieldName The field API name * @param defaultValue Value to return if field not queried * @return The field value or default */ public static Object getFieldOrDefault(SObject obj, String fieldName, Object defaultValue) { try { Object value = obj.get(fieldName); return value != null ? value : defaultValue; } catch (SObjectException e) { return defaultValue; } } } ``` --- ## Quick Reference: Field Coverage Checklist Before running code that processes SOQL results: ### Direct Fields - [ ] All fields in `if` conditions are queried - [ ] All fields on left side of assignments are queried - [ ] All fields passed to methods are queried - [ ] All fields used in map keys/values are queried ### Relationship Fields - [ ] Parent fields use dot notation (e.g., `Account.Name`) - [ ] Parent object null checks before field access - [ ] Relationship traversal doesn't exceed 5 levels ### Subqueries - [ ] Child records accessed as List, not single record - [ ] Subquery SELECT includes all accessed child fields - [ ] Null check before iterating subquery results ### Dynamic Access - [ ] Fields accessed via `get(fieldName)` are queried - [ ] Dynamic queries include all needed fields --- ## Common LLM Mistakes Summary | Mistake | Example | Fix | |---------|---------|-----| | Query subset, use superset | Query `Id, Name`, use `Industry` | Add `Industry` to SELECT | | Forget relationship field | Use `c.Account.Name` without querying | Add `Account.Name` to SELECT | | Assume AccountId = Account | Query `AccountId`, access `Account.Name` | Query `Account.Name` explicitly | | Wrong subquery access | `acc.Contacts.Email` | `for (Contact c : acc.Contacts) { c.Email }` | | Missing subquery field | Subquery `SELECT Id`, use `Email` | Add `Email` to subquery SELECT | --- ## Reference - **SOQL Anti-Patterns**: See `references/anti-patterns.md` for general SOQL mistakes - **Selector Patterns**: See `references/selector-patterns.md` for query organization