# 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](/apex-stem/docs/apex-eloquent-scribe-guide).

## 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 |

```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

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

```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

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

```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` / `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**.

```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

| 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)`) |

```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

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

```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](/apex-stem/docs/apex-eloquent-api-entry) for `fetchedBy`.

## Read Next

- [Building Queries with Scribe](/apex-stem/docs/apex-eloquent-scribe-guide): the usage guide
- [Parent Fields, Child Subqueries, and Many-to-Many](/apex-stem/docs/apex-eloquent-relations): typical relation-operation examples
- [API Reference: IEloquent / Eloquent / MockEloquent](/apex-stem/docs/apex-eloquent-api-eloquent): execute the assembled Scribe
- [ApexEloquent Guide](/apex-stem/docs/apex-eloquent-guide): back to the guide index
