ApexEloquent API Reference: Scribe

Apex Stem Docs
Apex StemApexEloquentAPI ReferenceScribeSalesforceApex
Complete API reference for the Scribe query builder class. Factory methods, SELECT / WHERE / ORDER clauses, aggregates, GROUP BY / HAVING, and inspection.

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

MethodPurpose
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

MethodPurpose
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
APEX
List<String> oppFields = new List<String>{ 'Id', 'Name', 'StageName' };
Scribe scribe = Scribe.of(Opportunity.class)
  .fields(oppFields)
  .parentField(Scribe.asParent('AccountId').field('Name'));
SOQL
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

MethodSOQL 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.

MethodGiven an empty collectionResult
whereIn(f, empty)builds a condition that is always falsezero rows
whereNotIn(f, empty)drops the condition entirelyno 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

MethodPurpose
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
APEX
// (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');
SOQL
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.

APEX
// ❌ 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 unhelpful unexpected 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 empty whereNotIn — still passes.

Retracting a condition dynamically: ignoreWhen

MethodPurpose
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.

APEX
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.

SOQL
-- 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 / whereExcludes10 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.

APEX
// 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 / whereNotEqual are not among the 12. X = null is 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.

APEX
// 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());
SOQL
-- 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

MethodPurpose
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

MethodSOQL
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

MethodPurpose
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))
APEX
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)
  );
SOQL
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

MethodReturnPurpose
toSoql()StringReturn the assembled SOQL string
isAggregate()BooleanWhether this is an aggregate query (Eloquent uses this internally to route get)
buildFieldStructure()FieldStructureBuild the SELECT-clause field structure (used internally by MockEntry's SELECT-omission detection)
buildAggregateFieldStructure()FieldStructureBuild 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.

APEX
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: scope is a real query result, so touching an unselected field throws from the platform
  • Tests: scope is a MockEntry you 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.

APEX
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');
  }
}
SOQL
SELECT id, companyname__c, matchstatus__c FROM BusinessCard__c WHERE MatchStatus__c = 'Unprocessed'
APEX
// 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.