API リファレンス: Scribe

•Apex Stem ドキュメント
Apex StemApexEloquentAPI ReferenceScribeSalesforceApex
Scribe クエリビルダーの全 API を整理した詳細リファレンス。ファクトリ・SELECT・WHERE・並び替え・集計・GROUP BY / HAVING・検査メソッドを網羅します。

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

使い方や典型シナリオは Scribe でクエリを組み立てる を参照してください。

Static ファクトリ

メソッド用途
Scribe.of(System.Type recordType)通常クエリの起点。Scribe.of(Account.class)
Scribe.source(Schema.SObjectType sObjectType)@deprecated。SObjectType を渡す旧来の起点で、後方互換のために残っています。新規コードは Scribe.of(...) を使います
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

項目名は組み立て時に検証される (v3.12.0+ / v2.10.0+)

SELECT 句が存在しない項目を弾くのは以前からですが、v3.12.0 からは WHERE 句の項目名も、組み立て時 (toSoql() / get()) に対象オブジェクトの describe と照合されます。存在しない項目名は ApexEloquentException になります。

APEX
Scribe.of(Contact.class).field('LastNam')                        // 弾かれる (以前から)
Scribe.of(Contact.class).field('Id').whereEqual('LastNam', 'x')  // 弾かれる (v3.12.0 から)

以前のバージョンでは、未知の項目名は型が解決できないため値が文字列として引用符付きで出力されていました (whereGreaterThan('Amoun', 1000) → Amoun > '1000')。実行時のエラーは型の不一致を指すので綴り間違いに気づきにくく、しかも MockEloquent は SOQL を実行しないので、単体テストでは緑のまま本番まで届いていました。

  • 項目名にドットは書けません。whereEqual('Account.Type', 'x') は弾かれます。親の項目で絞るときは parentCondition(Scribe.asParent('AccountId').whereEqual('Type', 'x')) と書きます
  • ポリモーフィックな親 (Owner / Queue / What / Who) での絞り込みと、項目の位置に SOQL 関数 (toLabel(Status) / convertCurrency(...) / CALENDAR_YEAR(...)) を書く形は未対応です
  • ignoreWhen(true) で取り下げた条件は検証されません。取り下げる側の分岐しか通らないテストでは綴り間違いが残るので、ignoreWhen を書いたら両方の分岐をテストで通します
  • orderBy('Account.Name') のドット表記は引き続き使えます (SOQL に親側の ORDER BY 構文が無く、親の項目で並べる唯一の書き方のため)。ORDER BY / GROUP BY / 集計句の項目名は、現時点では検証対象外です

⚠️ 上げた瞬間に、これまで一度も失敗していなかったクエリが落ちることがあります。空の whereNotIn で条件ごと消えていたもの、テストが通らない分岐にあったものなどです。テストが赤くなったら、旧バージョンに戻す前に項目名を確認してください。

空コレクションの扱い (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 指定。第 3 引数には 'FIRST' / 'LAST' だけを渡す (NULLS はフレームワークが前置する。'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()FieldStructureSELECT 句のフィールド構造を組み立て (MockEntry の SELECT 漏れ検知に内部利用)
buildAggregateFieldStructure()FieldStructure集計クエリ用のフィールド構造を組み立て
getSelectedFields(Map<String, SObjectField>)List<String>SELECT 対象フィールドのリスト

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

このほか、単体テスト専用に、組み立てたクエリが特定の条件を持っているかを検証する assertCondition / assertNoCondition / assertConditionAt / assertNoConditionAt / hasCondition / hasConditionAt / conditionAssertionMessage があります (v3.11.0+ / v2.9.0+、すべて @TestVisible)。MockEloquent.scribeAt(label) と組み合わせて使います。詳しくは API リファレンス: IEloquent / Eloquent / MockEloquent の「クエリの形を検証する」を参照してください。

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 を参照してください。

次に読む