Scribe でクエリを組み立てる
このドキュメントは、ApexEloquent のクエリビルダー Scribe を プロダクションコードでどう書くか に焦点を当てた使い方ガイドです。全 API のシグネチャ一覧は API リファレンス: Scribe を参照してください。他の ApexEloquent トピックの一覧は ApexEloquent ガイド から確認できます。
Scribe とは
Scribe は、SOQL を型付きのメソッドチェーンで組み立てるクエリビルダーです。Scribe.of(Account.class) から始めて field、whereEqual、orderBy などを積み重ねるだけで、SOQL 文字列が完成します。
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に委譲: 完成したScribeをIEloquent.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>) でまとめて渡します。
Scribe scribe = Scribe.of(Opportunity.class)
.field('Id')
.field('Name')
.field('StageName');
// → SELECT id, name, stagename FROM Opportunity
fields(List<String>) には、あらかじめ宣言した List<String> をそのまま渡すこともできます。
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 句に取り込まれます。
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 を参照。
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 の前に 挟みます。
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() で片側を括弧でまとめます。
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 本にまとめられます。
// 「フォローしている取引先」に紐づく商談だけを取る
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 インジェクションは起きません。
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 で並び替え・件数制限・ロックが書けます。
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
forUpdate を orderBy / offset と併用すると例外、offset は最大 2000 など、SOQL の制約をそのまま反映する組み立て時チェックがあります。詳細は API リファレンス: Scribe を参照。
集計クエリ (Aggregate)
「親 ID ごとに子レコードの件数を集計」のようなケースでは、Apex 側の SOQL 行数とヒープ消費を抑えるため、集計クエリを使います。
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) の最高額を、商品 × 商談単位で集計する。
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 本のチェーンとして書けます。
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) は直前の条件を取り下げます。空の whereIn が 0 件 になってしまう罠も、これで避けられます (詳細は 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(...)) が必要です。
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 本を組む」ようなパターンです。
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 は両方の派生元として変わらず再利用できます。
次に読む
- データ取得と DML、IEntry、Mock: 組み立てた
ScribeをIEloquentで実行し、IEntryを扱う - 親項目・子サブクエリ・多対多: リレーションを使ったクエリと、
MockEntryでの親子モック - ApexEloquent ガイド: ApexEloquent ガイドの目次に戻る