Scribe でクエリを組み立てる

Apex Stem ドキュメント
Apex StemApexEloquentScribeSOQLSalesforceApex
ApexEloquent のクエリビルダー Scribe の使い方を 1 本でまとめます。SELECT・WHERE・並び替え・集計・動的クエリの組み立てまで。

このドキュメントは、ApexEloquent のクエリビルダー Scribeプロダクションコードでどう書くか に焦点を当てた使い方ガイドです。全 API のシグネチャ一覧は API リファレンス: Scribe を参照してください。他の ApexEloquent トピックの一覧は ApexEloquent ガイド から確認できます。

Scribe とは

Scribe は、SOQL を型付きのメソッドチェーンで組み立てるクエリビルダーです。Scribe.of(Account.class) から始めて fieldwhereEqualorderBy などを積み重ねるだけで、SOQL 文字列が完成します。

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

ポイントは 3 つです。

  • 不変 (immutable): 各メソッドは新しい Scribe インスタンスを返します。途中で分岐させて条件違いのクエリを派生させても、元の Scribe は変わりません。
  • クエリ実行は IEloquent に委譲: 完成した ScribeIEloquent.get(scribe) に渡すと、内部で SOQL が発行されてデータが取得されます。この クエリ組み立てと実行の分離 が ApexEloquent の中核で、詳しくは Query Delegation Pattern で解説しています。
  • DI で Mock に差し替え可能: IEloquent はインターフェースで、本番では Eloquent (実 SOQL を発行)、テストでは MockEloquent (DB を介さず、注入された IEntry をそのまま返す) を Layered Constructor Pattern で差し替えられます。Usecase 側のコードは IEloquent 型のフィールドに依存するので、本番もテストも呼び方は同じまま、テスト時だけ DB を介さずロジックを検証できます。詳しくは データ取得と DML、IEntry、Mock を参照してください。

フィールド名は SObjectField ではなく 文字列 で渡します ('Id' / 'Industry__c' など)。これにより、ビルダーは Apex の型システムに縛られず、実行時に動的に組み立てられます。

SELECT する項目を決める

単一フィールドと複数フィールド

field(String fieldName) で 1 つずつ追加するか、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>) には、あらかじめ宣言した List<String> をそのまま渡すこともできます。

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'

parentField で親項目を取り込む

Scribe.asParent('AccountId').field(...)parentField に渡すと、親オブジェクトのフィールドが SELECT 句に取り込まれます。

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'

子サブクエリ (withChildren) や多対多 (through) は 親項目・子サブクエリ・多対多 で詳しく扱います。

allFields で全件 SELECT

allFields() を使うと、対象オブジェクトのアクセス可能な全フィールドを SELECT します。プロダクションでは必要なフィールドを明示するほうが安全ですが、テストデータの構築や調査用には便利です。

WHERE で絞り込む

基本の WHERE

等価 (whereEqual / whereNotEqual)、比較 (whereGreaterThan 系 / whereLessThan 系)、パターン (whereLike / whereNotLike)、リスト (whereIn / whereNotIn)、多値選択 (whereIncludes / whereExcludes)、null チェック (whereNull / whereNotNull) があります。シグネチャと挙動の詳細は API リファレンス: Scribe を参照。

APEX
Scribe scribe = Scribe.of(Opportunity.class)
  .field('Id')
  .whereEqual('StageName', 'Prospecting')
  .whereGreaterThan('Amount', 1000)
  .whereIn('OwnerId', ownerIds);  // Set<Id> をそのまま渡せる
// → SELECT id FROM Opportunity WHERE StageName = 'Prospecting' AND Amount > 1000 AND OwnerId IN (...)

連続した whereXxx はデフォルトで AND で結合されます。

AND と OR を混ぜる

OR を入れたい場合は orCondition()次の 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'

OR を一度入れたら、以降の where はすべて OR で結合する必要があります。途中で AND に戻すことはできません。OR と AND を混ぜたい時は、AND 条件を前に集めて、OR 条件を後ろに置く か、whereGroup + Scribe.asGroup() で片側を括弧でまとめます。

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'

なお、whereIn空のリスト を渡すと、SOQL では Id = null (= 必ず偽の条件) に変換されます。「絞り込み対象が 0 件のときは結果も 0 件」という挙動を、呼び出し側で isEmpty() チェックせずにそのまま書ける仕様です。

⚠️ whereNotIn は逆で、空を渡すと条件ごと無視されます (絞り込みなし = 全件側)。「空なら絞り込まない」つもりで whereIn を書くと 0 件になるので、意図する側を明示したいときは後述の ignoreWhen を使ってください。

サブクエリで IN する

whereIn の第 2 引数には 別の Scribe を渡せます。「先に ID を取ってきてから次のクエリで使う」という二段 SOQL を 1 本にまとめられます。

APEX
// 「フォローしている取引先」に紐づく商談だけを取る
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 = '...')

SOQL の IN (SELECT ...) 構文がそのまま使えるイメージです。whereNotIn でも同じく Scribe を渡せます。

whereLike は SQL インジェクションを自動エスケープ

whereLike(field, pattern) に渡した pattern 内のシングルクォート (') は自動でエスケープされます。ユーザー入力をそのまま渡しても SOQL インジェクションは起きません。

APEX
String userInput = "O'Brien";  // 一見危なそうな入力
Scribe scribe = Scribe.of(Contact.class)
  .field('Id')
  .whereLike('LastName', '%' + userInput + '%');
// → ...WHERE LastName LIKE '%O\'Brien%'

並び替え・LIMIT・FOR UPDATE

orderBy / take / offset / forUpdate で並び替え・件数制限・ロックが書けます。

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

forUpdateorderBy / offset と併用すると例外、offset は最大 2000 など、SOQL の制約をそのまま反映する組み立て時チェックがあります。詳細は API リファレンス: Scribe を参照。

集計クエリ (Aggregate)

「親 ID ごとに子レコードの件数を集計」のようなケースでは、Apex 側の SOQL 行数とヒープ消費を抑えるため、集計クエリを使います。

APEX
Scribe eventScribe = Scribe.of(Event.class)
  .field('WhatId')               // GROUP BY する field は SELECT にも明示
  .count('Id', 'eventCount')     // alias 付きで COUNT
  .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();
}

集計関数は count / countDistinct / sum / average / max / min の 6 種類。GROUP BY 系は groupByField / groupByFields / groupByParent、HAVING 句は havingCondition(Scribe.asHaving()...) で組み立てます。それぞれのシグネチャは API リファレンス: Scribe を参照。

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

親フィールドの集計とグループ化

「親オブジェクトのフィールド」を集計や GROUP BY の対象にしたい場合、直接 field('Account.Industry') のような書き方はできません。親に関するものは Scribe.asParent(...) を経由する必要があります。

例: 商品 (Product2) ごとの注文明細合計と、その明細が属する商談 (Opportunity) の最高額を、商品 × 商談単位で集計する。

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

ポイント:

  • 親の SELECT 項目と集計関数は parentField(Scribe.asParent('OpportunityId').field(...).max(...)) の中にまとめる
  • 親フィールドでの GROUP BY は groupByParent(Scribe.asParent('OpportunityId').groupByField('Id')) で表現する
  • HAVING 句で親由来 alias (maxAmount) を参照するときも、Scribe.asHaving().whereGreaterThan('maxAmount', 1000) のように同じ alias で書ける

集計クエリの注意点

  • GROUP BY する field は SELECT にも明示する (field('Product2Id') を忘れない)
  • 集計結果は Decimal で返る。Integer に入れる時は ((Decimal) aggregateEntry.get('alias')).intValue() でキャスト
  • 取得 API は通常の get(scribe) のままで OK。集計クエリか通常クエリかはフレームワークが自動判別 します
  • 同じ alias を複数の集計関数で使うと例外になります (sum('A','total')max('B','total') のように total を重複させない)
  • 子サブクエリ (withChildren) と集計関数の併用は SOQL の制約により不可

動的にクエリを組み立てる

検索フィルタのように「入力があるときだけ条件を付ける」クエリは、ignoreWhen分岐なしの 1 本のチェーンとして書けます。

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);
// 3 つ全部が渡された時:
// → SELECT id, name FROM Opportunity WHERE Industry = 'Technology' AND StageName IN (...) AND CloseDate > 2026-01-01
// 何も渡されなかった時:
// → SELECT id, name FROM Opportunity

ignoreWhen(true)直前の条件を取り下げます。空の whereIn0 件 になってしまう罠も、これで避けられます (詳細は API リファレンス: Scribe)。

⚠️ 値が null のケースは v3.5.0 以上が必要です。 whereGreaterThan / whereIn など null を受け付けない 12 メソッドは、v3.4.x までチェーンした瞬間に例外を投げていたため、後続の ignoreWhen に到達できませんでした。v3.5.0 でエラーが組み立て時まで遅延するようになり、ignoreWhen で取り下げられます。上の closeAfter == null の行はまさにこのケースです。

if で積み上げる書き方

ignoreWhen が無いバージョンでは、条件分岐で積み上げます。Scribe は不変なので 再代入 (scribe = scribe.whereXxx(...)) が必要です。

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);
}

この形は今も動きますが、条件が増えるほど「クエリの形」が分岐の中に散らばって読みにくくなります。新しく書くなら ignoreWhen に寄せてください。

派生クエリを作る

各メソッドが 新しい Scribe インスタンス を返すので、1 本のクエリから派生を作れます。元の Scribe は変わりません。たとえば「同じ商談集合に対して、件数取得用と一覧取得用の 2 本を組む」ようなパターンです。

APEX
Scribe baseScribe = Scribe.of(Opportunity.class)
  .whereEqual('StageName', 'Prospecting')
  .whereGreaterThan('Amount', 1000);
 
// 件数だけ取りたい
Scribe countScribe = baseScribe.count('Id', 'cnt');
// → SELECT COUNT(Id) cnt FROM Opportunity WHERE StageName = 'Prospecting' AND Amount > 1000
 
// 一覧を取りたい
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 は両方の派生元として変わらず再利用できます。

次に読む