extension-querying-oql について
`icp` CLI で OQL canister をクエリするためのリファレンススキルです。スキーマの確認、JSON クエリの作成、絞り込み、集計、関係のたどり方を説明します。
主な機能
- 01`schema()` でエンティティとフィールドを確認
- 02`execute` で JSON クエリを作成
- 03行の絞り込み・並べ替え・射影
- 04結果のグループ化と集計
- 05宣言済みエンティティ関係の走査
活用シーン
- 01canister のデータスキーマを調べる
- 02条件に合うレコードを検索・ページ取得
- 03グループ集計でレコードを要約
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ caffeinelabs/ skills --skill extension-querying-oqlインストール後、Claude Code で/extension-querying-oqlを入力して読み込まれたか確認します。
詳細
OQL のクエリ — クイックリファレンス
OQL カニスターは、読み取り専用メソッドを 2 つ公開します。
| メソッド | 戻り値 | 目的 |
|---|---|---|
schema() | 1 つの JSON Text | カニスターのエンティティのカタログです。各エンティティの主キー、フィールド、エッジが記載されています。 |
execute(qJson : text) | 型付き Candid Result | JSON エンコードされたクエリを実行し、一致する行を返します。 |
カニスターの呼び出し
icp CLI はサンドボックスにすでにインストールされ、設定されています。カニスター名 backend はプロジェクトのカニスターを参照します(ID やカニスター ID は不要です)。どちらのメソッドも query 呼び出しなので、すべての呼び出しで --query を使用します。
icp canister call backend schema '()' --query
icp canister call backend execute '("<json-query>")' --query
execute は text 引数を 1 つ取ります。これは Candid のテキストリテラルとして埋め込まれた JSON クエリです。JSON を ("...") で囲み、すべての " を \" にエスケープします。クエリ {"start":"customer","limit":3} は次のようになります。
icp canister call backend execute '("{\"start\":\"customer\",\"limit\":3}")' --query
schema() も同じ方法で JSON を返します。Candid の text リテラル ("...escaped json...") なので、読み取るには \" → "(および \\ → \)のようにエスケープを解除します。ドラフトではなくデプロイ済みのカニスターを読み取るには、--branch live を追加します(live は query 専用です)。文字列値にシングルクォートが含まれている場合は、シェル用に '\'' でエスケープします。
レシピ
- スキーマを一度取得します。
icp canister call backend schema '()' --query— セッション中はキャッシュしてください。変更されるのはデプロイ間のみです(§1)。 - リクエストをエンティティに対応付けます。 回答が含まれるエンティティを選びます。各フィールドの
typeNameとvaluesを使ってリテラルの型を選び、role: {"edge": ...}でエンティティ間のつながりを確認します。 - 1 つ以上のクエリに変換します。 行を取得したいエンティティから開始します(§2)。
where(§2.1)、orderBy/limit/offset、select、およびaggregate/groupBy(§2.2)を追加します。1 つのクエリでドット区切りのパスを使って前方エッジをたどります(§4.1)。逆方向の一対多では、まず親キーを取得し、その後inを使います(§4.2)。 - 実行して読み取ります。
icp canister call backend execute '("<json>")' --query— Candid の行はセルのnameを使って解析します(§3)。hasMoreがある場合はoffsetを使ってページングします(§5)。 - トラップ発生時は再試行します。 エラーを格納するラッパーはありません。スキーマを再度読み取り、クエリを修正して再実行します(§6)。
1. 検出 — schema
一度取得し、セッション中はキャッシュします。変更されるのはカニスターのデプロイ間のみです。
icp canister call backend schema '()' --query
次のように読み取ります。
name→ エンティティ名です。クエリのstartとして使用します。primaryKey→ 行を識別する値を持つフィールドです。エッジ{"to": "<entity>"}の値は、そのターゲットの主キー値です。fields→ 各フィールドのname、スカラーのtypeName、およびroleが含まれます。"payload"(通常のフィールド)または{"edge": {"to": "<entity>"}}(外部キーであり、グラフをたどる方法)です。2 つの列が同じ名前になる場合、名前には__1、__2、… のサフィックスが付くことがあります。schema()が返す正確な名前を使用してください。values(省略可能)→ フィールドが取り得る正確なリテラルです(通常はバリアントのアーム)。推測ではなく、それらのリテラルでフィルターします。["free","pro","enterprise"]の場合、クエリには"Enterprise"ではなく"enterprise"を指定します。存在しない場合は値に制限がありません。候補が必要なら、クエリでサンプルを取得してください。typeName→valueに指定する JSON リテラルの型です。"Nat"→ 符号なし整数(0、1、…)"Int"→ 符号付き整数(-1、0、1、…)"Float"→ 小数点を含む JSON 数値(0.5、-3.14、1.0e2)。整数(10)も受け付けます。数値バリアント間の変換が行われるため、gt(price, 10)はprice : Float = 12.5の行に一致します。Float の等価比較は IEEE-754 のビット単位で行われます。正確な二進数表現がない0.42のような小数には、範囲(ge+le)を使用してください。"Bool"→true/false"Null"→ サンプリングされた値がすべてnullでした。実際の型は不明です。"Text"→ JSON 文字列です。Principalフィールドは"Text"(正規のテキスト形式)として報告されます。文字列値でフィルターしてください。
2. クエリを作成する — execute
クエリは単一の JSON オブジェクトです。必須なのは start だけです。
{
"start": "<entityName>",
"where": <Predicate>,
"groupBy": ["<fieldName>", ...],
"aggregate": [{ "fn": "count|sum|avg|min|max", "field": "<fieldName>", "as": "<outName>" }, ...],
"orderBy": [{ "field": "<fieldName>", "dir": "asc|desc" }, ...],
"offset": <Nat>,
"limit": <Nat>,
"select": ["<fieldName>", ...]
}
| フィールド | デフォルト | 備考 |
|---|---|---|
start | (必須) | schema() のエンティティ name です。 |
where | 省略時 ⇒ フィルターなし | 単一の述語(§2.1)です。{"filter": ...} で囲みません。 |
groupBy | [] | これらのフィールドで行をグループ化し、一意の組み合わせごとに1つの出力行を返します(§2.2)。 |
aggregate | [] | バケットごとに集計します。groupBy が空の場合は、全行を集計します(§2.2)。 |
orderBy | [](カニスターで定義された順序。通常は挿入順) | 複数キーで並べ替えます。最初の句が優先されます。dir のデフォルトは "asc" です。 |
offset | 0 | 一致する先頭の N 件を除外します。 |
limit | 一致するすべての項目 | 最大 N 件を保持します。結果の hasMore で、さらに項目があるかどうかがわかります。 |
select | 非表示でないすべてのフィールド(集計時はグループキーと集計列) | 投影するサブセットです。 |
icp canister call backend execute '("{\"start\":\"customer\",\"limit\":3}")' --query
フィルター、並べ替え、投影を組み合わせた基本形(where + orderBy + limit + select):
icp canister call backend execute '("{\"start\":\"customer\",\"where\":{\"eq\":{\"field\":\"plan\",\"value\":\"enterprise\"}},\"orderBy\":[{\"field\":\"monthlyRevenueUsd\",\"dir\":\"desc\"}],\"limit\":5,\"select\":[\"companyName\",\"monthlyRevenueUsd\",\"accountManagerName\"]}")' --query
2.1 述語演算子
Predicate は、演算子名をキーとする キーがちょうど1つの JSON オブジェクトです。
| 演算子 | 形式 | 意味 |
|---|---|---|
eq / ne / lt / le / gt / ge | {"<op>": { "field": "<name>", "value": <scalar> } } | スカラー値の比較です。 |
in | {"in": { "field": "<name>", "value": [<scalar>, ...] } } | 含まれるかどうかを判定します。空の配列は何にも一致しません。 |
contains / startsWith / endsWith | {"<op>": { "field": "<name>", "value": "<text>" } } | Text に対する大文字と小文字を区別する部分文字列/接頭辞/接尾辞の検索です。サーバー側でスキャンするため、行をコンテキストにページ単位で取得する必要はありません。 |
icontains | {"icontains": { "field": "<name>", "value": "<text>" } } | 大文字と小文字を区別しない contains です。ユーザーが入力した検索語にはこちらを推奨します。 |
and / or / not | {"and": [<P>, ...]} / {"or": [<P>, ...]} / {"not": <P>} | ブール演算による組み合わせです。 |
テキスト検索はサーバー側で実行されます。「名前に north が含まれる顧客」は、行をスキャンしてコンテキストに取り込まず、1つのクエリで検索できます。
icp canister call backend execute '("{\"start\":\"customer\",\"where\":{\"icontains\":{\"field\":\"companyName\",\"value\":\"north\"}},\"select\":[\"companyName\",\"accountManagerName\"]}")' --query
<scalar> はフィールドの typeName と一致している必要があります。
| JSON | 対応先 | 使用するフィールドの typeName |
|---|---|---|
null | null_ | すべてのフィールド — eq null は任意フィールドが空の行を、ne null は値が設定されている行を検索します |
true / false | bool | "Bool" |
0、1、42 | nat | "Nat"(数値型変換により "Float" にも一致します) |
-1、-42 | int | "Int"(数値型変換により "Float" にも一致します) |
0.5、-3.14、1.0e2 | float | "Float" |
"foo" | text | "Text" |
フィールドが null_ の行は、ne と eq null を除くすべての比較に一致しません。関連をフィルターするには、field に "<edge>"、value に対象エンティティの 主キー値 を指定します。または、エッジをたどって "<edge>.<targetField>" を読み取ります(§4.1)。
2.2 集計 — count、groupBy、sum/avg/min/max
すべての行を取得してクライアント側で集計する代わりに、カニスター上で計算します。fn は count/sum/avg/min/max です。count 以外では field が必須です。min/max はテキストにも使用できます。as は出力列の名前を変更します(デフォルトは count、sum_<field> など)。. を含めることはできません(ドットはエッジをたどる区切り文字のため、解析エラーになります)。ドット区切りの field では、デフォルト名の各セグメントは _ で連結されます(dept.budget の sum → sum_dept_budget)。groupBy を指定せずに aggregate を指定すると、フィルター後の全体を1行で集計します(該当する行がない場合の count は 0 です)。aggregate を指定せずに groupBy を指定すると、サーバー側で DISTINCT を実行します。出力行にはグループキーと集計列のみが含まれます。
「エンタープライズ顧客は何社か?」— フィルター後の集合に対する count を実行し、1行を返します。
icp canister call backend execute '("{\"start\":\"customer\",\"where\":{\"eq\":{\"field\":\"plan\",\"value\":\"enterprise\"}},\"aggregate\":[{\"fn\":\"count\"}]}")' --query
「最も多くの顧客を担当しているアカウントマネージャーは誰で、MRR の合計はいくらか?」— groupBy + count + sum:
icp canister call backend execute '("{\"start\":\"customer\",\"groupBy\":[\"accountManager\"],\"aggregate\":[{\"fn\":\"count\"},{\"fn\":\"sum\",\"field\":\"monthlyRevenueUsd\",\"as\":\"mrr\"}],\"orderBy\":[{\"field\":\"count\",\"dir\":\"desc\"}],\"limit\":1}")' --query
3. 結果を読む
type Value = variant { null_; bool : bool; nat : nat; int : int; float : float; text : text };
type Cell = record { name : text; value : Value };
type Result = record { rows : vec vec Cell; hasMore : bool };
外側の rows = vec { ... } が行リストで、内側の各 vec { ... } が1行です。各 record { value = variant { "<tag>" = <payload> }; name = "<field>" } が1つのセルです。name はフィールドを示し、<tag> はスカラー型を示し、ペイロードは値です。35_000 : nat のアンダースコアは桁区切りなので、解析する場合は取り除いてください。hasMore = false ならすべての一致結果を取得済みで、hasMore = true なら結果が切り詰められているため、次のページを取得してください。セルは位置ではなく name で参照してください。select を変更すると順序が変わります。
4. エッジをたどる(結合)
前方(一対一)の関係は1回のクエリで処理できます。ドット区切りのパスは、フィールド位置を問わず、宣言されたエッジをたどります。逆方向(一対多)の関係は in パターン(§4.2)を使った2回のクエリのままです。
4.1 前方(子 → 親):ドット区切りパス
"<edgeField>.<targetField>" はエッジをサーバー側でたどります。where、groupBy、orderBy、aggregate.field、select で使えます。1回のクエリでエッジをたどって投影できます。
icp canister call backend execute '("{\"start\":\"customer\",\"where\":{\"eq\":{\"field\":\"companyName\",\"value\":\"Northstar Public\"}},\"select\":[\"companyName\",\"accountManager.name\",\"accountManager.office\"]}")' --query
複数段のチェーン("manager.department.name"、最大4段)も使え、集計と組み合わせることもできます。「アカウントマネージャーのオフィス別の平均収益」は1回の呼び出しで取得できます。
icp canister call backend execute '("{\"start\":\"customer\",\"groupBy\":[\"accountManager.office\"],\"aggregate\":[{\"fn\":\"avg\",\"field\":\"monthlyRevenueUsd\",\"as\":\"avg_mrr\"}],\"orderBy\":[{\"field\":\"avg_mrr\",\"dir\":\"desc\"}]}")' --query
ルール:
- 先頭のセグメントは、
schema()でroleが{"edge": {"to": ... }}のフィールドでなければなりません。エッジではないフィールドをたどるドット区切りパスは、値が外部キーのように見えても トラップします(たどり方はスキーマに基づき、名前から推測されることはありません)。作成者がエッジを宣言していない場合は、以下の2回のクエリのパターンを使ってください。 - null または参照先のない FK の場合、ドット区切りパス全体は
nullに解決されます(左結合)。その行はneを除くすべての関係条件に一致せず、セルにはnullが投影されます。 - 多側から集計してください。 エンティティをまたぐ集計は、開始エンティティの行を対象に実行されます。
employeeからの"department.budget"のavgは、従業員数で重み付けされます。部門ごとの数値を求めるには、departmentから開始するか、ドット区切りパスでグループ化して開始エンティティのフィールドを集計してください。 - エッジフィールド単体(
"accountManager")を選択しても、FK スカラーが返されます。.*は使えないため、必要な対象フィールドをそれぞれ指定してください。
4.2 逆方向(親1つ → 子複数)
1つの親の主キーには eq、まとめて指定する場合は in を使います。エッジフィールドに対して、対象エンティティの主キー値を指定してください。
icp canister call backend execute '("{\"start\":\"customer\",\"where\":{\"eq\":{\"field\":\"accountManager\",\"value\":\"[email protected]\"}},\"select\":[\"companyName\",\"monthlyRevenueUsd\"]}")' --query
親の条件が通常の述語で表せる場合、まとめて指定する必要はありません。エッジをたどる前方フィルターを使います(§4.1)。「ベルリンオフィスにいる人が担当するすべての顧客」は1回のクエリで取得できます。
icp canister call backend execute '("{\"start\":\"customer\",\"where\":{\"eq\":{\"field\":\"accountManager.office\",\"value\":\"Berlin\"}},\"select\":[\"companyName\",\"monthlyRevenueUsd\"]}")' --query
親の集合に独自のクエリ形状(上位N件、並べ替え、ページネーション)が必要な場合は、バッチ in パターンが必要です。まずキーを取得し、その後エッジフィールドに対して in を使います。「最も勤続年数の長い従業員3人が担当する顧客」は2回のクエリで取得します。
icp canister call backend execute '("{\"start\":\"employee\",\"orderBy\":[{\"field\":\"level\",\"dir\":\"desc\"}],\"limit\":3,\"select\":[\"email\"]}")' --query
# collect the three emails from the rows, then:
icp canister call backend execute '("{\"start\":\"customer\",\"where\":{\"in\":{\"field\":\"accountManager\",\"value\":[\"[email protected]\",\"[email protected]\",\"[email protected]\"]}},\"select\":[\"companyName\",\"monthlyRevenueUsd\"]}")' --query
N回の個別の eq クエリを実行するのではなく、必ず in でまとめてください。
4.3 複合条件
and / or で組み合わせます。
icp canister call backend execute '("{\"start\":\"customer\",\"where\":{\"and\":[{\"eq\":{\"field\":\"plan\",\"value\":\"enterprise\"}},{\"in\":{\"field\":\"country\",\"value\":[\"US\",\"CA\",\"DE\"]}},{\"ge\":{\"field\":\"monthlyRevenueUsd\",\"value\":20000}}]},\"orderBy\":[{\"field\":\"monthlyRevenueUsd\",\"dir\":\"desc\"}]}")' --query
4.4 2段階/自己エッジ結合
親キーが与えられておらず、先に検索する必要がある場合(例:「プロジェクト forge20 のリードに直属する人は誰か?」)、2回のクエリを実行します。2回目のクエリでは、1回目で返されたキーを使って、自己エッジ(employee.manager → employee)をフィルターします。
icp canister call backend execute '("{\"start\":\"project\",\"where\":{\"eq\":{\"field\":\"codename\",\"value\":\"forge20\"}},\"select\":[\"lead\"]}")' --query
# the row's `lead` cell is the lead's email, e.g. [email protected] — use it as the parent key:
icp canister call backend execute '("{\"start\":\"employee\",\"where\":{\"eq\":{\"field\":\"manager\",\"value\":\"[email protected]\"}},\"select\":[\"name\",\"jobTitle\",\"level\"]}")' --query
5. ページネーション
limit は結果数の上限を設定します。hasMore は結果が切り詰められたかどうかを示します。offset を使ってページを順に取得します。
offset = 0
limit = 25
loop:
result = icp canister call backend execute '("{\"start\":\"...\",\"limit\":25,\"offset\":<offset>,...}")' --query
consume result.rows
if not result.hasMore: break
offset += limit
limit は必ず明示的に設定してください。OQL 自体には上限がありません(limit を省略すると、すべての一致結果が返されます)が、canister の作成者が上限を追加している場合があります。その場合、上限を超える数を要求しても、結果は通知なく切り詰められます。
6. よくある問題
| 症状 | 原因/対処法 |
|---|---|
execute で OQL: unknown entity '...' が発生する | start が schema() のどの name とも一致していません。エンティティ名では大文字と小文字が区別されます。スキーマを読み直してください。 |
execute で解析エラーが発生する | JSON の形式が正しくない(末尾のカンマ、シングルクォート)か、Candid のテキストリテラルとしてエスケープされていません。各内側の " を \" にエスケープして、("...") の形式で囲んでください。まず python3 -m json.tool で JSON を検証してください。 |
execute で OQL: unknown field '...' on '...' — fields: ... が発生する | field の入力ミスです。トラップにエンティティの正しいフィールドが列挙されています。その中から選んでください。 |
execute で OQL: invalid query — where: field "..." is Int but value is Text が発生する | value リテラルの JSON 型が、フィールドの typeName と一致することはありません(数値フィールドに対する "5" や "now-7d"、Text/Bool に対する数値など。in の要素は一つずつチェックされます)。フィールドの型に合ったリテラルを指定してください。数値フィールドでは Nat、Int、Float のいずれも使用でき、null は常に null 判定に使えます。 |
| 一致するはずのフィルターで行が返されない | フィールドがストレージ上で実際に null_ であるか、リテラルの型は正しいものの値が間違っています(大文字と小文字、単位など。タイムスタンプはエポックからのナノ秒です)。 |
contains で確認できるはずの行が見つからない | contains/startsWith/endsWith では大文字と小文字が区別されます。ユーザーが入力する検索語には icontains を使用してください。 |
ドット区切りのパスで 'x' is not an edge of 'y' が発生する | 先頭のセグメントが宣言済みのエッジではありません。値が外部キーのように見えても、たどれるパスはスキーマに従います。代わりに、2 つのクエリで in を使うパターンを使用してください。 |
| エンティティをまたぐ平均値が正しくないように見える | 集計は start エンティティの行に対して実行されます。平均を求めたい行を持つエンティティを start にするか、ドット区切りのパスでグループ化し、start エンティティのフィールドを集計してください。 |
execute は行を返すが、フィールドが欠けている | select に指定したフィールドがエンティティにありません(入力ミス、または作成者によって非表示にされています)。select からそのフィールドを削除するか、既定の射影を使うために select を削除してください。 |
構造化されたエラーエンベロープはありません。 失敗すると必ずトラップが発生します。クエリを修正して再試行してください。