关于 extension-querying-oql
通过 `icp` CLI 查询 OQL canister 的参考技能,涵盖架构发现、JSON 查询、筛选、聚合和关系遍历。
主要功能
- 01用 `schema()` 查看实体与字段
- 02用 `execute` 构造 JSON 查询
- 03筛选、排序和投影行数据
- 04分组并聚合查询结果
- 05遍历已声明的实体关系
适用场景
- 01检查 canister 的数据架构
- 02搜索并分页读取匹配记录
- 03按分组汇总记录
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ caffeinelabs/ skills --skill extension-querying-oql安装后在 Claude Code 中输入/extension-querying-oql确认已加载。
详细介绍
查询 OQL — 快速参考
OQL canister 提供两个只读方法:
| 方法 | 返回值 | 用途 |
|---|---|---|
schema() | 一个 JSON Text | canister 实体目录:每个实体的主键、字段和边。 |
execute(qJson : text) | 类型化的 Candid Result | 运行 JSON 编码的查询并返回匹配的行。 |
调用 canister
icp CLI 已在沙盒中安装并配置;canister 名称 backend 会解析到项目的 canister(无需身份,也无需 canister ID)。这两个方法都是 query 调用,因此每次调用都使用 --query:
icp canister call backend schema '()' --query
icp canister call backend execute '("<json-query>")' --query
execute 接受一个 text 参数——将 JSON 查询嵌入 Candid 文本字面量中。用 ("...") 包裹 JSON,并将每个 " 转义为 \"。查询 {"start":"customer","limit":3} 转换为:
icp canister call backend execute '("{\"start\":\"customer\",\"limit\":3}")' --query
schema() 也以相同方式返回 JSON——Candid text 字面量 ("...escaped json...");将 \" 反转义为 "(将 \\ 反转义为 \)即可阅读。添加 --branch live 可读取已部署的 canister,而非草稿版本(live 仅支持 query)。如果字符串值包含单引号,请使用 '\'' 为其进行 shell 转义。
操作步骤
- 获取一次 schema。
icp canister call backend schema '()' --query— 在本次会话中缓存;它只会在部署之间发生变化(§1)。 - 将请求映射到实体。 选择包含所需答案的实体。根据每个字段的
typeName和values选择字面量类型,并通过role: {"edge": ...}查看实体之间的连接方式。 - 转换为一个或多个查询。 从包含所需行的实体开始(§2)。添加
where(§2.1)、orderBy/limit/offset、select和aggregate/groupBy(§2.2)。在单个查询中使用点分路径穿过正向边(§4.1);反向一对多关系需要先获取父键,然后使用in(§4.2)。 - 运行并读取结果。
icp canister call backend execute '("<json>")' --query— 根据单元格name解析 Candid 行(§3);如果有hasMore,则使用offset翻页(§5)。 - 遇到 trap 时重试。 没有错误封装 — 重新读取 schema,修正查询,然后重新运行(§6)。
1. 发现 — schema
获取一次并在本次会话中缓存 — 它只会在 canister 部署之间发生变化。
icp canister call backend schema '()' --query
阅读方式如下:
name→ 实体名称;在查询中将其用作start。primaryKey→ 用于标识一行的字段。{"to": "<entity>"}边的值是目标实体中的主键值。fields→ 每个字段的name、标量typeName和role:"payload"(普通字段)或{"edge": {"to": "<entity>"}}(外键 — 用于遍历图)。如果两个列会有相同名称,名称可能带有__1、__2、… 后缀 — 请使用schema()报告的准确名称。values(可选)→ 字段可取的确切字面量(通常是 variant 的各个分支)。请使用这些字面量进行筛选,不要猜测:["free","pro","enterprise"]表示查询"enterprise",而不是"Enterprise"。缺失 ⇒ 无界 — 如果需要候选值,请通过查询采样。typeName→value的 JSON 字面量类型:"Nat"→ 无符号整数(0、1、…)"Int"→ 有符号整数(-1、0、1、…)"Float"→ 带小数点的 JSON 数值(0.5、-3.14、1.0e2)。也接受不带小数点的整数(10)——数值 variant 可互通,因此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 | [] | 按这些字段对行分组;每种不同组合输出一行(§2.2)。 |
aggregate | [] | 计算每个分组的聚合值;当 groupBy 为空时,则对所有行进行聚合(§2.2)。 |
orderBy | [](由 canister 定义顺序,通常为插入顺序) | 多键排序,第一个子句为主排序键。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 是一个 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 的客户”只需一个查询,无需将行扫描并载入上下文:
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
在 canister 上计算,而不是获取每一行后在客户端统计。fn 可以是 count/sum/avg/min/max;除 count 外,每个 fn 都需要 field;min/max 也适用于文本。as 用于重命名输出列(默认值为 count、sum_<field> 等),且不得包含 .(点号是边遍历分隔符——会导致解析错误)。对于带点号的 field,默认名称会用 _ 连接各段(对 dept.budget 求 sum → sum_dept_budget)。不带 groupBy 的 aggregate → 对整个筛选结果集输出一行(空匹配集的 count 为 0)。不带 aggregate 的 groupBy → 服务端执行 DISTINCT。输出行仅包含分组键 + 聚合列。
“有多少企业客户?”——对筛选结果集执行 count,输出一行:
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 { ... }
是一行。每个 record { value = variant { "<tag>" = <payload> }; name = "<field>" }
是一个单元格:name 表示字段,<tag> 表示标量类型,<payload> 是值。35_000 : nat 中的下划线是数字分隔符,解析时可以去掉。hasMore = false ⇒ 已获取所有匹配项;hasMore = true ⇒ 结果被截断,请获取下一页。按 name 查找单元格,而不是按位置查找——select 变化时,顺序也会变化。
4. 遍历边(联接)
单值关系(向前)只需一次查询:带点号的路径会穿过已声明的边,且可用于任意字段位置。一对多关系(反向)仍需两次查询,使用 in 模式(§4.2)。
4.1 向前(子项 → 父项):带点号的路径
"<edgeField>.<targetField>" 会在服务端通过边读取字段,可用于
where、groupBy、orderBy、aggregate.field 和 select。在一次查询中通过边投影字段:
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 跳),也可与聚合组合——“按客户经理的办公室统计平均收入”只需一次调用:
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": ... }}的字段。对非边字段使用带点号的路径会触发陷阱,即使其值看起来像外键也一样 (遍历由模式驱动,不会根据名称猜测)。如果作者没有声明该边,请改用下面的两次查询模式。 - 空值或悬空外键会使整条带点号的路径解析为
null(左联接):该行不匹配除ne以外的任何关系运算符,投影出的单元格也为null。 - **从多的一侧进行聚合。**跨实体聚合在起始实体的行上执行:从
employee读取"department.budget"并计算avg时,结果按员工加权。要获取每个部门的数值,请从department开始,或按带点号的路径分组,再聚合起始实体的字段。 - 选择裸边字段(
"accountManager")仍会返回外键标量;不支持.*,请逐一列出所需的目标字段。
4.2 反向(一对多)
对于一个父项的主键,使用 eq;对于一批父项的主键,使用 in。这两种方式都用于边字段,匹配目标实体的主键值。
icp canister call backend execute '("{\"start\":\"customer\",\"where\":{\"eq\":{\"field\":\"accountManager\",\"value\":\"[email protected]\"}},\"select\":[\"companyName\",\"monthlyRevenueUsd\"]}")' --query
当父项条件是普通谓词时,无需先批量查询——通过边向前筛选即可(§4.1)。“查找由柏林办公室中任意员工管理的所有客户”只需一次查询:
icp canister call backend execute '("{\"start\":\"customer\",\"where\":{\"eq\":{\"field\":\"accountManager.office\",\"value\":\"Berlin\"}},\"select\":[\"companyName\",\"monthlyRevenueUsd\"]}")' --query
如果父项集合需要单独的查询形态(前 N 项、排序、分页),则必须使用批量 in 模式:先收集键,再对边字段使用 in。“查找由资历最深的 3 名员工管理的客户”需要两次查询:
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
始终使用 in 批量查询,不要分别运行 N 次 eq 查询。
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 两跳/自引用边联接
如果未提供父项的键,而必须先查找该键——例如“谁向项目 forge20 的负责人汇报?”——请运行两次查询。第二次查询使用第一次查询返回的键,通过自引用边(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_,或者字面量类型正确但值不正确(大小写、单位——时间戳的单位是 epoch 纳秒)。 |
contains 未匹配到可见的行 | contains / startsWith / endsWith 区分大小写。对于用户输入的搜索词,请使用 icontains。 |
点号路径触发 'x' is not an edge of 'y' | 路径首段不是已声明的边——即使值看起来像外键,遍历仍由架构决定。请改用两次查询的 in 模式。 |
| 跨实体平均值看起来不正确 | 聚合会针对 start 实体的行执行。从要计算平均值的实体开始,或者按点号路径分组并聚合 start 实体的字段。 |
execute 返回行,但缺少字段 | 你通过 select 选择的字段不属于该实体(拼写错误或被作者隐藏)。将其从 select 中移除,或者移除 select 以使用默认投影。 |
没有结构化的错误封装。任何失败都会触发错误——修正查询后重试。