ApexEloquent: Building queries with Scribe
This document is a usage guide focused on how to write Scribe in production code, ApexEloquent's query builder. For the full API signatures, see API Reference: Scribe. For an index of the other ApexEloquent topics, see the ApexEloquent Guide.
What Scribe Is
Scribe is a query builder that assembles SOQL through typed method chains. Starting from Scribe.of(Account.class) and stacking field, whereEqual, orderBy, and friends, you end up with a complete SOQL string.
Scribe accountScribe = Scribe.of(Account.class)
.field('Id')
.field('Name')
.field('Industry')
.whereEqual('Industry', 'Technology')
.orderBy('Name', 'ASC')
.take(10);
// → SELECT id, name, industry FROM Account WHERE Industry = 'Technology' ORDER BY Name ASC LIMIT 10
List<IEntry> accounts = new Eloquent().get(accountScribe);
Three key points:
- Immutable: each method returns a new
Scribeinstance. You can branch midway to derive condition-varied queries without altering the originalScribe. - Query execution is delegated to
IEloquent: when you hand a finishedScribetoIEloquent.get(scribe), the SOQL gets issued and the data is fetched. This separation of query construction from execution is the heart of ApexEloquent — laid out in detail in Query Delegation Pattern. - Mockable via DI:
IEloquentis an interface, withEloquent(issues real SOQL) in production andMockEloquent(no DB; returns the injectedIEntrylist as-is) in tests, swapped via the Layered Constructor Pattern. The Usecase-side code depends on a field of typeIEloquent, so the calling shape stays identical in production and tests — only tests get to run logic without touching the DB. Details in Data Access, DML, IEntry, and Mock.
Field names are passed as strings rather than SObjectField (e.g. 'Id' / 'Industry__c'). This frees the builder from Apex's type system and lets queries be assembled dynamically at runtime.
Choosing What to SELECT
Single Field vs Multiple Fields
Add one at a time with field(String fieldName), or hand over many at once via fields(List<String>).
Scribe scribe = Scribe.of(Opportunity.class)
.field('Id')
.field('Name')
.field('StageName');
// → SELECT id, name, stagename FROM Opportunity
fields(List<String>) also accepts a pre-declared List<String> directly.
List<String> opportunityFields = new List<String>{
'Id',
'Name',
'StageName',
'CloseDate',
'Amount'
};
Scribe scribe = Scribe.of(Opportunity.class)
.fields(opportunityFields)
.whereEqual('StageName', 'Prospecting');
// → SELECT id, name, stagename, closedate, amount FROM Opportunity WHERE StageName = 'Prospecting'
Pulling in Parent Fields with parentField
Pass Scribe.asParent('AccountId').field(...) to parentField, and the parent object's fields land in the SELECT clause.
Scribe scribe = Scribe.of(Opportunity.class)
.field('Id')
.field('Name')
.parentField(Scribe.asParent('AccountId').field('Name').field('Industry'))
.whereEqual('StageName', 'Closed Won');
// → SELECT id, name, Account.name, Account.industry FROM Opportunity WHERE StageName = 'Closed Won'
Child subqueries (withChildren) and many-to-many (through) are covered in Parent Fields, Child Subqueries, and Many-to-Many.
allFields for Selecting Everything
allFields() selects every accessible field on the target object. In production, explicit fields are safer, but it's handy for building test data or for investigation.
Filtering with WHERE
Basic WHERE
Equality (whereEqual / whereNotEqual), comparison (whereGreaterThan family / whereLessThan family), pattern (whereLike / whereNotLike), list (whereIn / whereNotIn), multi-select (whereIncludes / whereExcludes), and null check (whereNull / whereNotNull) are all available. For signatures and behavior, see API Reference: Scribe.
Scribe scribe = Scribe.of(Opportunity.class)
.field('Id')
.whereEqual('StageName', 'Prospecting')
.whereGreaterThan('Amount', 1000)
.whereIn('OwnerId', ownerIds); // Set<Id> can be passed as-is
// → SELECT id FROM Opportunity WHERE StageName = 'Prospecting' AND Amount > 1000 AND OwnerId IN (...)
Successive whereXxx calls are joined by AND by default.
Mixing AND and OR
To slip in an OR, insert orCondition() before the next where.
Scribe scribe = Scribe.of(Account.class)
.field('Id')
.whereEqual('Name', 'A')
.orCondition()
.whereEqual('Name', 'B')
.orCondition()
.whereEqual('Name', 'C');
// → SELECT id FROM Account WHERE Name = 'A' OR Name = 'B' OR Name = 'C'
Once you switch to OR, every subsequent where must also be joined by OR. You can't switch back to AND midway. When you want to mix OR and AND, put the AND conditions first and the OR conditions at the end, or use whereGroup + Scribe.asGroup() to wrap one side in parentheses.
Scribe scribe = Scribe.of(Account.class)
.field('Id')
.whereGroup(
Scribe.asGroup()
.whereEqual('Industry', 'Tech')
.whereEqual('Name', 'X')
)
.orCondition()
.whereEqual('BillingCity', 'Tokyo');
// → SELECT id FROM Account WHERE (Industry = 'Tech' AND Name = 'X') OR BillingCity = 'Tokyo'
Note: when whereIn receives an empty list, the SOQL converts it to Id = null (a condition guaranteed to be false). This is by design — "if the filter target has 0 items, the result is also 0" — so you don't need an isEmpty() check on the caller side.
⚠️ whereNotIn goes the other way: given an empty collection it drops the condition entirely (no filtering — the all-rows side). Writing whereIn while meaning "don't filter when empty" gives you zero rows, so when you want to state which side you mean, use ignoreWhen (below).
Subquery IN
The second argument of whereIn can take another Scribe. The two-step SOQL pattern of "first fetch IDs, then use them in the next query" collapses into one.
// Take only opportunities tied to "accounts the current user follows"
Scribe followedAccountIds = Scribe.of(AccountShare.class)
.field('AccountId')
.whereEqual('UserOrGroupId', UserInfo.getUserId());
Scribe oppScribe = Scribe.of(Opportunity.class)
.field('Id')
.field('Name')
.whereIn('AccountId', followedAccountIds);
// → SELECT id, name FROM Opportunity WHERE AccountId IN (SELECT accountid FROM AccountShare WHERE UserOrGroupId = '...')
This is essentially SOQL's IN (SELECT ...) syntax made native. whereNotIn also accepts a Scribe the same way.
whereLike Auto-Escapes Against SQL Injection
Single quotes (') inside the pattern argument to whereLike(field, pattern) are auto-escaped. You can pass user input through and not introduce a SOQL injection.
String userInput = "O'Brien"; // a suspicious-looking input
Scribe scribe = Scribe.of(Contact.class)
.field('Id')
.whereLike('LastName', '%' + userInput + '%');
// → ...WHERE LastName LIKE '%O\'Brien%'
Sorting, LIMIT, FOR UPDATE
orderBy / take / offset / forUpdate handle sorting, limits, and row locking.
Scribe scribe = Scribe.of(Opportunity.class)
.field('Id')
.field('CloseDate')
.whereEqual('StageName', 'Prospecting')
.orderBy('CloseDate', 'DESC')
.take(20);
// → SELECT id, closedate FROM Opportunity WHERE StageName = 'Prospecting' ORDER BY CloseDate DESC LIMIT 20
Combining forUpdate with orderBy / offset throws an exception; offset is capped at 2000. SOQL's constraints get mirrored as build-time checks. Details in API Reference: Scribe.
Aggregate Queries
For cases like "aggregate child-record counts per parent Id", use aggregate queries — they keep Apex SOQL row counts and heap usage in check.
Scribe eventScribe = Scribe.of(Event.class)
.field('WhatId') // GROUP BY fields must also appear in SELECT
.count('Id', 'eventCount') // COUNT with alias
.whereIn('WhatId', opportunityIds)
.groupByField('WhatId');
// → SELECT whatid, COUNT(Id) eventCount FROM Event WHERE WhatId IN (...) GROUP BY WhatId
List<IEntry> aggregateEntries = new Eloquent().get(eventScribe);
for(IEntry aggregateEntry : aggregateEntries) {
Id whatId = (Id) aggregateEntry.get('WhatId');
Integer cnt = ((Decimal) aggregateEntry.get('eventCount')).intValue();
}
The six aggregate functions are count / countDistinct / sum / average / max / min. GROUP BY uses groupByField / groupByFields / groupByParent; HAVING is assembled with havingCondition(Scribe.asHaving()...). For signatures, see API Reference: Scribe.
Aliases are required. Each aggregate function takes count(field, alias) — the alias cannot be omitted — and you retrieve the value via aggregateEntry.get('alias'). Salesforce's standard AggregateResult defaults to field names like expr0 / expr1 / ... (in declaration order), which is a classic beginner trap; ApexEloquent enforces aliases at the method signature level to dodge that pitfall.
Aggregating and Grouping by Parent Fields
When you want to aggregate or GROUP BY a parent object's field, you cannot write field('Account.Industry') directly. Everything parent-related has to go through Scribe.asParent(...).
Example: aggregate per Product × Opportunity — total line-item sum per product, plus the maximum amount on the opportunity each line item belongs to.
Scribe scribe = Scribe.of(OpportunityLineItem.class)
.field('Product2Id')
.sum('TotalPrice', 'totalPrice')
.parentField(
Scribe.asParent('OpportunityId')
.field('Id')
.max('Amount', 'maxAmount')
)
.groupByField('Product2Id')
.groupByParent(
Scribe.asParent('OpportunityId').groupByField('Id')
);
// → SELECT product2id, SUM(TotalPrice) totalPrice, Opportunity.id, MAX(Opportunity.Amount) maxAmount FROM OpportunityLineItem GROUP BY Product2Id, Opportunity.Id
Points:
- Parent SELECT fields and aggregate functions are bundled inside
parentField(Scribe.asParent('OpportunityId').field(...).max(...)) - GROUP BY on a parent field is expressed as
groupByParent(Scribe.asParent('OpportunityId').groupByField('Id')) - A HAVING clause referring to a parent-derived alias (
maxAmount) uses the same alias:Scribe.asHaving().whereGreaterThan('maxAmount', 1000)
Notes on Aggregate Queries
- GROUP BY fields must also appear in SELECT (don't forget
field('Product2Id')) - Aggregate results come back as
Decimal. To put them into anInteger, cast like((Decimal) aggregateEntry.get('alias')).intValue() - The retrieval API stays as the normal
get(scribe). Whether the query is aggregate or normal is auto-detected by the framework - Reusing the same alias across multiple aggregate functions throws (don't duplicate
totalbetweensum('A','total')andmax('B','total')) - Combining child subqueries (
withChildren) with aggregate functions is forbidden by SOQL constraints
Building Queries Dynamically
Queries like search filters — "only add the condition when there is input" — come out as a single chain with no branching, thanks to ignoreWhen.
Scribe scribe = Scribe.of(Opportunity.class)
.field('Id')
.field('Name')
.whereEqual('Industry', industry).ignoreWhen(industry == null)
.whereIn('StageName', stages).ignoreWhen(stages.isEmpty())
.whereGreaterThan('CloseDate', closeAfter).ignoreWhen(closeAfter == null);
List<IEntry> opps = this.fetchEloquent.get(scribe);
// When all three are provided:
// → SELECT id, name FROM Opportunity WHERE Industry = 'Technology' AND StageName IN (...) AND CloseDate > 2026-01-01
// When none are:
// → SELECT id, name FROM Opportunity
ignoreWhen(true) retracts the immediately preceding condition. It also sidesteps the trap where an empty whereIn collapses to zero rows (see API Reference: Scribe).
⚠️ The
nullcase needs v3.5.0 or later. The 12 methods that reject null —whereGreaterThan,whereInand friends — used to throw the moment you chained them, so a followingignoreWhenwas never reached. From v3.5.0 the error is deferred to build time, which letsignoreWhenretract it. ThecloseAfter == nullline above is exactly this case.
Stacking with if
On versions without ignoreWhen, you stack conditions with branches. Scribe is immutable, so reassignment (scribe = scribe.whereXxx(...)) is required.
Scribe scribe = Scribe.of(Opportunity.class)
.field('Id')
.field('Name');
if(industry != null) {
scribe = scribe.whereEqual('Industry', industry);
}
if(stages != null && !stages.isEmpty()) {
scribe = scribe.whereIn('StageName', stages);
}
This still works, but as conditions pile up the shape of the query gets scattered across branches and stops being readable. Prefer ignoreWhen for new code.
Deriving queries
Since each method returns a new Scribe instance, you can derive from a single query without changing the original — for example, building both a count query and a list query against the same set of opportunities.
Scribe baseScribe = Scribe.of(Opportunity.class)
.whereEqual('StageName', 'Prospecting')
.whereGreaterThan('Amount', 1000);
// Just the count
Scribe countScribe = baseScribe.count('Id', 'cnt');
// → SELECT COUNT(Id) cnt FROM Opportunity WHERE StageName = 'Prospecting' AND Amount > 1000
// The list itself
Scribe listScribe = baseScribe
.field('Id')
.field('Name')
.orderBy('CloseDate', 'ASC')
.take(50);
// → SELECT id, name FROM Opportunity WHERE StageName = 'Prospecting' AND Amount > 1000 ORDER BY CloseDate ASC LIMIT 50
baseScribe stays unchanged and is reusable as the source for both derivations.
Read Next
- Data Access, DML, IEntry, and Mock: execute the assembled
ScribewithIEloquentand work withIEntry - Parent Fields, Child Subqueries, and Many-to-Many: queries using relations, and mocking parent-child structures with
MockEntry - ApexEloquent Guide: back to the ApexEloquent Guide index