# API リファレンス: Scribe

`Scribe` は ApexEloquent のクエリビルダー。型ヒントとメソッドチェーンで SOQL を組み立てる **immutable** なクラスです。各メソッドは新しい `Scribe` インスタンスを返します。

使い方や典型シナリオは [Scribe でクエリを組み立てる](/ja/apex-stem/docs/apex-eloquent-scribe-guide) を参照してください。

## Static ファクトリ

| メソッド | 用途 |
|---|---|
| `Scribe.of(System.Type recordType)` | 通常クエリの起点。`Scribe.of(Account.class)` |
| `Scribe.source(Schema.SObjectType sObjectType)` | 同じく起点。`SObjectType` を渡す形。`Scribe.source(Account.getSObjectType())` |
| `Scribe.asParent(String parentRelationIdFieldName)` | 親リレーション専用 Scribe。`parentField` / `parentCondition` / `groupByParent` に渡す |
| `Scribe.asChild(System.Type childRecordType)` | 子サブクエリ専用 Scribe。`withChildren` に渡す |
| `Scribe.asGroup()` | WHERE 句を括弧で括る用 Scribe。`whereGroup` に渡す |
| `Scribe.asHaving()` | HAVING 句専用 Scribe。`havingCondition` に渡す |
| `Scribe.asThrough(System.Type junctionType, String relatedKey)` | Junction Object 経由の多対多用 Scribe。`through` に渡す |

## SELECT 系

| メソッド | 用途 |
|---|---|
| `field(String fieldName)` | 単一フィールドを SELECT |
| `fields(List<String> fieldNames)` | 複数フィールドを SELECT |
| `allFields()` | 対象 SObject の全フィールドを SELECT |
| `parentField(Scribe parentScribe)` | 親オブジェクトのフィールドを SELECT に組み込む |
| `withChildren(Scribe childScribe)` | 子サブクエリを追加 |
| `through(Scribe throughScribe)` | Junction Object 経由の関連を追加 |
| `relationName(String relationName)` | 子サブクエリ / 多対多 で曖昧性を解消する relationship 名を明示 |

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

SELECT 句の項目名は小文字に正規化され、親項目は**リレーション名を前置**して並びます (自分の項目 → 親の項目の順)。WHERE 句の項目名は元の表記のままです。

## WHERE 系

### 比較・包含・パターンマッチ

| メソッド | SOQL 出力 |
|---|---|
| `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 不可) |
| `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 '...'` (`'` は自動エスケープ) |
| `whereNotLike(String f, String pattern)` | `f NOT LIKE '...'` |
| `whereIn(String f, Object values)` | `f IN (...)`。`List` / `Set` をそのまま受ける (詰め替え不要)。空の扱いは下記参照 |
| `whereIn(String f, Scribe subQuery)` | `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` |
| `whereExcludes(String f, List<String> values)` | 多値選択リスト `EXCLUDES` |
| `whereNull(String f)` | `f = NULL` |
| `whereNotNull(String f)` | `f != NULL` |

### 空コレクションの扱い (whereIn / whereNotIn で非対称)

`whereIn` / `whereNotIn` に**空のコレクション**を渡したときの挙動は、両者で異なります。

| メソッド | 空を渡すと | 結果 |
|---|---|---|
| `whereIn(f, 空)` | **常に偽になる条件**を組み立てる | **0 件**になる |
| `whereNotIn(f, 空)` | **条件ごと無視**する | 絞り込みなし = **全件**側に倒れる |

SOQL としてはどちらも妥当ですが、**「空なら絞り込まない」つもりで `whereIn` を書くと 0 件になります**。意図を明示するなら、次の `ignoreWhen` を使ってください。

### 論理結合とグループ化

| メソッド | 用途 |
|---|---|
| `orCondition()` | 次の where を OR で結合 (一度入れたら以降すべて OR) |
| `whereGroup(Scribe groupScribe)` | 条件群を括弧で括る (`Scribe.asGroup()` から組み立て) |
| `parentCondition(Scribe parentScribe)` | 親オブジェクトの条件で絞り込む |

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

#### AND と OR を素で混ぜると組み立て時に落ちる (v3.5.0+)

SOQL は **1 つの階層で AND と OR が括弧なしで混ざる**ことを許しません。次はどちらの向きでも `ApexEloquentException` になり、**`whereGroup` を使うよう案内されます**。

```apex
// ❌ AND のあとに OR
.whereEqual('Industry', 'Tech').whereEqual('Name', 'X').orCondition().whereEqual('BillingCity', 'Tokyo')
```

> ⚠️ **v3.5.0 未満では OR-after-AND がすり抜けます。** `toSoql()` は成功するのに、実行時に SOQL 側が `unexpected token: OR` で落ちるという分かりにくい失敗になっていました。
>
> 判定は**チェーン時ではなく組み立て時**です。`ignoreWhen()` による取り下げや、空の `whereNotIn` のような意味上のスキップで混在が解消されるケースを、正しく通すためです。

### 条件を動的に取り下げる: ignoreWhen

| メソッド | 用途 |
|---|---|
| `ignoreWhen(Boolean shouldIgnore)` | `true` のとき、**直前の `where...()` 条件を取り下げる** |

「入力が空なら条件を付けない」を、`if` 分岐や再代入なしに 1 本のチェーンで書けます。

```apex
Scribe scribe = Scribe.of(Opportunity.class)
  .field('Id')
  .whereIn('Id', ids).ignoreWhen(ids.isEmpty())
  .whereLike('Name', keyword).ignoreWhen(String.isBlank(keyword));
```

実行時の値によって、組み上がる SOQL が変わります。

```soql
-- ids に値があり、keyword は空のとき
SELECT id FROM Opportunity WHERE Id IN ('006000000000000AAA', '006000000000000AAB')

-- 両方とも空のとき (WHERE 句自体が付かない)
SELECT id FROM Opportunity
```

上の「空コレクションの扱い」で触れた、**空の `whereIn` が 0 件になる**罠も、これで避けられます。

**必ず `where...()` の直後にチェーンします。** 先頭で呼ぶ / `orderBy()` の後で呼ぶ / 2 回続けて呼ぶと `ApexEloquentException` になります。`ignoreWhen()` がどの条件に掛かっているかを曖昧にしないための制約です。

`whereGroup(...)` の直後に置いた場合は、**そのグループ全体**が取り下げ対象になります。

#### null 値も取り下げられる (v3.5.0+)

`whereGreaterThan` / `whereGreaterThanOrEqual` / `whereLessThan` / `whereLessThanOrEqual` / `whereLike` / `whereNotLike` / `whereIn` / `whereNotIn` / `whereIncludes` / `whereExcludes` の **10 種 (オーバーロード込みで 12 メソッド)** は `null` を受け付けません。

v3.5.0 から、この **null エラーは組み立て時 (`toSoql()`) まで遅延**します。チェーン時点では「無効な条件」として記録されるだけなので、**直後の `ignoreWhen(true)` で取り下げられます**。

```apex
// v3.4.x まで: whereGreaterThan の時点で例外 → ignoreWhen に到達しない
// v3.5.0 から: 取り下げられて WHERE 句に出ない
.whereGreaterThan('CloseDate', closeAfter).ignoreWhen(closeAfter == null)
```

取り下げられずに `toSoql()` まで残った場合は、**どのメソッドのどの項目か**を名指しし、`ignoreWhen` という逃げ道も併記した例外になります。

> ⚠️ `whereEqual` / `whereNotEqual` はこの 12 に含まれません。`X = null` は SOQL として正当なので、null がそのまま条件になります。

#### orCondition との組み合わせ

条件が 1 つも無い状態で `orCondition()` を呼んでも **何も起きません (no-op)**。取り下げによって先頭の条件が消えても落ちず、残ったほうが単独の条件になります。

```apex
// 両方あれば OR、片方が消えれば残りが単独条件、両方消えれば WHERE 自体が付かない
Scribe scribe = Scribe.of(Opportunity.class)
  .field('Id')
  .whereIn('StageName', stages).ignoreWhen(stages.isEmpty())
  .orCondition()
  .whereIn('OwnerId', ownerIds).ignoreWhen(ownerIds.isEmpty());
```

```soql
-- 両方あるとき
SELECT id FROM Opportunity WHERE StageName IN ('Prospecting') OR OwnerId IN ('005000000000000AAA')

-- stages だけあるとき (OR が消え、単独条件になる)
SELECT id FROM Opportunity WHERE StageName IN ('Prospecting')

-- 両方とも空のとき
SELECT id FROM Opportunity
```

OR 条件だけが取り下げられた場合、**OR モードも一緒に解除されます**。取り下げた条件の OR マーカーが残って、後続の素の条件が弾かれる、ということは起きません。

## ORDER / LIMIT / OFFSET / forUpdate

| メソッド | 用途 |
|---|---|
| `orderBy(String field)` | ASC 並び替え |
| `orderBy(String field, String order)` | `ASC` / `DESC` 指定 |
| `orderBy(String field, String order, String nullsOperator)` | `NULLS FIRST` / `NULLS LAST` 指定 |
| `take(Integer limitNumber)` | LIMIT 句 |
| `offset(Integer offsetNumber)` | OFFSET 句 (最大 2000、超えると例外) |
| `forUpdate()` | FOR UPDATE 句 |

**制約**: `forUpdate` は `orderBy` および `offset` と併用できません。組み立て段階で例外が出ます。

## 集計関数

| メソッド | 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` |

**`alias` は必須引数** です。Salesforce 標準の `AggregateResult` はエイリアスを省略するとデフォルトで `expr0` / `expr1` / ... (宣言順) のフィールド名でアクセスする必要があり、初学者の罠になりがちですが、ApexEloquent はメソッドシグネチャでエイリアスを強制することでこのつまずきを避けています。戻り値の取り出しは `aggregateEntry.get('alias 名')` で、自分が付けた名前で参照できます。

**そのほかの注意**:
- 同じ alias を複数の集計関数で使うと例外。
- 子サブクエリ (`withChildren`) と集計関数の併用は不可。

## GROUP BY / HAVING

| メソッド | 用途 |
|---|---|
| `groupByField(String fieldName)` | 単一フィールドで GROUP BY |
| `groupByFields(List<String> fieldNames)` | 複数フィールドで GROUP BY |
| `groupByParent(Scribe parentScribe)` | 親オブジェクトのフィールドで GROUP BY (`Scribe.asParent(...).groupByField(...)` を渡す) |
| `havingCondition(Scribe havingScribe)` | HAVING 句 (`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
```

HAVING では **alias が集計式に展開されます**。`whereGreaterThan('totalPrice', 1000)` と書けば `SUM(TotalPrice) > 1000` になるので、集計式を二度書く必要はありません。

## 検査・出力

| メソッド | 戻り値 | 用途 |
|---|---|---|
| `toSoql()` | `String` | 組み立てた SOQL 文字列を返す |
| `isAggregate()` | `Boolean` | 集計クエリかどうかを判定 (Eloquent が `get` の振り分けで内部利用) |
| `buildFieldStructure()` | `FieldStructure` | SELECT 句のフィールド構造を組み立て (MockEntry の SELECT 漏れ検知に内部利用) |
| `buildAggregateFieldStructure()` | `FieldStructure` | 集計クエリ用のフィールド構造を組み立て |
| `getSelectedFields(Map<String, SObjectField>)` | `List<String>` | SELECT 対象フィールドのリスト |

`toSoql()` はデバッグや学習用にも使いますが、**実用上の主役はバッチの `start()`** です。

### toSoql() でバッチの QueryLocator を組み立てる

`Database.getQueryLocator()` は SOQL を**文字列**で要求するため、ここだけは `Scribe` をそのまま渡せません。組み立ては `Scribe` で行い、最後に `toSoql()` で文字列にします。

```apex
public Database.QueryLocator start(Database.BatchableContext bc) {
  return Database.getQueryLocator(scope().toSoql());
}
```

### Scribe を切り出すと、テストでも SELECT 漏れ検知が効く

バッチには構造上の穴があります。`execute(bc, scope)` に渡ってくるレコードは **プラットフォームが直接渡してくる** もので、`IEloquent` を通りません。つまり「このクエリが何を SELECT したか」という契約が、Usecase 側に届きません。

- **本番**: `scope` は実クエリの結果なので、SELECT していない項目に触れればプラットフォームが例外を投げます
- **テスト**: `scope` は自分で組み立てた `MockEntry` なので、**何も検査されません**

この差を埋めるのが `fetchedBy(scribe)` です。クエリの組み立てを `@TestVisible` なメソッドに切り出しておけば、テストから**本番とまったく同じ `Scribe`** を取り出して、モックに焼き付けられます。

```apex
public with sharing class RematchCompanyCardsHandler implements Database.Batchable<SObject> {
  public Database.QueryLocator start(Database.BatchableContext bc) {
    return Database.getQueryLocator(scope().toSoql());
  }

  // 本番とテストで同じ 1 つの定義を共有する
  @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
// テスト側: 本番の SELECT 契約をモックに焼き付ける
MockEntry card = MockEntry.of(BusinessCard__c.class)
  .autoId(1)
  .set('CompanyName__c', 'Acme')
  .fetchedBy(RematchCompanyCardsHandler.scope());
```

これで、Usecase が `scope()` に無い項目を読んだ瞬間に**単体テストで落ちます**。バッチの `start` に項目を足し忘れたまま Usecase 側だけ増やした、という食い違いを、本番に出る前に捕まえられます。

> `fetchedBy` は [API リファレンス: MockEntry](/ja/apex-stem/docs/apex-eloquent-api-entry) を参照してください。

## 次に読む

- [Scribe でクエリを組み立てる](/ja/apex-stem/docs/apex-eloquent-scribe-guide): 使い方ガイド
- [親項目・子サブクエリ・多対多](/ja/apex-stem/docs/apex-eloquent-relations): リレーション操作の典型例
- [API リファレンス: IEloquent / Eloquent / MockEloquent](/ja/apex-stem/docs/apex-eloquent-api-eloquent): 組み立てた Scribe を実行する
- [ApexEloquent ガイド](/ja/apex-stem/docs/apex-eloquent-guide): ガイド目次に戻る
