ApexEloquent: Building queries with Scribe

Apex Stem Docs
Apex StemApexEloquentScribeSOQLSalesforceApex
Reference for the Scribe query builder. Covers SELECT clauses, WHERE conditions, ordering, aggregates, dynamic composition, and the immutable builder pattern.

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.

APEX
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 Scribe instance. You can branch midway to derive condition-varied queries without altering the original Scribe.
  • Query execution is delegated to IEloquent: when you hand a finished Scribe to IEloquent.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: IEloquent is an interface, with Eloquent (issues real SOQL) in production and MockEloquent (no DB; returns the injected IEntry list as-is) in tests, swapped via the Layered Constructor Pattern. The Usecase-side code depends on a field of type IEloquent, 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>).

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

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

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

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

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

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

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

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

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

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

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')
  );
// → 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 an Integer, 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 total between sum('A','total') and max('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.

APEX
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 null case needs v3.5.0 or later. The 12 methods that reject nullwhereGreaterThan, whereIn and friends — used to throw the moment you chained them, so a following ignoreWhen was never reached. From v3.5.0 the error is deferred to build time, which lets ignoreWhen retract it. The closeAfter == null line 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.

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

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