ApexEloquent API Reference: Scribe
Scribe is ApexEloquent's query builder. It's an immutable class that assembles SOQL through type hints and method chains. Each method returns a new Scribe instance.
For usage and typical scenarios, see Building Queries with Scribe.
Static Factories
| Method | Purpose |
|---|---|
Scribe.of(System.Type recordType) | Starting point for normal queries. Scribe.of(Account.class) |
Scribe.source(Schema.SObjectType sObjectType) | Also an entry point, taking an SObjectType. Scribe.source(Account.getSObjectType()) |
Scribe.asParent(String parentRelationIdFieldName) | Parent-relation Scribe. Pass to parentField / parentCondition / groupByParent |
Scribe.asChild(System.Type childRecordType) | Child-subquery Scribe. Pass to withChildren |
Scribe.asGroup() | Scribe for wrapping WHERE clauses in parentheses. Pass to whereGroup |
Scribe.asHaving() | HAVING-clause Scribe. Pass to havingCondition |
Scribe.asThrough(System.Type junctionType, String relatedKey) | Many-to-many via junction object Scribe. Pass to through |
SELECT Family
| Method | Purpose |
|---|---|
field(String fieldName) | SELECT a single field |
fields(List<String> fieldNames) | SELECT multiple fields |
allFields() | SELECT every field on the target SObject |
parentField(Scribe parentScribe) | Bring parent-object fields into the SELECT |
withChildren(Scribe childScribe) | Add a child subquery |
through(Scribe throughScribe) | Add a many-to-many relation via a junction object |
relationName(String relationName) | Disambiguate child-subquery / many-to-many by explicit relationship name |
List<String> oppFields = new List<String>{ 'Id', 'Name', 'StageName' };
Scribe scribe = Scribe.of(Opportunity.class)
.fields(oppFields)
.parentField(Scribe.asParent('AccountId').field('Name'));
SELECT id, name, stagename, Account.name FROM Opportunity
Plain field names in the SELECT clause are normalised to lower case, and parent fields are prefixed with the relationship name (own fields first, then parent fields). Field names in the WHERE clause keep their original casing.
WHERE Family
Comparison, Inclusion, Pattern Matching
| Method | SOQL Output |
|---|---|
whereEqual(String f, Object v) | f = v (null → f = NULL) |
whereNotEqual(String f, Object v) | f != v |
whereGreaterThan(String f, Object v) | f > v (null not allowed) |
whereGreaterThanOrEqual(String f, Object v) | f >= v |
whereLessThan(String f, Object v) | f < v |
whereLessThanOrEqual(String f, Object v) | f <= v |
whereLike(String f, String pattern) | f LIKE '...' (' is auto-escaped) |
whereNotLike(String f, String pattern) | f NOT LIKE '...' |
whereIn(String f, Object values) | f IN (...). Takes a List or Set as-is (no repacking). See below for empty collections |
whereIn(String f, Scribe subQuery) | Subquery form: f IN (SELECT ...) |
whereNotIn(String f, Object values) | f NOT IN (...) |
whereNotIn(String f, Scribe subQuery) | f NOT IN (SELECT ...) |
whereIncludes(String f, List<String> values) | INCLUDES for multi-select picklists |
whereExcludes(String f, List<String> values) | EXCLUDES for multi-select picklists |
whereNull(String f) | f = NULL |
whereNotNull(String f) | f != NULL |
Empty Collections (whereIn and whereNotIn differ)
Passing an empty collection behaves differently between the two.
| Method | Given an empty collection | Result |
|---|---|---|
whereIn(f, empty) | builds a condition that is always false | zero rows |
whereNotIn(f, empty) | drops the condition entirely | no filtering — falls to the all rows side |
Both are reasonable as SOQL, but writing whereIn while meaning "don't filter when empty" gives you zero rows. To state the intent, use ignoreWhen below.
Logical Joins and Grouping
| Method | Purpose |
|---|---|
orCondition() | Join the next where with OR (once set, every subsequent join must be OR) |
whereGroup(Scribe groupScribe) | Wrap a group of conditions in parentheses (built from Scribe.asGroup()) |
parentCondition(Scribe parentScribe) | Filter by a parent-object condition |
// (Industry = 'Tech' AND Name = 'X') OR BillingCity = 'Tokyo'
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'
Mixing AND and OR bare fails at build time (v3.5.0+)
SOQL does not allow AND and OR to mix at one nesting level without parentheses. Either direction now raises ApexEloquentException, and the message points you at whereGroup.
// ❌ OR after ANDs
.whereEqual('Industry', 'Tech').whereEqual('Name', 'X').orCondition().whereEqual('BillingCity', 'Tokyo')
⚠️ Before v3.5.0, OR-after-AND slipped through.
toSoql()succeeded and the query then died at run time with the unhelpfulunexpected token: OR.The check runs at build time, not chain time, so that a mix legitimately resolved by an
ignoreWhen()retraction — or by a semantic skip such as an emptywhereNotIn— still passes.
Retracting a condition dynamically: ignoreWhen
| Method | Purpose |
|---|---|
ignoreWhen(Boolean shouldIgnore) | When true, retracts the immediately preceding where...() condition |
"Skip the condition when the input is empty" becomes a single chain, with no if branches and no reassignment.
Scribe scribe = Scribe.of(Opportunity.class)
.field('Id')
.whereIn('Id', ids).ignoreWhen(ids.isEmpty())
.whereLike('Name', keyword).ignoreWhen(String.isBlank(keyword));
The SOQL that comes out depends on the values at run time.
-- ids has values, keyword is blank
SELECT id FROM Opportunity WHERE Id IN ('006000000000000AAA', '006000000000000AAB')
-- both empty (no WHERE clause at all)
SELECT id FROM Opportunity
This also avoids the trap from "Empty Collections" above, where an empty whereIn yields zero rows.
It must be chained directly onto a where...(). Calling it first, after orderBy(), or twice in a row throws ApexEloquentException — the constraint keeps it unambiguous which condition an ignoreWhen() applies to.
Placed right after whereGroup(...), it retracts the whole group.
null values can be retracted too (v3.5.0+)
whereGreaterThan / whereGreaterThanOrEqual / whereLessThan / whereLessThanOrEqual / whereLike / whereNotLike / whereIn / whereNotIn / whereIncludes / whereExcludes — 10 methods (12 counting overloads) — do not accept null.
From v3.5.0 that null error is deferred to build time (toSoql()). Chaining only records an invalid condition, so a following ignoreWhen(true) retracts it.
// Through v3.4.x: whereGreaterThan threw on the spot — ignoreWhen was never reached
// From v3.5.0: retracted, and it never appears in the WHERE clause
.whereGreaterThan('CloseDate', closeAfter).ignoreWhen(closeAfter == null)
If it survives un-retracted to toSoql(), the exception names which method and which field originated it, and mentions ignoreWhen as the escape hatch.
⚠️
whereEqual/whereNotEqualare not among the 12.X = nullis valid SOQL, so null becomes the condition as written.
Combining with orCondition
Calling orCondition() while no condition exists yet is a no-op. So a chain does not break when the leading condition is retracted; whatever survives simply becomes the sole condition.
// Both present → OR. One retracted → the other stands alone. Both gone → no WHERE.
Scribe scribe = Scribe.of(Opportunity.class)
.field('Id')
.whereIn('StageName', stages).ignoreWhen(stages.isEmpty())
.orCondition()
.whereIn('OwnerId', ownerIds).ignoreWhen(ownerIds.isEmpty());
-- both present
SELECT id FROM Opportunity WHERE StageName IN ('Prospecting') OR OwnerId IN ('005000000000000AAA')
-- only stages (the OR disappears and it becomes a single condition)
SELECT id FROM Opportunity WHERE StageName IN ('Prospecting')
-- both empty
SELECT id FROM Opportunity
When the retracted condition was the OR one, OR-only mode is lifted along with it. The OR marker never outlives the condition it was attached to, so a plain condition after it is not rejected.
ORDER / LIMIT / OFFSET / forUpdate
| Method | Purpose |
|---|---|
orderBy(String field) | ASC sort |
orderBy(String field, String order) | ASC / DESC specification |
orderBy(String field, String order, String nullsOperator) | NULLS FIRST / NULLS LAST specification |
take(Integer limitNumber) | LIMIT clause |
offset(Integer offsetNumber) | OFFSET clause (max 2000; throws if exceeded) |
forUpdate() | FOR UPDATE clause |
Constraints: forUpdate cannot be combined with orderBy or offset. An exception is thrown at build time.
Aggregate Functions
| Method | SOQL |
|---|---|
count(String field, String alias) | COUNT(field) alias |
countDistinct(String field, String alias) | COUNT_DISTINCT(field) alias |
sum(String field, String alias) | SUM(field) alias |
average(String field, String alias) | AVG(field) alias |
max(String field, String alias) | MAX(field) alias |
min(String field, String alias) | MIN(field) alias |
The alias argument is required. Salesforce's standard AggregateResult defaults to field names like expr0 / expr1 / ... (in declaration order) when the alias is omitted — a classic beginner trap. ApexEloquent enforces aliases at the method signature level to sidestep this. Retrieve the result via aggregateEntry.get('alias') — the name you assigned.
Other notes:
- Reusing the same alias across multiple aggregate functions throws.
- Combining child subqueries (
withChildren) with aggregate functions is forbidden.
GROUP BY / HAVING
| Method | Purpose |
|---|---|
groupByField(String fieldName) | GROUP BY a single field |
groupByFields(List<String> fieldNames) | GROUP BY multiple fields |
groupByParent(Scribe parentScribe) | GROUP BY a parent-object field (pass Scribe.asParent(...).groupByField(...)) |
havingCondition(Scribe havingScribe) | HAVING clause (pass Scribe.asHaving().whereGreaterThan(alias, value)) |
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'))
.havingCondition(
Scribe.asHaving().whereGreaterThan('totalPrice', 1000)
);
SELECT product2id, SUM(TotalPrice) totalPrice, Opportunity.id, MAX(Opportunity.Amount) maxAmount
FROM OpportunityLineItem
GROUP BY Product2Id, Opportunity.Id
HAVING SUM(TotalPrice) > 1000
In HAVING, the alias expands back into the aggregate expression. Writing whereGreaterThan('totalPrice', 1000) yields SUM(TotalPrice) > 1000, so you never write the aggregate twice.
Inspection / Output
| Method | Return | Purpose |
|---|---|---|
toSoql() | String | Return the assembled SOQL string |
isAggregate() | Boolean | Whether this is an aggregate query (Eloquent uses this internally to route get) |
buildFieldStructure() | FieldStructure | Build the SELECT-clause field structure (used internally by MockEntry's SELECT-omission detection) |
buildAggregateFieldStructure() | FieldStructure | Build the field structure for aggregate queries |
getSelectedFields(Map<String, SObjectField>) | List<String> | List of fields targeted by SELECT |
toSoql() is handy for debugging and learning, but its real job is a batch start().
Building a batch QueryLocator with toSoql()
Database.getQueryLocator() demands SOQL as a string, so this is the one place a Scribe cannot be handed over directly. Build it with Scribe, then turn it into a string at the very end.
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator(scope().toSoql());
}
Extract the Scribe and SELECT-omission detection works in tests too
Batches have a structural hole. The records handed to execute(bc, scope) come straight from the platform — they never pass through IEloquent. So the contract of "what this query selected" never reaches the Usecase.
- Production:
scopeis a real query result, so touching an unselected field throws from the platform - Tests:
scopeis aMockEntryyou built yourself, so nothing is checked
fetchedBy(scribe) closes that gap. Pull the query construction out into a @TestVisible method and your test can hand over exactly the same Scribe production uses.
public with sharing class RematchCompanyCardsHandler implements Database.Batchable<SObject> {
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator(scope().toSoql());
}
// One definition, shared by production and tests
@TestVisible
private static Scribe scope() {
List<String> cardFields = new List<String>{ 'Id', 'CompanyName__c', 'MatchStatus__c' };
return Scribe.of(BusinessCard__c.class)
.fields(cardFields)
.whereEqual('MatchStatus__c', 'Unprocessed');
}
}
SELECT id, companyname__c, matchstatus__c FROM BusinessCard__c WHERE MatchStatus__c = 'Unprocessed'
// In the test: bake production's SELECT contract onto the mock
MockEntry card = MockEntry.of(BusinessCard__c.class)
.autoId(1)
.set('CompanyName__c', 'Acme')
.fetchedBy(RematchCompanyCardsHandler.scope());
Now the moment the Usecase reads a field that scope() does not select, the unit test fails. Adding a field on the Usecase side while forgetting to add it to the batch's SELECT gets caught before it ships.
See API Reference: MockEntry for
fetchedBy.
Read Next
- Building Queries with Scribe: the usage guide
- Parent Fields, Child Subqueries, and Many-to-Many: typical relation-operation examples
- API Reference: IEloquent / Eloquent / MockEloquent: execute the assembled Scribe
- ApexEloquent Guide: back to the guide index