
Platform Soql Query
- 2.4k installs
- 763 repo stars
- Updated July 24, 2026
- forcedotcom/sf-skills
Runs SOQL queries against a Salesforce org so a solo builder can read and inspect platform data directly instead of clicking through record pages.
About
An official Salesforce skill that executes SOQL queries against a target org, returning records so a developer can read and inspect platform data programmatically. A solo builder reaches for it when they need to pull or verify Salesforce data - checking records, debugging state, or exporting results - without navigating the org's UI record by record.
- Runs SOQL queries on an org
- Reads and inspects Salesforce data
- Returns records via the CLI
- Official forcedotcom skill set
Platform Soql Query by the numbers
- 2,386 all-time installs (skills.sh)
- +610 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #45 of 923 Databases skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/forcedotcom/sf-skills --skill platform-soql-queryAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.4k |
|---|---|
| repo stars | ★ 763 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 24, 2026 |
| Repository | forcedotcom/sf-skills ↗ |
What it does
Runs SOQL queries against a Salesforce org so a solo builder can read and inspect platform data directly instead of clicking through record pages.
Who is it for?
Querying Salesforce data with SOQL
Skip if: Non-Salesforce databases
What you get
- query results
Files
platform-soql-query: Salesforce SOQL Query Expert
Use this skill when the user needs SOQL/SOSL authoring or optimization: natural-language-to-query generation, relationship queries, aggregates, query-plan analysis, and performance/safety improvements for Salesforce queries.
When This Skill Owns the Task
Use platform-soql-query when the work involves:
.soqlfiles- query generation from natural language
- relationship queries and aggregate queries
- query optimization and selectivity analysis
- SOQL/SOSL syntax and governor-aware design
Delegate elsewhere when the user is:
- performing bulk data operations → platform-data-manage
- embedding query logic inside broader Apex implementation → platform-apex-generate
- debugging via logs rather than query shape → platform-apex-logs-debug
---
Required Context to Gather First
Ask for or infer:
- target object(s)
- fields needed
- filter criteria
- sort / limit requirements
- whether the query is for display, automation, reporting-like analysis, or Apex usage
- whether performance / selectivity is already a concern
---
Recommended Workflow
1. Generate the simplest correct query
Prefer:
- only needed fields
- clear WHERE criteria
- reasonable LIMIT when appropriate
- relationship depth only as deep as necessary
2. Choose the right query shape
| Need | Default pattern |
|---|---|
| parent data from child | child-to-parent traversal |
| child rows from parent | subquery |
| counts / rollups | aggregate query |
| records with / without related rows | semi-join / anti-join |
| text search across objects | SOSL |
3. Optimize for selectivity and safety
Check:
- indexed / selective filters
- no unnecessary fields
- no avoidable wildcard or scan-heavy patterns
- security enforcement expectations
4. Validate execution path if needed
If the user wants runtime verification, hand off execution to:
- platform-data-manage
---
High-Signal Rules
- never use
SELECT *style thinking; query only required fields - do not query inside loops in Apex contexts
- prefer filtering in SOQL rather than post-filtering in Apex
- use aggregates for counts and grouped summaries instead of loading unnecessary records
- evaluate wildcard usage carefully; leading wildcards often defeat indexes
- account for security mode / field access requirements when queries move into Apex
---
Output Format
When finishing, report in this order: 1. Query purpose 2. Final SOQL/SOSL 3. Why this shape was chosen 4. Optimization or security notes 5. Execution suggestion if needed
Suggested shape — use references/soql-syntax-reference.md for exact syntax:
Query goal: <summary>
Query: <soql or sosl>
Design: <relationship / aggregate / filter choices>
Notes: <selectivity, limits, security, governor awareness>
Next step: <run in platform-data-manage or embed in Apex>---
Cross-Skill Integration
| Need | Delegate to | Reason |
|---|---|---|
| run the query against an org | platform-data-manage | execution and export |
| embed the query in services/selectors | platform-apex-generate | implementation context |
| analyze slow-query symptoms from logs | platform-apex-logs-debug | runtime evidence |
| wire query-backed UI | experience-lwc-generate | frontend integration |
---
Score Guide
| Score | Meaning |
|---|---|
| 90+ | production-optimized query |
| 80–89 | good query with minor improvements possible |
| 70–79 | functional but performance concerns remain |
| < 70 | needs revision before production use |
---
Reference File Index
| File | When to read |
|---|---|
references/soql-syntax-reference.md | Syntax, operators, date literals, relationship query patterns |
references/query-optimization.md | Selectivity rules, indexing strategy, governor limits, security patterns |
references/soql-reference.md | Quick reference — operators, date functions, aggregate functions, WITH clauses |
references/anti-patterns.md | Common SOQL mistakes and their fixes — read before finalizing any query |
references/selector-patterns.md | Apex selector layer patterns — read when embedding queries in Apex classes |
references/field-coverage-rules.md | Field coverage validation — read when generating SOQL used inside Apex code |
references/cli-commands.md | sf CLI query execution, bulk export, query plan commands |
assets/basic-queries.soql | Starter query examples for common objects |
assets/relationship-queries.soql | Parent-to-child and child-to-parent relationship query patterns |
assets/aggregate-queries.soql | COUNT, SUM, GROUP BY, ROLLUP query patterns |
assets/optimization-patterns.soql | Selective filter and index-aware query patterns |
assets/bulkified-query-pattern.cls | Apex Map-based bulk query pattern for trigger contexts |
assets/selector-class.cls | Full selector class implementation template |
scripts/post-tool-validate.py | Post-write hook — runs static SOQL validation and live query plan analysis after .soql file edits |
/**
* AGGREGATE QUERY PATTERNS
*
* SOQL aggregate functions:
* - COUNT(), COUNT(field), COUNT_DISTINCT(field)
* - SUM(field), AVG(field), MIN(field), MAX(field)
*
* Use with GROUP BY for grouping results.
*/
// ═══════════════════════════════════════════════════════════════════════════
// BASIC COUNT
// ═══════════════════════════════════════════════════════════════════════════
-- Count all records
SELECT COUNT() FROM Account
-- Count with filter
SELECT COUNT() FROM Contact WHERE Email != null
-- Count with field alias
SELECT COUNT(Id) total FROM Account
-- Count distinct values
SELECT COUNT_DISTINCT(Industry) FROM Account
// ═══════════════════════════════════════════════════════════════════════════
// SUM, AVG, MIN, MAX
// ═══════════════════════════════════════════════════════════════════════════
-- Sum of amounts
SELECT SUM(Amount) totalAmount FROM Opportunity WHERE IsClosed = true
-- Average amount
SELECT AVG(Amount) avgAmount FROM Opportunity WHERE StageName = 'Closed Won'
-- Min and Max
SELECT MIN(Amount) smallest, MAX(Amount) largest
FROM Opportunity
WHERE IsClosed = true
-- Combined aggregates
SELECT
COUNT(Id) totalOpps,
SUM(Amount) totalAmount,
AVG(Amount) avgAmount,
MIN(Amount) minAmount,
MAX(Amount) maxAmount
FROM Opportunity
WHERE StageName = 'Closed Won'
AND CALENDAR_YEAR(CloseDate) = 2024
// ═══════════════════════════════════════════════════════════════════════════
// GROUP BY
// ═══════════════════════════════════════════════════════════════════════════
-- Count by single field
SELECT Industry, COUNT(Id)
FROM Account
GROUP BY Industry
-- Count by multiple fields
SELECT Industry, Type, COUNT(Id)
FROM Account
GROUP BY Industry, Type
-- Sum by category
SELECT StageName, SUM(Amount)
FROM Opportunity
GROUP BY StageName
-- Average by owner
SELECT OwnerId, AVG(Amount)
FROM Opportunity
GROUP BY OwnerId
-- Aggregate by date part
SELECT CALENDAR_MONTH(CloseDate), SUM(Amount)
FROM Opportunity
WHERE CALENDAR_YEAR(CloseDate) = 2024
GROUP BY CALENDAR_MONTH(CloseDate)
ORDER BY CALENDAR_MONTH(CloseDate)
-- Year and Month grouping
SELECT CALENDAR_YEAR(CloseDate) yr, CALENDAR_MONTH(CloseDate) mn, SUM(Amount)
FROM Opportunity
GROUP BY CALENDAR_YEAR(CloseDate), CALENDAR_MONTH(CloseDate)
ORDER BY CALENDAR_YEAR(CloseDate), CALENDAR_MONTH(CloseDate)
// ═══════════════════════════════════════════════════════════════════════════
// HAVING CLAUSE
// ═══════════════════════════════════════════════════════════════════════════
-- Filter groups by aggregate result
SELECT Industry, COUNT(Id) cnt
FROM Account
GROUP BY Industry
HAVING COUNT(Id) > 10
-- Multiple HAVING conditions
SELECT OwnerId, SUM(Amount) total, COUNT(Id) cnt
FROM Opportunity
WHERE StageName = 'Closed Won'
GROUP BY OwnerId
HAVING SUM(Amount) > 100000
AND COUNT(Id) >= 5
-- HAVING with different aggregate than SELECT
SELECT LeadSource, COUNT(Id)
FROM Lead
GROUP BY LeadSource
HAVING AVG(NumberOfEmployees) > 100
// ═══════════════════════════════════════════════════════════════════════════
// GROUP BY ROLLUP (Subtotals)
// ═══════════════════════════════════════════════════════════════════════════
-- Single rollup
SELECT Industry, COUNT(Id)
FROM Account
GROUP BY ROLLUP(Industry)
-- Returns: individual industries + grand total (null Industry row)
-- Multiple field rollup
SELECT Industry, Type, COUNT(Id)
FROM Account
GROUP BY ROLLUP(Industry, Type)
-- Returns: Industry+Type combos, Industry subtotals, grand total
-- With SUM
SELECT StageName, CALENDAR_MONTH(CloseDate), SUM(Amount)
FROM Opportunity
WHERE CALENDAR_YEAR(CloseDate) = 2024
GROUP BY ROLLUP(StageName, CALENDAR_MONTH(CloseDate))
// ═══════════════════════════════════════════════════════════════════════════
// GROUP BY CUBE (All Combinations)
// ═══════════════════════════════════════════════════════════════════════════
-- All dimension combinations
SELECT Industry, Type, COUNT(Id)
FROM Account
GROUP BY CUBE(Industry, Type)
-- Returns: Industry+Type, Industry only, Type only, grand total
// ═══════════════════════════════════════════════════════════════════════════
// DATE FUNCTIONS IN AGGREGATES
// ═══════════════════════════════════════════════════════════════════════════
-- By calendar year
SELECT CALENDAR_YEAR(CreatedDate) yr, COUNT(Id)
FROM Account
GROUP BY CALENDAR_YEAR(CreatedDate)
-- By calendar quarter
SELECT CALENDAR_YEAR(CloseDate), CALENDAR_QUARTER(CloseDate), SUM(Amount)
FROM Opportunity
GROUP BY CALENDAR_YEAR(CloseDate), CALENDAR_QUARTER(CloseDate)
-- By fiscal year (if fiscal year defined)
SELECT FISCAL_YEAR(CloseDate), SUM(Amount)
FROM Opportunity
GROUP BY FISCAL_YEAR(CloseDate)
-- By week
SELECT WEEK_IN_YEAR(CreatedDate), COUNT(Id)
FROM Lead
WHERE CALENDAR_YEAR(CreatedDate) = 2024
GROUP BY WEEK_IN_YEAR(CreatedDate)
-- By day of week
SELECT DAY_IN_WEEK(CreatedDate), COUNT(Id)
FROM Case
WHERE CreatedDate = THIS_MONTH
GROUP BY DAY_IN_WEEK(CreatedDate)
-- 1=Sunday, 2=Monday, etc.
// ═══════════════════════════════════════════════════════════════════════════
// COMMON AGGREGATE PATTERNS
// ═══════════════════════════════════════════════════════════════════════════
-- Sales by rep this year
SELECT Owner.Name, SUM(Amount) totalSales, COUNT(Id) dealCount
FROM Opportunity
WHERE StageName = 'Closed Won'
AND CloseDate = THIS_YEAR
GROUP BY Owner.Name
ORDER BY SUM(Amount) DESC
-- Lead conversion rate by source
SELECT LeadSource, COUNT(Id) total, SUM(CASE WHEN IsConverted = true THEN 1 ELSE 0 END) converted
FROM Lead
GROUP BY LeadSource
-- Note: CASE not supported in SOQL. Calculate in Apex.
-- Cases by status and priority
SELECT Status, Priority, COUNT(Id)
FROM Case
WHERE CreatedDate = THIS_MONTH
GROUP BY Status, Priority
-- Average deal size by industry
SELECT Account.Industry, AVG(Amount)
FROM Opportunity
WHERE StageName = 'Closed Won'
GROUP BY Account.Industry
HAVING AVG(Amount) > 50000
-- Top accounts by opportunity count
SELECT AccountId, Account.Name, COUNT(Id) oppCount
FROM Opportunity
GROUP BY AccountId, Account.Name
ORDER BY COUNT(Id) DESC
LIMIT 20
-- Pipeline by stage and month
SELECT StageName, CALENDAR_MONTH(CloseDate) closeMonth, SUM(Amount)
FROM Opportunity
WHERE IsClosed = false
AND CloseDate = THIS_QUARTER
GROUP BY StageName, CALENDAR_MONTH(CloseDate)
ORDER BY CALENDAR_MONTH(CloseDate), StageName
// ═══════════════════════════════════════════════════════════════════════════
// AGGREGATE IN APEX
// ═══════════════════════════════════════════════════════════════════════════
/*
// In Apex, aggregate results are returned as AggregateResult[]
List<AggregateResult> results = [
SELECT Industry, COUNT(Id) cnt, SUM(AnnualRevenue) total
FROM Account
GROUP BY Industry
];
for (AggregateResult ar : results) {
String industry = (String) ar.get('Industry');
Integer count = (Integer) ar.get('cnt');
Decimal total = (Decimal) ar.get('total');
}
*/
/**
* BASIC SOQL QUERY PATTERNS
*
* This file contains common SOQL patterns for everyday queries.
* Each pattern includes the query and usage notes.
*/
// ═══════════════════════════════════════════════════════════════════════════
// SIMPLE SELECT QUERIES
// ═══════════════════════════════════════════════════════════════════════════
-- Get accounts with specific fields
SELECT Id, Name, Industry, AnnualRevenue, Phone
FROM Account
LIMIT 100
-- Get contacts with email
SELECT Id, FirstName, LastName, Email, Phone
FROM Contact
WHERE Email != null
LIMIT 100
-- Get opportunities by stage
SELECT Id, Name, Amount, StageName, CloseDate
FROM Opportunity
WHERE StageName = 'Prospecting'
ORDER BY Amount DESC
// ═══════════════════════════════════════════════════════════════════════════
// DATE FILTERING
// ═══════════════════════════════════════════════════════════════════════════
-- Records created today
SELECT Id, Name FROM Account
WHERE CreatedDate = TODAY
-- Records created this week
SELECT Id, Name FROM Lead
WHERE CreatedDate = THIS_WEEK
-- Records created in last 30 days
SELECT Id, Name, CreatedDate FROM Contact
WHERE CreatedDate = LAST_N_DAYS:30
ORDER BY CreatedDate DESC
-- Records modified this month
SELECT Id, Name, LastModifiedDate FROM Account
WHERE LastModifiedDate = THIS_MONTH
-- Opportunities closing this quarter
SELECT Id, Name, Amount, CloseDate
FROM Opportunity
WHERE CloseDate = THIS_QUARTER
AND IsClosed = false
-- Records created in specific date range
SELECT Id, Name, CreatedDate FROM Account
WHERE CreatedDate >= 2024-01-01T00:00:00Z
AND CreatedDate < 2024-04-01T00:00:00Z
// ═══════════════════════════════════════════════════════════════════════════
// TEXT FILTERING
// ═══════════════════════════════════════════════════════════════════════════
-- Exact match
SELECT Id, Name FROM Account
WHERE Name = 'Acme Corporation'
-- Case-insensitive exact match (default behavior)
SELECT Id, Name FROM Account
WHERE Name = 'acme corporation'
-- Starts with pattern
SELECT Id, Name FROM Account
WHERE Name LIKE 'Acme%'
-- Contains pattern
SELECT Id, Name FROM Account
WHERE Name LIKE '%Corp%'
-- Ends with pattern (NOT indexed - use sparingly)
SELECT Id, Name FROM Account
WHERE Name LIKE '%Inc.'
-- Multiple values
SELECT Id, Name, Industry FROM Account
WHERE Industry IN ('Technology', 'Finance', 'Healthcare')
-- Exclude values
SELECT Id, Name, Industry FROM Account
WHERE Industry NOT IN ('Other', 'Unknown')
// ═══════════════════════════════════════════════════════════════════════════
// NUMERIC FILTERING
// ═══════════════════════════════════════════════════════════════════════════
-- Greater than
SELECT Id, Name, AnnualRevenue FROM Account
WHERE AnnualRevenue > 1000000
-- Range
SELECT Id, Name, Amount FROM Opportunity
WHERE Amount >= 10000 AND Amount <= 100000
-- Not equal
SELECT Id, Name, Amount FROM Opportunity
WHERE Amount != 0
// ═══════════════════════════════════════════════════════════════════════════
// NULL HANDLING
// ═══════════════════════════════════════════════════════════════════════════
-- Find records with null value
SELECT Id, Name FROM Contact
WHERE Email = null
-- Find records with non-null value
SELECT Id, Name, Email FROM Contact
WHERE Email != null
-- Find accounts without opportunities (using NOT IN)
SELECT Id, Name FROM Account
WHERE Id NOT IN (SELECT AccountId FROM Opportunity)
// ═══════════════════════════════════════════════════════════════════════════
// SORTING
// ═══════════════════════════════════════════════════════════════════════════
-- Sort ascending (default)
SELECT Id, Name FROM Account
ORDER BY Name ASC
-- Sort descending
SELECT Id, Name, AnnualRevenue FROM Account
ORDER BY AnnualRevenue DESC
-- Multiple sort fields
SELECT Id, Name, Industry, AnnualRevenue FROM Account
ORDER BY Industry ASC, AnnualRevenue DESC
-- Nulls last
SELECT Id, Name, AnnualRevenue FROM Account
ORDER BY AnnualRevenue DESC NULLS LAST
// ═══════════════════════════════════════════════════════════════════════════
// PAGINATION
// ═══════════════════════════════════════════════════════════════════════════
-- First page (records 1-100)
SELECT Id, Name FROM Account
ORDER BY Name
LIMIT 100
-- Second page (records 101-200)
SELECT Id, Name FROM Account
ORDER BY Name
LIMIT 100
OFFSET 100
-- Note: OFFSET max is 2000. For larger datasets, use query locator in Apex.
// ═══════════════════════════════════════════════════════════════════════════
// COMBINED PATTERNS
// ═══════════════════════════════════════════════════════════════════════════
-- Active technology accounts with high revenue created this year
SELECT Id, Name, Industry, AnnualRevenue, CreatedDate
FROM Account
WHERE Industry = 'Technology'
AND AnnualRevenue > 500000
AND IsActive__c = true
AND CreatedDate = THIS_YEAR
ORDER BY AnnualRevenue DESC
LIMIT 50
-- Contacts with Gmail addresses at accounts in California
SELECT Id, FirstName, LastName, Email, Account.Name
FROM Contact
WHERE Email LIKE '%@gmail.com'
AND Account.BillingState = 'CA'
ORDER BY LastName
-- Open cases older than 7 days
SELECT Id, CaseNumber, Subject, Status, CreatedDate, Owner.Name
FROM Case
WHERE IsClosed = false
AND CreatedDate < LAST_N_DAYS:7
ORDER BY CreatedDate ASC
/**
* Bulkified Query Patterns
*
* This file demonstrates the correct patterns for querying related data
* in bulk operations (triggers, batches, etc.) without hitting governor limits.
*
* Key Pattern: Collect IDs first, query once, use Map for O(1) lookups.
*
* @see https://www.apexhours.com/bulkification-of-apex-triggers/
* @see https://medium.com/@saurabh.samirs/salesforce-apex-triggers-5-bulkification-patterns-to-avoid-soql-dml-limits-f4e9c8bbfb3a
*/
// ═══════════════════════════════════════════════════════════════════════════
// PATTERN 1: Simple Map Lookup (Parent Record Access)
// ═══════════════════════════════════════════════════════════════════════════
/**
* ❌ ANTI-PATTERN: Query inside loop
* This code will fail with "Too many SOQL queries: 101" for 101+ records
*/
public class AntiPatternExample {
public void processContacts_WRONG(List<Contact> contacts) {
for (Contact c : contacts) {
// ❌ QUERY IN LOOP - will hit governor limit!
Account a = [SELECT Name FROM Account WHERE Id = :c.AccountId];
c.Description = 'Account: ' + a.Name;
}
}
}
/**
* ✅ CORRECT PATTERN: Bulk query with Map lookup
* Uses 1 query regardless of record count
*/
public class BulkPatternExample {
public void processContacts_CORRECT(List<Contact> contacts) {
// Step 1: Collect all parent IDs
Set<Id> accountIds = new Set<Id>();
for (Contact c : contacts) {
if (c.AccountId != null) {
accountIds.add(c.AccountId);
}
}
// Step 2: Single bulk query
Map<Id, Account> accountMap = new Map<Id, Account>([
SELECT Id, Name
FROM Account
WHERE Id IN :accountIds
WITH SECURITY_ENFORCED
]);
// Step 3: O(1) Map lookups
for (Contact c : contacts) {
Account a = accountMap.get(c.AccountId);
if (a != null) {
c.Description = 'Account: ' + a.Name;
}
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// PATTERN 2: Grouped Child Records (One-to-Many)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Get child records grouped by parent ID
* Useful when you need all children for each parent
*/
public class GroupedChildPattern {
/**
* Returns Contacts grouped by their AccountId
*/
public static Map<Id, List<Contact>> getContactsByAccountId(Set<Id> accountIds) {
Map<Id, List<Contact>> contactsByAccount = new Map<Id, List<Contact>>();
// Initialize empty lists for each account
for (Id accountId : accountIds) {
contactsByAccount.put(accountId, new List<Contact>());
}
// Single query for all contacts
for (Contact c : [
SELECT Id, FirstName, LastName, Email, AccountId
FROM Contact
WHERE AccountId IN :accountIds
WITH SECURITY_ENFORCED
]) {
contactsByAccount.get(c.AccountId).add(c);
}
return contactsByAccount;
}
/**
* Usage example in a trigger
*/
public void onAccountUpdate(List<Account> accounts) {
Set<Id> accountIds = new Set<Id>();
for (Account a : accounts) {
accountIds.add(a.Id);
}
// Get all contacts for all accounts in ONE query
Map<Id, List<Contact>> contactsByAccount = getContactsByAccountId(accountIds);
for (Account a : accounts) {
List<Contact> accountContacts = contactsByAccount.get(a.Id);
System.debug('Account ' + a.Name + ' has ' + accountContacts.size() + ' contacts');
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// PATTERN 3: Multi-Level Relationship Lookup
// ═══════════════════════════════════════════════════════════════════════════
/**
* When you need to traverse multiple relationship levels
* Example: Contact -> Account -> Owner
*/
public class MultiLevelLookupPattern {
public void enrichContactsWithOwnerInfo(List<Contact> contacts) {
// Step 1: Get Account IDs
Set<Id> accountIds = new Set<Id>();
for (Contact c : contacts) {
if (c.AccountId != null) {
accountIds.add(c.AccountId);
}
}
// Step 2: Query Accounts with Owner info (single query)
Map<Id, Account> accountMap = new Map<Id, Account>([
SELECT Id, Name, OwnerId, Owner.Name, Owner.Email
FROM Account
WHERE Id IN :accountIds
WITH SECURITY_ENFORCED
]);
// Step 3: Use the data
for (Contact c : contacts) {
Account a = accountMap.get(c.AccountId);
if (a != null && a.Owner != null) {
c.Description = 'Account Owner: ' + a.Owner.Name;
}
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// PATTERN 4: Conditional Query Based on Field Values
// ═══════════════════════════════════════════════════════════════════════════
/**
* When you only need to query for certain records based on field values
*/
public class ConditionalQueryPattern {
public void updateHighValueOpportunities(List<Opportunity> opportunities) {
// Step 1: Filter to high-value opportunities only
Set<Id> highValueAccountIds = new Set<Id>();
for (Opportunity opp : opportunities) {
if (opp.Amount != null && opp.Amount > 100000 && opp.AccountId != null) {
highValueAccountIds.add(opp.AccountId);
}
}
// Step 2: Only query if we have high-value records
if (highValueAccountIds.isEmpty()) {
return; // No query needed!
}
Map<Id, Account> accountMap = new Map<Id, Account>([
SELECT Id, Name, AnnualRevenue, Industry
FROM Account
WHERE Id IN :highValueAccountIds
WITH SECURITY_ENFORCED
]);
// Step 3: Process only the high-value opportunities
for (Opportunity opp : opportunities) {
if (opp.Amount != null && opp.Amount > 100000) {
Account a = accountMap.get(opp.AccountId);
if (a != null) {
opp.Description = 'High-value opp for ' + a.Industry + ' account';
}
}
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// PATTERN 5: Avoiding Duplicate Queries in Complex Logic
// ═══════════════════════════════════════════════════════════════════════════
/**
* When the same data is needed in multiple methods,
* query once and pass the Map around
*/
public class SharedQueryPattern {
private Map<Id, Account> accountCache;
/**
* Initialize the cache once at the start of processing
*/
public void initializeCache(Set<Id> accountIds) {
this.accountCache = new Map<Id, Account>([
SELECT Id, Name, Industry, AnnualRevenue, OwnerId
FROM Account
WHERE Id IN :accountIds
WITH SECURITY_ENFORCED
]);
}
/**
* Multiple methods can use the cached data
*/
public String getAccountName(Id accountId) {
Account a = accountCache.get(accountId);
return a != null ? a.Name : null;
}
public String getAccountIndustry(Id accountId) {
Account a = accountCache.get(accountId);
return a != null ? a.Industry : null;
}
public Decimal getAccountRevenue(Id accountId) {
Account a = accountCache.get(accountId);
return a != null ? a.AnnualRevenue : 0;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// PATTERN 6: SOQL For Loop (Large Dataset Processing)
// ═══════════════════════════════════════════════════════════════════════════
/**
* For processing very large datasets without hitting heap limits
* The SOQL for loop fetches 200 records at a time
*/
public class LargeDatasetPattern {
public void processAllAccounts() {
// This fetches 200 records at a time, not all 50,000
for (Account a : [
SELECT Id, Name, Industry
FROM Account
WHERE CreatedDate = THIS_YEAR
]) {
// Process one record at a time
// Heap stays small because only 200 records in memory
processAccount(a);
}
}
public void processAllAccountsInBatches() {
// Alternative: explicit batch processing (200 at a time)
for (List<Account> accountBatch : [
SELECT Id, Name, Industry
FROM Account
WHERE CreatedDate = THIS_YEAR
]) {
// Process 200 records at a time
processBatch(accountBatch);
}
}
private void processAccount(Account a) {
// Individual record processing
}
private void processBatch(List<Account> accounts) {
// Batch processing
}
}
/**
* SOQL OPTIMIZATION PATTERNS
*
* These patterns demonstrate how to write efficient, selective queries
* that perform well within Salesforce governor limits.
*/
// ═══════════════════════════════════════════════════════════════════════════
// SELECTIVE vs NON-SELECTIVE QUERIES
// ═══════════════════════════════════════════════════════════════════════════
-- ❌ NON-SELECTIVE: Status alone is not indexed
SELECT Id, Name FROM Lead
WHERE Status = 'Open'
-- Scans ALL Lead records
-- ✅ SELECTIVE: Add indexed field filter
SELECT Id, Name FROM Lead
WHERE Status = 'Open'
AND CreatedDate = LAST_N_DAYS:30
-- CreatedDate is indexed, reduces scan
-- ❌ NON-SELECTIVE: Leading wildcard prevents index use
SELECT Id, Name FROM Account
WHERE Name LIKE '%corporation'
-- ✅ SELECTIVE: Trailing wildcard uses index
SELECT Id, Name FROM Account
WHERE Name LIKE 'Acme%'
-- ❌ NON-SELECTIVE: OR can prevent index optimization
SELECT Id FROM Contact
WHERE Email = 'test@test.com'
OR Phone = '555-1234'
-- ✅ SELECTIVE: Use UNION-style in Apex or multiple queries
-- Query 1: SELECT Id FROM Contact WHERE Email = 'test@test.com'
-- Query 2: SELECT Id FROM Contact WHERE Phone = '555-1234'
-- Combine results in Apex
// ═══════════════════════════════════════════════════════════════════════════
// INDEXED FIELDS
// ═══════════════════════════════════════════════════════════════════════════
-- Always indexed fields (use these in WHERE)
-- • Id (primary key)
-- • Name (standard Name field)
-- • OwnerId
-- • CreatedDate
-- • LastModifiedDate
-- • RecordTypeId
-- • External ID fields (custom)
-- • Master-Detail relationship fields
-- • Lookup fields (selective when <100k parent records)
-- ✅ GOOD: Uses indexed OwnerId
SELECT Id, Name FROM Account
WHERE OwnerId = '005xx000000XXXX'
-- ✅ GOOD: Uses indexed CreatedDate
SELECT Id, Name FROM Contact
WHERE CreatedDate >= 2024-01-01T00:00:00Z
-- ✅ GOOD: Uses indexed External ID
SELECT Id, Name FROM Account
WHERE External_Id__c = 'EXT-12345'
// ═══════════════════════════════════════════════════════════════════════════
// FIELD SELECTION OPTIMIZATION
// ═══════════════════════════════════════════════════════════════════════════
-- ❌ BAD: Selecting unnecessary fields
SELECT Id, Name, Description, BillingStreet, BillingCity, BillingState,
BillingPostalCode, BillingCountry, ShippingStreet, ShippingCity,
ShippingState, ShippingPostalCode, ShippingCountry, Phone, Fax,
AccountNumber, Website, Industry, AnnualRevenue, NumberOfEmployees,
Ownership, TickerSymbol, Description, Rating, Site, AccountSource
FROM Account
-- ✅ GOOD: Select only needed fields
SELECT Id, Name, Industry, AnnualRevenue
FROM Account
-- For display only: Use specific fields
SELECT Id, Name FROM Account
-- For processing: Query fields you'll update
SELECT Id, Status__c, Last_Processed__c FROM Account
// ═══════════════════════════════════════════════════════════════════════════
// LIMIT USAGE
// ═══════════════════════════════════════════════════════════════════════════
-- ❌ BAD: No limit (could return 50,000 rows)
SELECT Id, Name FROM Account
-- ✅ GOOD: Appropriate limit
SELECT Id, Name FROM Account LIMIT 200
-- For pagination
SELECT Id, Name FROM Account ORDER BY Name LIMIT 100 OFFSET 0
-- For single record lookup
SELECT Id, Name FROM Account WHERE Name = 'Acme Corp' LIMIT 1
-- For existence check
SELECT Id FROM Account WHERE Name = 'Acme Corp' LIMIT 1
-- In Apex: if (!results.isEmpty()) { /* exists */ }
// ═══════════════════════════════════════════════════════════════════════════
// RELATIONSHIP QUERY OPTIMIZATION
// ═══════════════════════════════════════════════════════════════════════════
-- ❌ BAD: Deep nesting (5 levels is max, avoid if possible)
SELECT Id, Account.Owner.Manager.Department.Name FROM Contact
-- ✅ GOOD: Flatten when possible
SELECT Id, Account.Name, Account.OwnerId FROM Contact
-- Then query Owner separately if needed
-- ❌ BAD: Too many child subqueries
SELECT Id, Name,
(SELECT Id FROM Contacts),
(SELECT Id FROM Opportunities),
(SELECT Id FROM Cases),
(SELECT Id FROM Tasks),
(SELECT Id FROM Events),
(SELECT Id FROM Notes)
FROM Account
-- ✅ GOOD: Query what you need
SELECT Id, Name,
(SELECT Id, Name FROM Contacts LIMIT 5)
FROM Account
-- ❌ BAD: Unfiltered subquery
SELECT Id, (SELECT Id FROM Opportunities) FROM Account
-- ✅ GOOD: Filtered subquery
SELECT Id, (SELECT Id FROM Opportunities WHERE StageName != 'Closed Lost' LIMIT 10)
FROM Account
// ═══════════════════════════════════════════════════════════════════════════
// BULK QUERY PATTERNS
// ═══════════════════════════════════════════════════════════════════════════
-- ❌ BAD: Query in loop
/*
for (Contact c : contacts) {
Account a = [SELECT Name FROM Account WHERE Id = :c.AccountId];
}
*/
-- ✅ GOOD: Bulk query with Map
/*
Set<Id> accountIds = new Set<Id>();
for (Contact c : contacts) {
accountIds.add(c.AccountId);
}
Map<Id, Account> accountMap = new Map<Id, Account>(
[SELECT Id, Name FROM Account WHERE Id IN :accountIds]
);
for (Contact c : contacts) {
Account a = accountMap.get(c.AccountId);
}
*/
-- Use IN clause for bulk operations
SELECT Id, Name FROM Account WHERE Id IN :accountIdSet
-- Bind variable with Set (most efficient)
SELECT Id, Name FROM Contact WHERE AccountId IN :accountIds
// ═══════════════════════════════════════════════════════════════════════════
// FOR UPDATE (Record Locking)
// ═══════════════════════════════════════════════════════════════════════════
-- Lock records for update (prevents concurrent modification)
SELECT Id, Status__c FROM Account WHERE Id = :accountId FOR UPDATE
-- Note: Only use FOR UPDATE when you need to prevent race conditions
-- Adds overhead and can cause lock contention
// ═══════════════════════════════════════════════════════════════════════════
// SECURITY ENFORCEMENT
// ═══════════════════════════════════════════════════════════════════════════
-- ✅ Recommended: WITH SECURITY_ENFORCED
SELECT Id, Name, Phone FROM Account
WITH SECURITY_ENFORCED
-- Throws exception if user lacks field access
-- Alternative: USER_MODE (API 54.0+)
SELECT Id, Name FROM Account
WITH USER_MODE
-- Respects sharing rules and FLS
-- For admin operations: SYSTEM_MODE
SELECT Id, Name FROM Account
WITH SYSTEM_MODE
-- Bypasses sharing (use with caution)
// ═══════════════════════════════════════════════════════════════════════════
// QUERY PLAN ANALYSIS
// ═══════════════════════════════════════════════════════════════════════════
/*
Use Developer Console or CLI to analyze query plans:
1. Developer Console:
- Query Editor > Check "Use Tooling API"
- Click "Query Plan" button
2. CLI (uses REST API explain endpoint):
sf api request rest '/query/?explain=SELECT+Id+FROM+Account+WHERE+Name='\''Test'\''' --target-org my-org --json
Plan Output:
- Cardinality: Estimated rows returned
- Fields: Index fields used
- LeadingOperationType:
- "Index" = Good (using index)
- "TableScan" = Bad (scanning all records)
- Cost: Relative cost (lower is better)
- sObjectCardinality: Total records in object
*/
// ═══════════════════════════════════════════════════════════════════════════
// LARGE DATA VOLUME PATTERNS
// ═══════════════════════════════════════════════════════════════════════════
-- For tables with millions of records:
-- 1. Always use indexed fields
SELECT Id FROM Account
WHERE External_Id__c = 'EXT-123'
-- 2. Use date range with limit
SELECT Id, Name FROM Contact
WHERE CreatedDate >= 2024-01-01T00:00:00Z
AND CreatedDate < 2024-02-01T00:00:00Z
LIMIT 10000
-- 3. Use skinny tables (configured by Salesforce Support)
-- Query fields included in skinny table for best performance
-- 4. Consider archiving old records
SELECT Id FROM Account
WHERE LastActivityDate < LAST_N_YEARS:2
AND CreatedDate < LAST_N_YEARS:3
-- 5. Use Database.query() with query locator for batch
/*
Database.QueryLocator ql = Database.getQueryLocator(
'SELECT Id, Name FROM Account WHERE CreatedDate = THIS_YEAR'
);
// Can process up to 50 million records in batch
*/
/**
* RELATIONSHIP QUERY PATTERNS
*
* SOQL supports two types of relationships:
* 1. Child-to-Parent (dot notation) - Traverse up
* 2. Parent-to-Child (subquery) - Traverse down
*/
// ═══════════════════════════════════════════════════════════════════════════
// CHILD-TO-PARENT (DOT NOTATION)
// ═══════════════════════════════════════════════════════════════════════════
-- Basic parent field access
SELECT Id, Name, Account.Name, Account.Industry
FROM Contact
-- Multiple levels up (max 5 levels)
SELECT Id, Name,
Account.Name,
Account.Owner.Name,
Account.Owner.Manager.Name
FROM Contact
-- Filter by parent field
SELECT Id, Name, Email
FROM Contact
WHERE Account.Industry = 'Technology'
AND Account.AnnualRevenue > 1000000
-- Custom object parent relationship (__r suffix)
SELECT Id, Name, Custom_Parent__r.Name, Custom_Parent__r.Status__c
FROM Custom_Child__c
-- Standard lookup relationships
SELECT Id, Subject, WhatId, What.Name
FROM Task
WHERE What.Type = 'Account'
SELECT Id, CaseNumber, Account.Name, Contact.Name
FROM Case
WHERE Account.Industry = 'Healthcare'
// ═══════════════════════════════════════════════════════════════════════════
// PARENT-TO-CHILD (SUBQUERIES)
// ═══════════════════════════════════════════════════════════════════════════
-- Basic child records
SELECT Id, Name,
(SELECT Id, FirstName, LastName, Email FROM Contacts)
FROM Account
LIMIT 100
-- Multiple child relationships
SELECT Id, Name,
(SELECT Id, Name, Email FROM Contacts),
(SELECT Id, Name, Amount, StageName FROM Opportunities),
(SELECT Id, CaseNumber, Subject FROM Cases)
FROM Account
WHERE Industry = 'Technology'
LIMIT 50
-- Filtered child records
SELECT Id, Name,
(SELECT Id, Name, Amount
FROM Opportunities
WHERE StageName = 'Closed Won'
AND Amount > 10000
ORDER BY Amount DESC
LIMIT 5)
FROM Account
-- Custom child relationship (__r suffix, plural)
SELECT Id, Name,
(SELECT Id, Name, Status__c FROM Custom_Children__r)
FROM Custom_Parent__c
-- Order in subquery
SELECT Id, Name,
(SELECT Id, FirstName, LastName, CreatedDate
FROM Contacts
ORDER BY CreatedDate DESC
LIMIT 10)
FROM Account
// ═══════════════════════════════════════════════════════════════════════════
// COMMON RELATIONSHIP NAMES
// ═══════════════════════════════════════════════════════════════════════════
-- Account relationships
SELECT Id, Name,
(SELECT Id FROM Contacts), -- Standard
(SELECT Id FROM Opportunities), -- Standard
(SELECT Id FROM Cases), -- Standard
(SELECT Id FROM Notes), -- Standard
(SELECT Id FROM Attachments), -- Standard
(SELECT Id FROM Tasks), -- Standard
(SELECT Id FROM Events) -- Standard
FROM Account
LIMIT 10
-- Contact relationships
SELECT Id, Name,
(SELECT Id FROM Cases),
(SELECT Id FROM Tasks),
(SELECT Id FROM Events),
(SELECT Id FROM OpportunityContactRoles)
FROM Contact
LIMIT 10
-- Opportunity relationships
SELECT Id, Name,
(SELECT Id FROM OpportunityLineItems),
(SELECT Id FROM OpportunityContactRoles),
(SELECT Id FROM Tasks)
FROM Opportunity
LIMIT 10
// ═══════════════════════════════════════════════════════════════════════════
// SEMI-JOINS AND ANTI-JOINS
// ═══════════════════════════════════════════════════════════════════════════
-- Semi-join: Accounts WITH contacts
SELECT Id, Name FROM Account
WHERE Id IN (SELECT AccountId FROM Contact)
-- Semi-join: Accounts WITH opportunities over $100k
SELECT Id, Name FROM Account
WHERE Id IN (
SELECT AccountId FROM Opportunity
WHERE Amount > 100000
AND StageName = 'Closed Won'
)
-- Anti-join: Accounts WITHOUT contacts
SELECT Id, Name FROM Account
WHERE Id NOT IN (SELECT AccountId FROM Contact WHERE AccountId != null)
-- Anti-join: Accounts WITHOUT opportunities
SELECT Id, Name FROM Account
WHERE Id NOT IN (SELECT AccountId FROM Opportunity WHERE AccountId != null)
-- Anti-join: Leads not converted
SELECT Id, Name, Email FROM Lead
WHERE IsConverted = false
AND Id NOT IN (SELECT LeadId FROM Contact WHERE LeadId != null)
-- Multiple semi-joins
SELECT Id, Name FROM Account
WHERE Id IN (SELECT AccountId FROM Contact WHERE Email != null)
AND Id IN (SELECT AccountId FROM Opportunity WHERE Amount > 50000)
// ═══════════════════════════════════════════════════════════════════════════
// POLYMORPHIC RELATIONSHIPS (TYPEOF)
// ═══════════════════════════════════════════════════════════════════════════
-- Basic polymorphic query
SELECT Id, Subject, Who.Name, What.Name
FROM Task
WHERE Who.Type = 'Contact'
AND What.Type = 'Opportunity'
-- TYPEOF for conditional fields
SELECT
Id,
Subject,
TYPEOF What
WHEN Account THEN Name, Industry, AnnualRevenue
WHEN Opportunity THEN Name, Amount, StageName
WHEN Case THEN CaseNumber, Subject, Status
END
FROM Task
WHERE What.Type IN ('Account', 'Opportunity', 'Case')
-- TYPEOF with ELSE clause
SELECT
Id,
TYPEOF Owner
WHEN User THEN FirstName, LastName, Email
WHEN Group THEN Name
ELSE Name
END
FROM Account
// ═══════════════════════════════════════════════════════════════════════════
// ADVANCED PATTERNS
// ═══════════════════════════════════════════════════════════════════════════
-- Get accounts with contact count > 5 (requires Apex filtering)
-- Note: SOQL can't filter by aggregate of subquery
SELECT Id, Name, (SELECT Id FROM Contacts)
FROM Account
-- Then filter in Apex: WHERE Contacts.size() > 5
-- Get hierarchical data (self-reference)
SELECT Id, Name, ParentId, Parent.Name, Parent.Parent.Name
FROM Account
WHERE ParentId != null
-- Get users with role hierarchy
SELECT Id, Name, Manager.Name, Manager.Manager.Name
FROM User
WHERE IsActive = true
AND ManagerId != null
/**
* ${OBJECT_NAME}Selector - Centralized SOQL queries for ${OBJECT_NAME}
*
* This selector class follows the Selector Layer pattern to:
* - Centralize all ${OBJECT_NAME} queries in one place
* - Enforce Field-Level Security (FLS) by default
* - Support query mocking in unit tests
* - Provide consistent field selection across the codebase
*
* @see https://blog.beyondthecloud.dev/blog/why-do-you-need-selector-layer
* @see https://www.jamessimone.net/blog/joys-of-apex/repository-pattern/
*/
public inherited sharing class ${OBJECT_NAME}Selector {
// ═══════════════════════════════════════════════════════════════════
// FIELD DEFINITIONS
// Centralize field lists to make updates easy
// ═══════════════════════════════════════════════════════════════════
/**
* Standard fields queried in most operations
*/
private static final List<SObjectField> STANDARD_FIELDS = new List<SObjectField>{
${OBJECT_NAME}.Id,
${OBJECT_NAME}.Name,
${OBJECT_NAME}.OwnerId,
${OBJECT_NAME}.CreatedDate,
${OBJECT_NAME}.LastModifiedDate
// Add commonly needed fields here
};
/**
* Extended fields for detail views
*/
private static final List<SObjectField> DETAIL_FIELDS = new List<SObjectField>{
${OBJECT_NAME}.Id,
${OBJECT_NAME}.Name,
${OBJECT_NAME}.OwnerId,
${OBJECT_NAME}.CreatedDate,
${OBJECT_NAME}.LastModifiedDate
// Add detail-specific fields here
};
// ═══════════════════════════════════════════════════════════════════
// MOCKING SUPPORT (for unit tests)
// ═══════════════════════════════════════════════════════════════════
@TestVisible
private static List<${OBJECT_NAME}> mockResults;
@TestVisible
private static void setMockResults(List<${OBJECT_NAME}> records) {
mockResults = records;
}
@TestVisible
private static void clearMockResults() {
mockResults = null;
}
private static Boolean useMock() {
return Test.isRunningTest() && mockResults != null;
}
// ═══════════════════════════════════════════════════════════════════
// QUERY METHODS
// ═══════════════════════════════════════════════════════════════════
/**
* Query records by their IDs
*
* @param recordIds Set of record IDs to query
* @return List of matching records with standard fields
*/
public static List<${OBJECT_NAME}> byIds(Set<Id> recordIds) {
if (recordIds == null || recordIds.isEmpty()) {
return new List<${OBJECT_NAME}>();
}
if (useMock()) {
return mockResults;
}
return [
SELECT Id, Name, OwnerId, CreatedDate, LastModifiedDate
FROM ${OBJECT_NAME}
WHERE Id IN :recordIds
WITH SECURITY_ENFORCED
];
}
/**
* Query records by their IDs, returning a Map for O(1) lookups
*
* @param recordIds Set of record IDs to query
* @return Map of Id to record
*/
public static Map<Id, ${OBJECT_NAME}> byIdsAsMap(Set<Id> recordIds) {
return new Map<Id, ${OBJECT_NAME}>(byIds(recordIds));
}
/**
* Query records by Owner
*
* @param ownerId The owner's User Id
* @return List of records owned by the specified user
*/
public static List<${OBJECT_NAME}> byOwnerId(Id ownerId) {
if (ownerId == null) {
return new List<${OBJECT_NAME}>();
}
if (useMock()) {
return mockResults;
}
return [
SELECT Id, Name, OwnerId, CreatedDate, LastModifiedDate
FROM ${OBJECT_NAME}
WHERE OwnerId = :ownerId
WITH SECURITY_ENFORCED
ORDER BY LastModifiedDate DESC
LIMIT 1000
];
}
/**
* Query records created within a date range
*
* @param startDate Start of date range (inclusive)
* @param endDate End of date range (inclusive)
* @return List of records created within the range
*/
public static List<${OBJECT_NAME}> byCreatedDateRange(Date startDate, Date endDate) {
if (startDate == null || endDate == null) {
return new List<${OBJECT_NAME}>();
}
if (useMock()) {
return mockResults;
}
return [
SELECT Id, Name, OwnerId, CreatedDate, LastModifiedDate
FROM ${OBJECT_NAME}
WHERE CreatedDate >= :startDate
AND CreatedDate <= :endDate
WITH SECURITY_ENFORCED
ORDER BY CreatedDate DESC
LIMIT 10000
];
}
// ═══════════════════════════════════════════════════════════════════
// RELATIONSHIP QUERIES
// ═══════════════════════════════════════════════════════════════════
/**
* Query records with related child records
* Customize the subquery for your object relationships
*
* @param recordIds Set of parent record IDs
* @return List of parent records with child records populated
*/
public static List<${OBJECT_NAME}> withChildRecordsByIds(Set<Id> recordIds) {
if (recordIds == null || recordIds.isEmpty()) {
return new List<${OBJECT_NAME}>();
}
if (useMock()) {
return mockResults;
}
return [
SELECT Id, Name, OwnerId,
(SELECT Id, Name
FROM ChildRelationshipName__r // Replace with actual relationship
WHERE IsActive__c = true
LIMIT 50)
FROM ${OBJECT_NAME}
WHERE Id IN :recordIds
WITH SECURITY_ENFORCED
];
}
// ═══════════════════════════════════════════════════════════════════
// AGGREGATE QUERIES
// ═══════════════════════════════════════════════════════════════════
/**
* Count records by a grouping field
*
* @return List of AggregateResult with grouping and count
*/
public static List<AggregateResult> countByOwner() {
return [
SELECT OwnerId, COUNT(Id) recordCount
FROM ${OBJECT_NAME}
GROUP BY OwnerId
HAVING COUNT(Id) > 0
];
}
// ═══════════════════════════════════════════════════════════════════
// EXISTENCE CHECK
// ═══════════════════════════════════════════════════════════════════
/**
* Check if a record exists by ID (efficient single record lookup)
*
* @param recordId The record ID to check
* @return true if the record exists
*/
public static Boolean existsById(Id recordId) {
if (recordId == null) {
return false;
}
List<${OBJECT_NAME}> results = [
SELECT Id
FROM ${OBJECT_NAME}
WHERE Id = :recordId
WITH SECURITY_ENFORCED
LIMIT 1
];
return !results.isEmpty();
}
}
platform-soql-query
Salesforce SOQL query generation, optimization, and analysis skill with 100-point scoring. Convert natural language into performant SOQL and validate queries for security, selectivity, and governor-limit awareness.
Features
- Natural Language to SOQL: Convert requests into executable queries
- Query Optimization: Improve selectivity, LIMIT usage, and field selection
- Relationship Queries: Parent-child, child-parent, and polymorphic patterns
- Security Guidance:
WITH USER_MODE,WITH SECURITY_ENFORCED, and Apex-safe usage - 100-Point Scoring: Performance, correctness, security, and readability checks
Quick Start
1. Invoke the skill
Skill: platform-soql-query
Request: "Find Accounts with open Opportunities created this quarter"2. Typical use cases
- Generate SOQL from plain-English requirements
- Optimize slow or non-selective queries
- Build aggregates and relationship queries
- Validate queries before using them in Apex or CLI workflows
Documentation
- SKILL.md - Core workflow and optimization guidance
- references/query-optimization.md - Selectivity and performance tuning
- references/soql-syntax-reference.md - Syntax, relationships, and aggregates
- references/selector-patterns.md - Reusable Apex selector patterns
- references/cli-commands.md - sf CLI query execution examples
Related Skills
platform-data-manage- For data creation/import/export workflowsplatform-apex-generate- For inline SOQL inside Apex codeplatform-apex-test-run- For validating query behavior in tests
<!-- Parent: platform-soql-query/SKILL.md -->
SOQL Anti-Patterns: What to Avoid
A catalog of common SOQL mistakes and their solutions. Avoiding these patterns will help you stay within governor limits, improve query performance, and write more maintainable code.
---
Anti-Pattern #1: SOQL Inside Loops
The Problem: Executing queries inside a loop quickly exhausts the 100 SOQL query limit.
// ❌ ANTI-PATTERN: Query per record
for (Contact c : Trigger.new) {
Account a = [SELECT Name FROM Account WHERE Id = :c.AccountId];
c.Account_Name__c = a.Name;
}
// 200 contacts = 200 queries = LIMIT EXCEEDEDThe Solution: Query once, use a Map for lookups.
// ✅ CORRECT: Single query with Map lookup
Set<Id> accountIds = new Set<Id>();
for (Contact c : Trigger.new) {
accountIds.add(c.AccountId);
}
Map<Id, Account> accountMap = new Map<Id, Account>(
[SELECT Id, Name FROM Account WHERE Id IN :accountIds]
);
for (Contact c : Trigger.new) {
Account a = accountMap.get(c.AccountId);
if (a != null) {
c.Account_Name__c = a.Name;
}
}
// 200 contacts = 1 query = SAFEKey Insight: Collect IDs first, query once with IN clause, then use Map for O(1) lookups.
---
Anti-Pattern #2: Non-Selective WHERE Clauses
The Problem: Queries on non-indexed fields cause full table scans, which fail on large objects (100k+ records).
// ❌ ANTI-PATTERN: Non-selective filter
SELECT Id FROM Lead WHERE Status = 'Open'
// Status is not indexed - scans ALL Lead recordsThe Solution: Add an indexed field to make the query selective.
// ✅ CORRECT: Add indexed field filter
SELECT Id FROM Lead
WHERE Status = 'Open'
AND CreatedDate = LAST_N_DAYS:30
// CreatedDate is indexed - uses index
// ✅ ALTERNATIVE: Use OwnerId (indexed)
SELECT Id FROM Lead
WHERE Status = 'Open'
AND OwnerId = :UserInfo.getUserId()Indexed Fields (Always use these in WHERE):
Id,Name,OwnerId,CreatedDate,LastModifiedDateRecordTypeId, External ID fields, Master-Detail fields- Standard indexed fields:
Account.AccountNumber,Contact.Email,Case.CaseNumber
---
Anti-Pattern #3: Leading Wildcards
The Problem: LIKE '%value' cannot use indexes and scans all records.
// ❌ ANTI-PATTERN: Leading wildcard
SELECT Id FROM Account WHERE Name LIKE '%Corporation'
// Cannot use index - full table scanThe Solution: Use trailing wildcards or exact matches.
// ✅ CORRECT: Trailing wildcard (uses index)
SELECT Id FROM Account WHERE Name LIKE 'Acme%'
// ✅ CORRECT: Exact match
SELECT Id FROM Account WHERE Name = 'Acme Corporation'
// ✅ CORRECT: Contains check (if absolutely necessary)
// Do the filtering in Apex after a selective query
List<Account> allAccounts = [
SELECT Id, Name FROM Account
WHERE CreatedDate = THIS_YEAR
];
List<Account> filtered = new List<Account>();
for (Account a : allAccounts) {
if (a.Name.contains('Corporation')) {
filtered.add(a);
}
}---
Anti-Pattern #4: Negative Operators
The Problem: !=, NOT IN, NOT LIKE often prevent index usage.
// ❌ ANTI-PATTERN: Negative operators
SELECT Id FROM Opportunity WHERE StageName != 'Closed Lost'
SELECT Id FROM Contact WHERE AccountId NOT IN :excludedIdsThe Solution: Query for what you want, not what you don't want.
// ✅ CORRECT: Positive filter with specific values
SELECT Id FROM Opportunity
WHERE StageName IN ('Prospecting', 'Qualification', 'Proposal', 'Negotiation')
// ✅ CORRECT: Use a formula field for complex exclusions
// Create IsExcluded__c formula, then:
SELECT Id FROM Contact WHERE IsExcluded__c = false---
Anti-Pattern #5: Querying for NULL
The Problem: WHERE Field = null is non-selective and scans all records.
// ❌ ANTI-PATTERN: Null check in WHERE
SELECT Id FROM Contact WHERE Email = null
// Non-selective - scans all contactsThe Solution: Combine with selective filters or redesign data model.
// ✅ CORRECT: Add selective filter
SELECT Id FROM Contact
WHERE Email = null
AND CreatedDate = LAST_N_DAYS:30
// ✅ BETTER: Use a checkbox field
// Create HasEmail__c formula checkbox
SELECT Id FROM Contact WHERE HasEmail__c = false---
Anti-Pattern #6: SELECT * (All Fields)
The Problem: Querying all fields wastes resources and can hit heap limits.
// ❌ ANTI-PATTERN: Selecting everything
SELECT FIELDS(ALL) FROM Account LIMIT 200
// Loads ALL fields into memory
// ❌ ANTI-PATTERN: Listing every field manually
SELECT Id, Name, Description, BillingStreet, BillingCity,
BillingState, BillingPostalCode, BillingCountry, ...
FROM AccountThe Solution: Query only the fields you need.
// ✅ CORRECT: Minimal field selection
SELECT Id, Name, Industry FROM Account
// ✅ FOR DISPLAY: Just display fields
SELECT Id, Name FROM Account
// ✅ FOR PROCESSING: Just processing fields
SELECT Id, Status__c, ProcessedDate__c FROM Account---
Anti-Pattern #7: No LIMIT on Queries
The Problem: Unbounded queries can return 50,000 records and consume heap memory.
// ❌ ANTI-PATTERN: No limit
SELECT Id, Name FROM Account
// Could return 50,000 records!
// ❌ ANTI-PATTERN: Excessive limit
SELECT Id, Name FROM Account LIMIT 50000The Solution: Use appropriate limits for your use case.
// ✅ CORRECT: Reasonable limit for UI display
SELECT Id, Name FROM Account LIMIT 200
// ✅ CORRECT: Pagination
SELECT Id, Name FROM Account
ORDER BY Name
LIMIT 50 OFFSET 0
// ✅ CORRECT: Single record lookup
SELECT Id, Name FROM Account WHERE Name = 'Acme' LIMIT 1
// ✅ CORRECT: Existence check
SELECT Id FROM Account WHERE Name = 'Acme' LIMIT 1
// In Apex: if (!results.isEmpty()) { /* exists */ }---
Anti-Pattern #8: Deep Relationship Traversal
The Problem: Deep nesting (>3 levels) hurts performance and readability.
// ❌ ANTI-PATTERN: Deep traversal
SELECT Id,
Account.Owner.Manager.Department.Name
FROM Contact
// 4 levels deep - hard to maintain, performance hitThe Solution: Flatten queries or use multiple queries.
// ✅ CORRECT: Flatten to 1-2 levels
SELECT Id, Account.Name, Account.OwnerId FROM Contact
// Then query Owner separately if needed
Map<Id, User> owners = new Map<Id, User>(
[SELECT Id, ManagerId FROM User WHERE Id IN :ownerIds]
);---
Anti-Pattern #9: Unfiltered Subqueries
The Problem: Child subqueries without filters can return massive datasets.
// ❌ ANTI-PATTERN: Unfiltered subquery
SELECT Id,
(SELECT Id FROM Contacts),
(SELECT Id FROM Opportunities)
FROM Account
// Could return thousands of child records per accountThe Solution: Always filter and limit subqueries.
// ✅ CORRECT: Filtered and limited subqueries
SELECT Id,
(SELECT Id, Name FROM Contacts
WHERE IsActive__c = true
LIMIT 5),
(SELECT Id, Name FROM Opportunities
WHERE StageName != 'Closed Lost'
LIMIT 5)
FROM Account
WHERE Industry = 'Technology'---
Anti-Pattern #10: Formula Fields in WHERE
The Problem: Formula fields are not indexed and require full table scans.
// ❌ ANTI-PATTERN: Filter on formula field
SELECT Id FROM Opportunity
WHERE Days_Since_Created__c > 30
// Formula field - cannot use indexThe Solution: Use the underlying indexed field.
// ✅ CORRECT: Use base field
SELECT Id FROM Opportunity
WHERE CreatedDate < LAST_N_DAYS:30
// ✅ ALTERNATIVE: Store computed value in regular field
// Use workflow/flow to update a Number field
SELECT Id FROM Opportunity
WHERE Days_Open__c > 30---
Quick Reference: Selectivity Rules
A filter is SELECTIVE when:
├── Uses an indexed field, AND
├── Returns < 10% of first million records, OR
├── Returns < 5% of records beyond first million
└── Absolute max: 333,333 records (1M / 3)Always Indexed Fields:
Id,Name,OwnerId,CreatedDate,LastModifiedDateRecordTypeId, External ID fields, Master-Detail relationship fields
Request Custom Index: Contact Salesforce Support with:
- Object name and field API name
- Sample SOQL query
- Cardinality (unique values count)
- Business justification
---
Testing Checklist
Before deploying SOQL to production:
1. [ ] Run Query Plan tool (Developer Console or CLI) 2. [ ] Verify LeadingOperationType is "Index" not "TableScan" 3. [ ] Test with 200+ records in trigger context 4. [ ] Verify query count stays under 100 per transaction 5. [ ] Check heap usage for large result sets
# CLI Query Plan
sf data query \
--query "SELECT Id FROM Account WHERE Name = 'Test'" \
--target-org my-org \
--use-tooling-api \
--plan<!-- Parent: platform-soql-query/SKILL.md -->
Salesforce CLI SOQL Commands
Quick Reference
| Task | Command |
|---|---|
| Run query | sf data query --query "SELECT..." |
| JSON output | sf data query --query "..." --json |
| CSV output | sf data query --query "..." --result-format csv |
| Bulk export | sf data export bulk --query "SELECT..." --target-org alias |
| Query plan | sf api request rest '/query/?explain=<SOQL>' --target-org alias |
---
Basic Queries
Run a Query
sf data query \
--query "SELECT Id, Name, Industry FROM Account LIMIT 10" \
--target-org my-sandboxQuery with Filters
sf data query \
--query "SELECT Id, Name FROM Account WHERE Industry = 'Technology'" \
--target-org my-sandboxQuery Relationships
# Child-to-parent
sf data query \
--query "SELECT Id, Name, Account.Name FROM Contact LIMIT 10" \
--target-org my-sandbox
# Parent-to-child
sf data query \
--query "SELECT Id, Name, (SELECT Id, Name FROM Contacts) FROM Account LIMIT 5" \
--target-org my-sandbox---
Output Formats
Human-Readable (Default)
sf data query \
--query "SELECT Id, Name FROM Account LIMIT 5" \
--target-org my-sandboxJSON
sf data query \
--query "SELECT Id, Name FROM Account LIMIT 5" \
--target-org my-sandbox \
--jsonCSV
sf data query \
--query "SELECT Id, Name, Industry FROM Account" \
--target-org my-sandbox \
--result-format csv > accounts.csvDirect to File
sf data query \
--query "SELECT Id, Name, Industry FROM Account" \
--target-org my-sandbox \
--result-format csv \
--output-file accounts.csv---
Bulk Data Export
For large result sets (> 2,000 records), use the dedicated bulk export command:
# Export to CSV (default)
sf data export bulk \
--query "SELECT Id, Name FROM Account" \
--target-org my-sandbox \
--output-file accounts.csv
# Export as JSON
sf data export bulk \
--query "SELECT Id, Name FROM Account" \
--target-org my-sandbox \
--output-file accounts.json \
--result-format jsonNote:--bulkand--waitflags onsf data querywere removed in v2.87.7. Usesf data export bulkinstead.
---
Query Plan Analysis
Analyze query performance before running:
sf data query \
--query "SELECT Id FROM Account WHERE Name = 'Acme'" \
--target-org my-sandbox \
--use-tooling-api \
--planUnderstanding Query Plan Output
{
"plans": [{
"cardinality": 50, // Estimated rows returned
"fields": ["Name"], // Fields used for filtering
"leadingOperationType": "Index", // Index = good, TableScan = bad
"relativeCost": 0.1, // Lower is better
"sobjectCardinality": 10000, // Total records in object
"sobjectType": "Account"
}]
}Key Indicators:
leadingOperationType: "Index"= Query uses index (good)leadingOperationType: "TableScan"= Full table scan (bad for large tables)relativeCost < 1= Efficient querycardinality= Expected number of results
---
Tooling API Queries
Query metadata objects:
# Query ApexClass
sf data query \
--query "SELECT Id, Name, Body FROM ApexClass WHERE Name = 'MyController'" \
--target-org my-sandbox \
--use-tooling-api
# Query CustomField
sf data query \
--query "SELECT Id, DeveloperName, TableEnumOrId FROM CustomField WHERE TableEnumOrId = 'Account'" \
--target-org my-sandbox \
--use-tooling-api---
Query from File
Store query in file and execute:
# Create query file
echo "SELECT Id, Name FROM Account WHERE Industry = 'Technology'" > query.soql
# Execute from file
sf data query \
--file query.soql \
--target-org my-sandbox---
Useful Patterns
Get Record Count
sf data query \
--query "SELECT COUNT() FROM Account" \
--target-org my-sandboxExport to File
# CSV export
sf data query \
--query "SELECT Id, Name, Industry, Phone FROM Account" \
--target-org my-sandbox \
--result-format csv > accounts.csv
# JSON export
sf data query \
--query "SELECT Id, Name, Industry FROM Account" \
--target-org my-sandbox \
--json > accounts.jsonQuery with jq Processing
# Get just the names
sf data query \
--query "SELECT Name FROM Account LIMIT 10" \
--target-org my-sandbox \
--json | jq -r '.result.records[].Name'
# Count records
sf data query \
--query "SELECT Id FROM Account" \
--target-org my-sandbox \
--json | jq '.result.totalSize'
# Filter in shell
sf data query \
--query "SELECT Id, Name, Industry FROM Account" \
--target-org my-sandbox \
--json | jq '.result.records[] | select(.Industry == "Technology")'Query with Dates
# Records created today
sf data query \
--query "SELECT Id, Name FROM Account WHERE CreatedDate = TODAY" \
--target-org my-sandbox
# Records from last 30 days
sf data query \
--query "SELECT Id, Name FROM Account WHERE CreatedDate = LAST_N_DAYS:30" \
--target-org my-sandboxAggregate Queries
# Count by industry
sf data query \
--query "SELECT Industry, COUNT(Id) FROM Account GROUP BY Industry" \
--target-org my-sandbox
# Sum of amounts
sf data query \
--query "SELECT SUM(Amount) FROM Opportunity WHERE StageName = 'Closed Won'" \
--target-org my-sandbox---
Troubleshooting
Query Timeout
For long-running queries, export via bulk API:
sf data export bulk \
--query "SELECT Id, Name FROM Account" \
--target-org my-sandbox \
--output-file results.csvToo Many Results
Add LIMIT or filter:
# With limit
sf data query \
--query "SELECT Id, Name FROM Account LIMIT 1000" \
--target-org my-sandbox
# With filter
sf data query \
--query "SELECT Id, Name FROM Account WHERE CreatedDate = THIS_YEAR" \
--target-org my-sandboxNon-Selective Query Error
Add indexed field to WHERE:
# Add CreatedDate filter (indexed)
sf data query \
--query "SELECT Id FROM Lead WHERE Status = 'Open' AND CreatedDate = LAST_N_DAYS:90" \
--target-org my-sandboxPermission Errors
Check field-level security:
# Query accessible fields only
sf data query \
--query "SELECT Id, Name FROM Account" \
--target-org my-sandbox---
Integration with Other Tools
Pipe to File
sf data query \
--query "SELECT Id, Name FROM Account" \
--target-org my-sandbox \
--result-format csv | tee accounts.csvUse in Scripts
#!/bin/bash
ORG=${1:-"my-sandbox"}
# Get count
COUNT=$(sf data query \
--query "SELECT COUNT() FROM Account" \
--target-org $ORG \
--json | jq -r '.result.totalSize')
echo "Total accounts: $COUNT"
# Get top accounts
sf data query \
--query "SELECT Name, AnnualRevenue FROM Account ORDER BY AnnualRevenue DESC LIMIT 10" \
--target-org $ORGCompare Orgs
#!/bin/bash
PROD_COUNT=$(sf data query --query "SELECT COUNT() FROM Account" --target-org prod --json | jq '.result.totalSize')
SANDBOX_COUNT=$(sf data query --query "SELECT COUNT() FROM Account" --target-org sandbox --json | jq '.result.totalSize')
echo "Production accounts: $PROD_COUNT"
echo "Sandbox accounts: $SANDBOX_COUNT"
echo "Difference: $((PROD_COUNT - SANDBOX_COUNT))"<!-- Parent: platform-soql-query/SKILL.md -->
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 2. Direct Field Access 3. Relationship Field Access 4. Dynamic Field Access 5. Aggregate Queries 6. Subquery Fields 7. 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.IndustryThis 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
// Query only includes Id and Name
List<Account> 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
// Query ALL fields that will be accessed
List<Account> 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
// Contact query without Account relationship fields
List<Contact> 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
// Use dot notation to include parent fields
List<Contact> 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
List<Contact> 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
// Option 1: Include relationship field
List<Contact> 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<Contact> contacts = [SELECT Id, AccountId FROM Contact];
Set<Id> accountIds = new Set<Id>();
for (Contact c : contacts) {
if (c.AccountId != null) {
accountIds.add(c.AccountId);
}
}
Map<Id, Account> accountMap = new Map<Id, Account>(
[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
List<Account> 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
// If you know which fields will be accessed dynamically, query them
List<Account> 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
// For truly dynamic scenarios, build the query dynamically
Set<String> fieldsToQuery = new Set<String>{'Id', 'Name'};
fieldsToQuery.addAll(dynamicFieldList); // Add dynamic fields
String query = 'SELECT ' + String.join(new List<String>(fieldsToQuery), ', ') +
' FROM Account WHERE Id IN :accountIds';
List<Account> accounts = Database.query(query);---
Aggregate Queries
Rule: Aggregate queries return AggregateResult, not sObjects
BAD: Treating Aggregate as sObject
// This returns AggregateResult, not Account
List<Account> 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
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
// Query with contact subquery
List<Account> 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
// Query all needed fields in subquery
List<Account> accounts = [
SELECT Id, Name,
(SELECT Id, Name, Email, Phone FROM Contacts)
FROM Account
];
for (Account acc : accounts) {
// Contacts is a List<Contact>
List<Contact> 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
List<Account> 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:
public class AccountProcessor {
// Document required fields at the top
private static final Set<String> REQUIRED_FIELDS = new Set<String>{
'Id', 'Name', 'Industry', 'Description', 'AnnualRevenue',
'OwnerId', 'Owner.Name', 'Owner.Email'
};
// Single method for consistent querying
public static List<Account> queryAccounts(Set<Id> accountIds) {
return [
SELECT Id, Name, Industry, Description, AnnualRevenue,
OwnerId, Owner.Name, Owner.Email
FROM Account
WHERE Id IN :accountIds
];
}
public static void processAccounts(List<Account> 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:
public class AccountSelector {
// Default fields for most operations
private static final List<String> DEFAULT_FIELDS = new List<String>{
'Id', 'Name', 'Industry', 'Type', 'OwnerId'
};
// Extended fields for detailed views
private static final List<String> DETAIL_FIELDS = new List<String>{
'Id', 'Name', 'Industry', 'Type', 'OwnerId',
'Description', 'AnnualRevenue', 'NumberOfEmployees',
'BillingCity', 'BillingState', 'BillingCountry',
'Owner.Name', 'Owner.Email'
};
public List<Account> selectById(Set<Id> ids) {
return selectByIdWithFields(ids, DEFAULT_FIELDS);
}
public List<Account> selectByIdDetailed(Set<Id> ids) {
return selectByIdWithFields(ids, DETAIL_FIELDS);
}
private List<Account> selectByIdWithFields(Set<Id> ids, List<String> fields) {
String query = 'SELECT ' + String.join(fields, ', ') +
' FROM Account WHERE Id IN :ids';
return Database.query(query);
}
}Pattern 3: Field Validation Helper
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
ifconditions 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.mdfor general SOQL mistakes - Selector Patterns: See
references/selector-patterns.mdfor query organization
<!-- Parent: platform-soql-query/SKILL.md -->
Query Optimization & Governor Limits
Indexing Strategy
Indexed Fields (Always Selective):
- Id, Name, OwnerId, CreatedDate, LastModifiedDate, RecordTypeId
- External ID fields, Master-Detail relationship fields
- Lookup fields (when unique)
Standard Indexed Fields by Object:
- Account: AccountNumber, Site
- Contact: Email
- Lead: Email
- Case: CaseNumber
Selectivity Rules
A filter is selective when it returns:
- < 10% of total records for first 1 million
- < 5% of total records for additional records
- OR uses an indexed fieldOptimization Patterns
-- ❌ NON-SELECTIVE (scans all records)
SELECT Id FROM Lead WHERE Status = 'Open'
-- ✅ SELECTIVE (uses index + selective filter)
SELECT Id FROM Lead
WHERE Status = 'Open'
AND CreatedDate = LAST_N_DAYS:30
LIMIT 10000
-- ❌ LEADING WILDCARD (can't use index)
SELECT Id FROM Account WHERE Name LIKE '%corp'
-- ✅ TRAILING WILDCARD (uses index)
SELECT Id FROM Account WHERE Name LIKE 'Acme%'Query Plan Analysis
# Get query plan
sf data query \
--query "SELECT Id FROM Account WHERE Name = 'Test'" \
--target-org my-org \
--use-tooling-api \
--planPlan Output Interpretation:
Cardinality: Estimated rows returnedCost: Relative query cost (lower is better)Fields: Index fields usedLeadingOperationType: How the query starts (Index vs TableScan)
---
Governor Limits
| Limit | Synchronous | Asynchronous |
|---|---|---|
| Total SOQL Queries | 100 | 200 |
| Records Retrieved | 50,000 | 50,000 |
| Query Rows (queryMore) | 2,000 | 2,000 |
| Query Locator Rows | 10 million | 10 million |
Efficient Patterns
-- ❌ Query all, filter in Apex
SELECT Id, Name FROM Account
-- Then filter 50,000 records in Apex
-- ✅ Filter in SOQL
SELECT Id, Name FROM Account
WHERE Industry = 'Technology' AND IsActive__c = true
LIMIT 1000
-- ❌ Multiple queries in loop
for (Contact c : contacts) {
Account a = [SELECT Name FROM Account WHERE Id = :c.AccountId];
}
-- ✅ Single query with Map
Map<Id, Account> accounts = new Map<Id, Account>(
[SELECT Id, Name FROM Account WHERE Id IN :accountIds]
);SOQL FOR Loops
// For large datasets - doesn't load all into heap
for (Account acc : [SELECT Id, Name FROM Account WHERE Industry = 'Technology']) {
// Process one record at a time
// Governor: Uses queryMore internally (200 at a time)
}
// With explicit batch size
for (List<Account> accs : [SELECT Id, Name FROM Account]) {
// Process 200 records at a time
}Security Patterns
WITH SECURITY_ENFORCED
-- Throws exception if user lacks FLS
SELECT Id, Name, Phone
FROM Account
WITH SECURITY_ENFORCEDWITH USER_MODE / SYSTEM_MODE
-- Respects sharing rules (default in Apex)
SELECT Id, Name FROM Account WITH USER_MODE
-- Bypasses sharing rules (use with caution)
SELECT Id, Name FROM Account WITH SYSTEM_MODEIn Apex: stripInaccessible
// Strip inaccessible fields instead of throwing
SObjectAccessDecision decision = Security.stripInaccessible(
AccessType.READABLE,
[SELECT Id, Name, SecretField__c FROM Account]
);
List<Account> safeAccounts = decision.getRecords();<!-- Parent: platform-soql-query/SKILL.md -->
Selector Patterns: Query Abstraction in Vanilla Apex
This guide teaches query abstraction patterns using pure Apex — no external libraries required. These patterns improve testability, maintainability, and security compliance.
---
Why Use a Selector Layer?
The Problem
Without abstraction, SOQL queries are scattered everywhere:
// In TriggerHandler.cls
List<Account> accounts = [SELECT Id, Name FROM Account WHERE Id IN :accountIds];
// In BatchJob.cls (duplicate!)
List<Account> accounts = [SELECT Id, Name FROM Account WHERE Id IN :ids];
// In ServiceClass.cls (slightly different fields!)
List<Account> accounts = [SELECT Id, Name, Industry FROM Account WHERE Id IN :accountIds];Problems: 1. Duplication: Same query logic repeated 2. Inconsistency: Different fields queried in different places 3. Fragility: Field deletion breaks multiple classes 4. Testability: Must create real records to test 5. Security: FLS/sharing often forgotten
The Solution
Centralize queries in Selector classes:
// Single source of truth
public class AccountSelector {
public static List<Account> byIds(Set<Id> accountIds) {
return [
SELECT Id, Name, Industry
FROM Account
WHERE Id IN :accountIds
WITH SECURITY_ENFORCED
];
}
}
// Usage everywhere
List<Account> accounts = AccountSelector.byIds(accountIds);---
Pattern 1: Basic Selector Class
The simplest approach - a class with static query methods.
/**
* AccountSelector - Centralized queries for Account object
*/
public inherited sharing class AccountSelector {
// ═══════════════════════════════════════════════════════════════════
// FIELD SETS (centralized field lists)
// ═══════════════════════════════════════════════════════════════════
private static final List<SObjectField> STANDARD_FIELDS = new List<SObjectField>{
Account.Id,
Account.Name,
Account.Industry,
Account.AnnualRevenue,
Account.OwnerId
};
// ═══════════════════════════════════════════════════════════════════
// QUERY METHODS
// ═══════════════════════════════════════════════════════════════════
/**
* Query accounts by their IDs
*/
public static List<Account> byIds(Set<Id> accountIds) {
if (accountIds == null || accountIds.isEmpty()) {
return new List<Account>();
}
return [
SELECT Id, Name, Industry, AnnualRevenue, OwnerId
FROM Account
WHERE Id IN :accountIds
WITH SECURITY_ENFORCED
];
}
/**
* Query accounts by Owner
*/
public static List<Account> byOwnerId(Id ownerId) {
return [
SELECT Id, Name, Industry, AnnualRevenue, OwnerId
FROM Account
WHERE OwnerId = :ownerId
WITH SECURITY_ENFORCED
LIMIT 1000
];
}
/**
* Query accounts with their contacts
*/
public static List<Account> withContactsByIds(Set<Id> accountIds) {
return [
SELECT Id, Name,
(SELECT Id, FirstName, LastName, Email
FROM Contacts
WHERE IsActive__c = true
LIMIT 50)
FROM Account
WHERE Id IN :accountIds
WITH SECURITY_ENFORCED
];
}
}Usage:
// Clean, readable, testable
List<Account> accounts = AccountSelector.byIds(accountIdSet);
List<Account> myAccounts = AccountSelector.byOwnerId(UserInfo.getUserId());---
Pattern 2: Selector with Sharing Modes
Control sharing rules at the selector level.
/**
* ContactSelector with sharing mode control
*/
public class ContactSelector {
// ═══════════════════════════════════════════════════════════════════
// USER MODE (respects sharing rules - default)
// ═══════════════════════════════════════════════════════════════════
public inherited sharing class UserMode {
public static List<Contact> byAccountIds(Set<Id> accountIds) {
return [
SELECT Id, FirstName, LastName, Email, AccountId
FROM Contact
WHERE AccountId IN :accountIds
WITH USER_MODE
];
}
}
// ═══════════════════════════════════════════════════════════════════
// SYSTEM MODE (bypasses sharing - use carefully!)
// ═══════════════════════════════════════════════════════════════════
public without sharing class SystemMode {
public static List<Contact> byAccountIds(Set<Id> accountIds) {
return [
SELECT Id, FirstName, LastName, Email, AccountId
FROM Contact
WHERE AccountId IN :accountIds
WITH SYSTEM_MODE
];
}
}
}
// Usage
List<Contact> visibleContacts = ContactSelector.UserMode.byAccountIds(ids);
List<Contact> allContacts = ContactSelector.SystemMode.byAccountIds(ids);---
Pattern 3: Mockable Selector (for Unit Tests)
Enable query mocking without database calls.
/**
* OpportunitySelector with mocking support
*/
public inherited sharing class OpportunitySelector {
// Test-visible mock data
@TestVisible
private static List<Opportunity> mockData;
/**
* Query opportunities by Account IDs
* Returns mock data in tests if set
*/
public static List<Opportunity> byAccountIds(Set<Id> accountIds) {
if (Test.isRunningTest() && mockData != null) {
return mockData;
}
return [
SELECT Id, Name, StageName, Amount, CloseDate, AccountId
FROM Opportunity
WHERE AccountId IN :accountIds
WITH SECURITY_ENFORCED
];
}
/**
* Set mock data for testing
*/
@TestVisible
private static void setMockData(List<Opportunity> opportunities) {
mockData = opportunities;
}
}Test Usage:
@IsTest
private class OpportunitySelectorTest {
@IsTest
static void testByAccountIds_returnsMockData() {
// Arrange - no database records needed!
List<Opportunity> mockOpps = new List<Opportunity>{
new Opportunity(Name = 'Test Opp', StageName = 'Prospecting', Amount = 1000)
};
OpportunitySelector.setMockData(mockOpps);
// Act
List<Opportunity> result = OpportunitySelector.byAccountIds(new Set<Id>());
// Assert
System.assertEquals(1, result.size());
System.assertEquals('Test Opp', result[0].Name);
}
}---
Pattern 4: Query Builder (Dynamic SOQL)
For complex, dynamic queries that vary at runtime.
/**
* Dynamic query builder for flexible SOQL construction
*/
public inherited sharing class QueryBuilder {
private String objectName;
private Set<String> fields = new Set<String>();
private List<String> conditions = new List<String>();
private Map<String, Object> bindings = new Map<String, Object>();
private String orderByClause;
private Integer limitCount;
/**
* Constructor
*/
public QueryBuilder(String objectName) {
this.objectName = objectName;
}
/**
* Add fields to select
*/
public QueryBuilder selectFields(List<String> fieldList) {
fields.addAll(fieldList);
return this;
}
/**
* Add fields using SObjectField tokens (type-safe!)
*/
public QueryBuilder selectFields(List<SObjectField> fieldTokens) {
for (SObjectField token : fieldTokens) {
fields.add(String.valueOf(token));
}
return this;
}
/**
* Add WHERE condition with binding
*/
public QueryBuilder whereEquals(String field, Object value) {
String bindName = 'bind' + bindings.size();
conditions.add(field + ' = :' + bindName);
bindings.put(bindName, value);
return this;
}
/**
* Add WHERE IN condition
*/
public QueryBuilder whereIn(String field, Set<Id> ids) {
String bindName = 'bind' + bindings.size();
conditions.add(field + ' IN :' + bindName);
bindings.put(bindName, ids);
return this;
}
/**
* Add ORDER BY
*/
public QueryBuilder orderBy(String field, Boolean ascending) {
orderByClause = field + (ascending ? ' ASC' : ' DESC');
return this;
}
/**
* Add LIMIT
*/
public QueryBuilder setLimit(Integer count) {
limitCount = count;
return this;
}
/**
* Build and execute the query
*/
public List<SObject> execute() {
String query = buildQuery();
return Database.queryWithBinds(query, bindings, AccessLevel.USER_MODE);
}
/**
* Build query string (for debugging)
*/
public String buildQuery() {
List<String> parts = new List<String>();
// SELECT
if (fields.isEmpty()) {
fields.add('Id');
}
parts.add('SELECT ' + String.join(new List<String>(fields), ', '));
// FROM
parts.add('FROM ' + objectName);
// WHERE
if (!conditions.isEmpty()) {
parts.add('WHERE ' + String.join(conditions, ' AND '));
}
// ORDER BY
if (orderByClause != null) {
parts.add('ORDER BY ' + orderByClause);
}
// LIMIT
if (limitCount != null) {
parts.add('LIMIT ' + limitCount);
}
return String.join(parts, ' ');
}
}Usage:
// Fluent API for dynamic queries
List<SObject> results = new QueryBuilder('Account')
.selectFields(new List<SObjectField>{Account.Id, Account.Name, Account.Industry})
.whereEquals('Industry', 'Technology')
.whereIn('Id', accountIds)
.orderBy('Name', true)
.setLimit(100)
.execute();
// Debug the generated query
System.debug(new QueryBuilder('Account').selectFields(...).buildQuery());
// "SELECT Id, Name, Industry FROM Account WHERE Industry = :bind0 AND Id IN :bind1 ORDER BY Name ASC LIMIT 100"---
Pattern 5: Bulkified Query Pattern
The Map-based lookup pattern for bulk operations.
/**
* BulkQueryHelper - Reusable bulk query patterns
*/
public inherited sharing class BulkQueryHelper {
/**
* Get Accounts by ID as a Map (O(1) lookup)
*/
public static Map<Id, Account> getAccountMapByIds(Set<Id> accountIds) {
return new Map<Id, Account>([
SELECT Id, Name, Industry
FROM Account
WHERE Id IN :accountIds
WITH SECURITY_ENFORCED
]);
}
/**
* Get Contacts grouped by AccountId
*/
public static Map<Id, List<Contact>> getContactsByAccountId(Set<Id> accountIds) {
Map<Id, List<Contact>> contactsByAccount = new Map<Id, List<Contact>>();
for (Contact c : [
SELECT Id, FirstName, LastName, Email, AccountId
FROM Contact
WHERE AccountId IN :accountIds
WITH SECURITY_ENFORCED
]) {
if (!contactsByAccount.containsKey(c.AccountId)) {
contactsByAccount.put(c.AccountId, new List<Contact>());
}
contactsByAccount.get(c.AccountId).add(c);
}
return contactsByAccount;
}
}Usage in Trigger:
// ❌ WRONG: Query per record
for (Opportunity opp : Trigger.new) {
Account a = [SELECT Name FROM Account WHERE Id = :opp.AccountId];
}
// ✅ CORRECT: Bulk query with Map lookup
Set<Id> accountIds = new Set<Id>();
for (Opportunity opp : Trigger.new) {
accountIds.add(opp.AccountId);
}
Map<Id, Account> accountMap = BulkQueryHelper.getAccountMapByIds(accountIds);
for (Opportunity opp : Trigger.new) {
Account a = accountMap.get(opp.AccountId);
if (a != null) {
// Use account data
}
}---
Best Practices Summary
| Practice | Benefit |
|---|---|
| Centralize in Selector classes | One place to update field lists |
Use WITH SECURITY_ENFORCED | Automatic FLS enforcement |
| Return empty List, not null | Prevents NullPointerException |
Use inherited sharing | Respects caller's sharing context |
| Make fields list a constant | Easy to update across queries |
| Add null/empty checks | Prevent unnecessary queries |
| Support mocking in tests | Faster tests, no database dependencies |
---
When to Use Each Pattern
| Scenario | Pattern |
|---|---|
| Simple, static queries | Pattern 1: Basic Selector |
| Need sharing mode control | Pattern 2: Sharing Modes |
| Heavy unit testing | Pattern 3: Mockable Selector |
| Dynamic filters at runtime | Pattern 4: Query Builder |
| Trigger/batch bulk operations | Pattern 5: Bulkified Query |
<!-- Parent: platform-soql-query/SKILL.md -->
SOQL Quick Reference
Query Structure
SELECT fields
FROM object
[WHERE conditions]
[WITH filter]
[GROUP BY fields]
[HAVING conditions]
[ORDER BY fields [ASC|DESC] [NULLS FIRST|LAST]]
[LIMIT number]
[OFFSET number]
[FOR UPDATE]---
Operators
Comparison Operators
| Operator | Description | Example |
|---|---|---|
= | Equal | Name = 'Acme' |
!= | Not equal | Status != 'Closed' |
< | Less than | Amount < 1000 |
<= | Less than or equal | Amount <= 1000 |
> | Greater than | Amount > 1000 |
>= | Greater than or equal | Amount >= 1000 |
LIKE | Pattern match | Name LIKE 'Acme%' |
IN | In list | Status IN ('New', 'Open') |
NOT IN | Not in list | Type NOT IN ('Other') |
INCLUDES | Multi-select contains | Skills__c INCLUDES ('Java') |
EXCLUDES | Multi-select excludes | Skills__c EXCLUDES ('Java') |
Logical Operators
| Operator | Description | Example |
|---|---|---|
AND | Both conditions | A = 1 AND B = 2 |
OR | Either condition | A = 1 OR B = 2 |
NOT | Negate condition | NOT (A = 1) |
LIKE Patterns
| Pattern | Matches |
|---|---|
'Acme%' | Starts with "Acme" |
'%Corp' | Ends with "Corp" |
'%test%' | Contains "test" |
'A_me' | "A" + any char + "me" |
---
Date Literals
Relative Dates
| Literal | Description |
|---|---|
TODAY | Current day |
YESTERDAY | Previous day |
TOMORROW | Next day |
THIS_WEEK | Current week (Sun-Sat) |
LAST_WEEK | Previous week |
NEXT_WEEK | Next week |
THIS_MONTH | Current month |
LAST_MONTH | Previous month |
NEXT_MONTH | Next month |
THIS_QUARTER | Current quarter |
LAST_QUARTER | Previous quarter |
NEXT_QUARTER | Next quarter |
THIS_YEAR | Current year |
LAST_YEAR | Previous year |
NEXT_YEAR | Next year |
THIS_FISCAL_QUARTER | Current fiscal quarter |
THIS_FISCAL_YEAR | Current fiscal year |
N Days/Weeks/Months/Years
| Literal | Description |
|---|---|
LAST_N_DAYS:n | Last n days |
NEXT_N_DAYS:n | Next n days |
LAST_N_WEEKS:n | Last n weeks |
NEXT_N_WEEKS:n | Next n weeks |
LAST_N_MONTHS:n | Last n months |
NEXT_N_MONTHS:n | Next n months |
LAST_N_QUARTERS:n | Last n quarters |
NEXT_N_QUARTERS:n | Next n quarters |
LAST_N_YEARS:n | Last n years |
NEXT_N_YEARS:n | Next n years |
Specific Dates
-- Date only
WHERE CloseDate = 2024-12-31
-- DateTime
WHERE CreatedDate >= 2024-01-01T00:00:00Z---
Aggregate Functions
| Function | Description | Example |
|---|---|---|
COUNT() | Count all rows | SELECT COUNT() FROM Account |
COUNT(field) | Count non-null values | SELECT COUNT(Email) FROM Contact |
COUNT_DISTINCT(field) | Count unique values | SELECT COUNT_DISTINCT(Industry) FROM Account |
SUM(field) | Sum of values | SELECT SUM(Amount) FROM Opportunity |
AVG(field) | Average of values | SELECT AVG(Amount) FROM Opportunity |
MIN(field) | Minimum value | SELECT MIN(Amount) FROM Opportunity |
MAX(field) | Maximum value | SELECT MAX(Amount) FROM Opportunity |
---
Date Functions
| Function | Returns | Example |
|---|---|---|
CALENDAR_YEAR(date) | Year (e.g., 2024) | SELECT CALENDAR_YEAR(CloseDate) FROM Opportunity |
CALENDAR_QUARTER(date) | Quarter (1-4) | SELECT CALENDAR_QUARTER(CloseDate) FROM Opportunity |
CALENDAR_MONTH(date) | Month (1-12) | SELECT CALENDAR_MONTH(CloseDate) FROM Opportunity |
DAY_IN_MONTH(date) | Day (1-31) | SELECT DAY_IN_MONTH(CreatedDate) FROM Account |
DAY_IN_WEEK(date) | Day (1=Sun, 7=Sat) | SELECT DAY_IN_WEEK(CreatedDate) FROM Account |
DAY_IN_YEAR(date) | Day (1-366) | SELECT DAY_IN_YEAR(CreatedDate) FROM Account |
WEEK_IN_MONTH(date) | Week (1-5) | SELECT WEEK_IN_MONTH(CreatedDate) FROM Account |
WEEK_IN_YEAR(date) | Week (1-53) | SELECT WEEK_IN_YEAR(CreatedDate) FROM Account |
HOUR_IN_DAY(date) | Hour (0-23) | SELECT HOUR_IN_DAY(CreatedDate) FROM Account |
FISCAL_YEAR(date) | Fiscal year | SELECT FISCAL_YEAR(CloseDate) FROM Opportunity |
FISCAL_QUARTER(date) | Fiscal quarter | SELECT FISCAL_QUARTER(CloseDate) FROM Opportunity |
FISCAL_MONTH(date) | Fiscal month | SELECT FISCAL_MONTH(CloseDate) FROM Opportunity |
---
Relationship Queries
Child-to-Parent (Dot Notation)
-- Standard objects
SELECT Contact.Name, Contact.Account.Name FROM Contact
-- Custom objects (use __r)
SELECT Child__c.Name, Child__c.Parent__r.Name FROM Child__cParent-to-Child (Subquery)
-- Standard objects
SELECT Id, (SELECT Id FROM Contacts) FROM Account
-- Custom objects (use __r)
SELECT Id, (SELECT Id FROM Children__r) FROM Parent__c---
WITH Clauses
| Clause | Description |
|---|---|
WITH SECURITY_ENFORCED | Enforce FLS (throws exception if no access) |
WITH USER_MODE | Respect sharing and FLS |
WITH SYSTEM_MODE | Bypass sharing rules |
---
Governor Limits
| Limit | Synchronous | Asynchronous |
|---|---|---|
| Total SOQL Queries | 100 | 200 |
| Records Retrieved | 50,000 | 50,000 |
| QueryLocator Rows | 10,000,000 | 10,000,000 |
| OFFSET Maximum | 2,000 | 2,000 |
| Subqueries | 20 | 20 |
| Relationship Depth | 5 levels | 5 levels |
---
Index Usage
Always Indexed
IdNameOwnerIdCreatedDateLastModifiedDateRecordTypeId- External ID fields
- Master-Detail fields
Selective Query Rules
- Query is selective if WHERE returns < 10% of first 1M records
- Or uses an indexed field with < 1M matching records
- Non-selective queries on large tables fail
---
CLI Commands
# Basic query
sf data query --query "SELECT Id, Name FROM Account LIMIT 10" --target-org my-org
# JSON output
sf data query --query "SELECT Id, Name FROM Account" --target-org my-org --json
# CSV output
sf data query --query "SELECT Id, Name FROM Account" --result-format csv --target-org my-org
# Bulk export (for large results, > 2,000 records)
sf data export bulk --query "SELECT Id, Name FROM Account" --target-org my-org --output-file accounts.csv
# Query plan (uses REST API explain endpoint)
sf api request rest "/query/?explain=SELECT+Id+FROM+Account+WHERE+Name='Test'" --target-org my-org --json<!-- Parent: platform-soql-query/SKILL.md -->
SOQL Syntax Reference
Basic Query Structure
SELECT field1, field2, ...
FROM ObjectName
WHERE condition1 AND condition2
ORDER BY field1 ASC/DESC
LIMIT number
OFFSET numberField Selection
-- Specific fields (recommended)
SELECT Id, Name, Industry FROM Account
-- All fields (avoid in Apex - use only in Developer Console)
SELECT FIELDS(ALL) FROM Account LIMIT 200
-- Standard fields only
SELECT FIELDS(STANDARD) FROM AccountWHERE Clause Operators
| Operator | Example | Notes |
|---|---|---|
= | Name = 'Acme' | Exact match |
!= | Status != 'Closed' | Not equal |
<, >, <=, >= | Amount > 1000 | Comparison |
LIKE | Name LIKE 'Acme%' | Wildcard match |
IN | Status IN ('New', 'Open') | Multiple values |
NOT IN | Type NOT IN ('Other') | Exclude values |
INCLUDES | Interests__c INCLUDES ('Golf') | Multi-select picklist |
EXCLUDES | Interests__c EXCLUDES ('Golf') | Multi-select exclude |
Date Literals
| Literal | Meaning |
|---|---|
TODAY | Current day |
YESTERDAY | Previous day |
THIS_WEEK | Current week (Sun-Sat) |
LAST_WEEK | Previous week |
THIS_MONTH | Current month |
LAST_MONTH | Previous month |
THIS_QUARTER | Current quarter |
THIS_YEAR | Current year |
LAST_N_DAYS:n | Last n days |
NEXT_N_DAYS:n | Next n days |
-- Created in last 30 days
SELECT Id FROM Account WHERE CreatedDate = LAST_N_DAYS:30
-- Modified this month
SELECT Id FROM Contact WHERE LastModifiedDate = THIS_MONTH---
Relationship Queries
Child-to-Parent (Dot Notation)
-- Access parent fields
SELECT Id, Name, Account.Name, Account.Industry
FROM Contact
WHERE Account.AnnualRevenue > 1000000
-- Up to 5 levels
SELECT Id, Contact.Account.Owner.Manager.Name
FROM CaseParent-to-Child (Subquery)
-- Get parent with related children
SELECT Id, Name,
(SELECT Id, FirstName, LastName FROM Contacts),
(SELECT Id, Name, Amount FROM Opportunities WHERE StageName = 'Closed Won')
FROM Account
WHERE Industry = 'Technology'Standard Relationship Names
| Object | Relationship Name | Example |
|---|---|---|
| Account → Contacts | Contacts | (SELECT Id FROM Contacts) |
| Account → Opportunities | Opportunities | (SELECT Id FROM Opportunities) |
| Account → Cases | Cases | (SELECT Id FROM Cases) |
| Contact → Cases | Cases | (SELECT Id FROM Cases) |
| Opportunity → OpportunityLineItems | OpportunityLineItems | (SELECT Id FROM OpportunityLineItems) |
Custom Object Relationships
-- Custom relationship: add __r suffix
SELECT Id, Name, Custom_Object__r.Name
FROM Another_Object__c
-- Child relationship: add __r suffix
SELECT Id, (SELECT Id FROM Custom_Children__r)
FROM Parent_Object__c---
Aggregate Queries
Basic Aggregates
-- Count all records
SELECT COUNT() FROM Account
-- Count with alias
SELECT COUNT(Id) cnt FROM Account
-- Sum, Average, Min, Max
SELECT SUM(Amount), AVG(Amount), MIN(Amount), MAX(Amount)
FROM Opportunity
WHERE StageName = 'Closed Won'GROUP BY
-- Count by field
SELECT Industry, COUNT(Id)
FROM Account
GROUP BY Industry
-- Multiple groupings
SELECT StageName, CALENDAR_YEAR(CloseDate), COUNT(Id)
FROM Opportunity
GROUP BY StageName, CALENDAR_YEAR(CloseDate)HAVING Clause
-- Filter aggregated results
SELECT Industry, COUNT(Id) cnt
FROM Account
GROUP BY Industry
HAVING COUNT(Id) > 10GROUP BY ROLLUP
-- Subtotals
SELECT LeadSource, Rating, COUNT(Id)
FROM Lead
GROUP BY ROLLUP(LeadSource, Rating)---
Advanced Features
Polymorphic Relationships (What)
-- Query polymorphic fields
SELECT Id, What.Name, What.Type
FROM Task
WHERE What.Type IN ('Account', 'Opportunity')
-- TYPEOF for conditional fields
SELECT
TYPEOF What
WHEN Account THEN Name, Phone
WHEN Opportunity THEN Name, Amount
END
FROM TaskSemi-Joins and Anti-Joins
-- Semi-join: Records that HAVE related records
SELECT Id, Name FROM Account
WHERE Id IN (SELECT AccountId FROM Contact)
-- Anti-join: Records that DON'T HAVE related records
SELECT Id, Name FROM Account
WHERE Id NOT IN (SELECT AccountId FROM Opportunity)Format and Currency
-- Format currency/date in results
SELECT FORMAT(Amount), FORMAT(CloseDate) FROM Opportunity
-- Convert to user's currency
SELECT Id, convertCurrency(Amount) FROM OpportunityRelated skills
FAQ
Is Platform Soql Query safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.