mcpField
E
extension-querying-oql

extension-querying-oql

github.com/caffeinelabs数据与数据库225.3k 次安装6 天前更新
代码仓库

关于 extension-querying-oql

通过 `icp` CLI 查询 OQL canister 的参考技能,涵盖架构发现、JSON 查询、筛选、聚合和关系遍历。

主要功能

  1. 01用 `schema()` 查看实体与字段
  2. 02用 `execute` 构造 JSON 查询
  3. 03筛选、排序和投影行数据
  4. 04分组并聚合查询结果
  5. 05遍历已声明的实体关系

适用场景

  1. 01检查 canister 的数据架构
  2. 02搜索并分页读取匹配记录
  3. 03按分组汇总记录

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/caffeinelabs/skills --skill extension-querying-oql

安装后在 Claude Code 中输入/extension-querying-oql确认已加载。

详细介绍

查询 OQL — 快速参考

OQL canister 提供两个只读方法:

方法返回值用途
schema()一个 JSON Textcanister 实体目录:每个实体的主键、字段和边。
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 转义。


操作步骤

  1. 获取一次 schema。 icp canister call backend schema '()' --query — 在本次会话中缓存;它只会在部署之间发生变化(§1)。
  2. 将请求映射到实体。 选择包含所需答案的实体。根据每个字段的 typeName 和 values 选择字面量类型,并通过 role: {"edge": ...} 查看实体之间的连接方式。
  3. 转换为一个或多个查询。 从包含所需行的实体开始(§2)。添加 where(§2.1)、orderBy / limit / offset、select 和 aggregate / groupBy(§2.2)。在单个查询中使用点分路径穿过正向边(§4.1);反向一对多关系需要先获取父键,然后使用 in(§4.2)。
  4. 运行并读取结果。 icp canister call backend execute '("<json>")' --query — 根据单元格 name 解析 Candid 行(§3);如果有 hasMore,则使用 offset 翻页(§5)。
  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"。
offset0丢弃前 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 为以下值的字段
nullnull_任意字段——eq null 查找可选字段为空的行,ne null 查找字段已设置的行
true / falsebool"Bool"
0、1、42nat"Nat"(也可通过数值桥接匹配 "Float")
-1、-42int"Int"(也可通过数值桥接匹配 "Float")
0.5、-3.14、1.0e2float"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 Textvalue 字面量的 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 以使用默认投影。

没有结构化的错误封装。任何失败都会触发错误——修正查询后重试。

评论

支持 Markdown