使容器数据可被 Caffeine Data Intelligence 智能体查询。当应用存储结构化数据(Map/List/记录数组)且应能通过自然语言回答(如“顶级客户”、“按区域统计收入”、“活跃项目”)时使用。通过 `caffeineai-oql` mops 包的 `Expose` 混入,添加可发现的 `schema()` 和 JSON `execute()` 查询端点。
OQL — 对象查询层
遍历 actor 的字段(非临时字段),对于每个值得查询的集合,考虑其数据如何映射到数据库中的表(一个实体)。每个表只声明一个实体——Expose 混入使其可查询。
后端
每个实体携带一个授权级别;默认的 .controllerOnly() 是安全的(对用户私有,但仍可被 Data Intelligence 智能体读取)。先建模实体,然后为每个实体选择一个级别——参见 ## Auth。
设置
在第一次 mo:caffeineai-oql/... 导入的同一写入批次中运行 mops add caffeineai-oql@0.4.0。自动推导需要 moc >= 1.11(生成的 app 模板已满足此要求)。
声明实体并安装
.toEntity(name, typeName, primaryKey) 将记录集合转换为可查询的实体;编译器自动推导字段。每个实体设置自己的授权级别(参见 ## Auth);下面的示例每个级别展示一个表。Expose 只添加 OQL 查询方法(schema / execute)——你现有的状态、类型和 shared 方法保持不变。
import Map "mo:core/Map";
import Nat "mo:core/Nat";
import Principal "mo:core/Principal";
import OQL "mo:caffeineai-oql";
import Expose "mo:caffeineai-oql/Expose";
actor {
type Product = { id : Nat; name : Text; priceUsd : Nat };
type Vendor = { id : Nat; name : Text };
type AuditLog = { id : Nat; action : Text; atNs : Nat };
type Note = { id : Nat; user : Principal; body : Text };
type Document = { id : Nat; owner : Principal; title : Text };
type User = { id : Principal; isAdmin : Bool };
let products = Map.empty<Nat, Product>();
let vendors = Map.empty<Nat, Vendor>();
let supplies = Map.empty<Product, Vendor>();
let auditLogs = Map.empty<Nat, AuditLog>();
let notes = Map.empty<Nat, Note>();
let documents = Map.empty<Nat, Document>();
// 并非所有集合都需要暴露,如果没有必要——`users` 仅用于认证,因此下面有意不将其转换为实体
let users = Map.empty<Principal, User>();
let anyP = Principal.fromText("aaaaa-aa"); // 示例所有者;该值被忽略
// 检查调用者是否为管理员。
func isAdmin(p : Principal) : Bool =
switch (users.get(p)) { case (?u) u.isAdmin; case null false };
// 自定义 .ownedByWith 规则:管理员可以看到所有文档,其他人只能看到自己的。
// `owner` 是字段的 Value——Principal 列以 #text 形式到达。
func canSeeDocument(caller : Principal, owner : OQL.Value) : Bool =
isAdmin(caller) or owner == #text(caller.toText());
include Expose({
entities = [
// #public_ — 任何人(包括匿名用户)读取整个目录
products.toEntity("product", "Product", "id")
.sample({ id = 0; name = ""; priceUsd = 0 })
.public_()
.build(),
vendors.toEntity("vendor", "Vendor", "id")
.sample({ id = 0; name = "" })
.public_()
.build(),
// `supplies : Map<Product, Vendor>` — 两个非基本类型之间的映射。
// 标识存在于键/值记录中,而不是字段中,因此以手动模式遍历 .entries(),
// 提升每一侧的 id,并对两者使用 .edge——查询随后可以遍历 "product.name" 和 "vendor.name"。
OQL.Entity.manual<(Product, Vendor)>("supply", func () = supplies.entries(), "Supply", "key")
.payload("key", func ((p, v)) = p.id.toText() # ":" # v.id.toText())
.payload("product", func ((p, _)) = p.id) .edge("product", "product")
.payload("vendor", func ((_, v)) = v.id) .edge("vendor", "vendor")
.controllerOnly()
.build(),
// #controllerOnly(默认,此处显式展示)——仅平台读取
auditLogs.toEntity("auditLog", "AuditLog", "id")
.sample({ id = 0; action = ""; atNs = 0 })
.controllerOnly()
.build(),
// #scopedPerUser — 每个已登录用户只读取自己的行
notes.toEntity("note", "Note", "id")
.sample({ id = 0; user = anyP; body = "" })
.ownedBy("user")
.scopedPerUser()
.build(),
// #controllerOrScoped — controller 读取所有;scoped 读取使用 canSeeDocument。
documents.toEntity("document", "Document", "id")
.sample({ id = 0; owner = anyP; title = "" })
.ownedByWith("owner", canSeeDocument)
.controllerOrScoped()
.build(),
];
});
}
Auth
授权是每个实体的——每个构建器声明一个级别,schema() 和 execute() 都针对实时的 caller 进行检查。没有应用级配置,没有令牌。未设置时的默认值是 #controllerOnly。
| 构建器调用 | 谁读取 | 返回的行 |
|---|---|---|
.public_() |
任何人(包括匿名用户) | 全部 |
.controllerOnly() (默认) |
仅控制器 | 全部 |
.scopedPerUser() |
任何已登录调用者 | 仅调用者自己的 |
.controllerOrScoped() |
控制器 + 已登录调用者 | 控制器:全部;用户:自己的 |
选择级别
根据谁应该读取其行为每个实体选择——如有疑问,保持默认。
.controllerOnly()(默认) — 智能体应回答的私有应用数据,但最终用户不直接读取(订单、指标、审计日志、配置)。智能体作为控制器调用,因此它读取所有内容,而数据对用户保持私有。.public_()— 世界可读数据,包括未登录访客(公共目录、已发布内容、排行榜)。.controllerOrScoped()— 每个用户只读取自己的行,但智能体仍必须回答聚合问题的每用户数据(个人资料、用户的订单)。需要所有者列。.scopedPerUser()— 严格私有的每用户数据:每个用户只读取自己的,智能体也被限定范围,因此它不能回答此表上的问题(私信、私人日志)。需要所有者列——除非智能体必须对此不可见,否则首选.controllerOrScoped()。
用户可以覆盖每个实体;如果请求暗示每用户数据但模棱两可,请询问。
每用户(行级)范围
限定范围的级别(.scopedPerUser()、.controllerOrScoped())需要一种方式知道哪些行属于调用者——一个所有者列或一个尊重主体的源。如果限定范围的实体没有这些,.build() 会报错;如果 .public_() 实体声明了所有者(检查永远不会运行),也会报错。这是防止常见数据泄露陷阱的护栏。
何时标记: Principal 字段是信号。
.ownedBy(field)— 字段就是所有者;可见性基于身份相等。.ownedByWith(field, canSee)— 自定义可见性(团队、管理员、共享)。
canSee : (caller : Principal, owner : Value) -> Bool决定每行;field不必是Principal,闭包可以读取 actor 状态。
限定范围的调用者只能看到其拥有的行——无论是作为查询目标还是通过连接——因此遍历永远不会泄露其他所有者的行。
// 每用户笔记:每个已登录用户只读取自己的行。
notes.toEntity("note", "Note", "id")
.sample({ id = 0; owner = Principal.fromText("aaaaa-aa") /* 任意 principal */; body = "" })
.ownedBy("owner")
.scopedPerUser()
.build()
// .ownedByWith 自定义规则:所有者看到自己的文档,列出的管理员看到所有人的,
// 平台控制器看到所有(#controllerOrScoped)。
// `owner` 是字段的 Value——Principal 列以 #text(principal) 形式到达。
docs.toEntity("doc", "Doc", "id")
.ownedByWith("owner", func (caller, owner) =
admins.get(caller) != null or owner == #text(caller.toText()))
.controllerOrScoped()
.build()
.ownedBy(f) 完全等同于 .ownedByWith(f, OQL.Entity.ownerIsCaller)。最多一个所有者列;它必须是真实字段,不能同时是 .edge / .hidden。对于所有者键控存储(Map<Principal, List<T>>),使用 OQL.Entity.newScoped(name, scopedIter, typeName, primaryKey),以便扫描是 O(用户行):scopedIter(?p) 只返回 p 的行,scopedIter(null) 返回所有(模式播种)。
实体构建器
两种模式,由行类型 T 选择。
自动推导 — .toEntity
对于字段都是基本类型且具有内置 _toRow(Nat、Int、Float、Text、Bool、有符号 Nat/Int 宽度、Principal)的记录:
customers.toEntity(name, typeName, primaryKey)
.sample(template) // 如果集合在构建时可能为空,则必需
.edge(field, targetEntity) // 将现有字段标记为外键
.ownedBy(field) // (或 .ownedByWith(field, canSee))每用户范围
.scopedPerUser() // 授权级别:.public_ / .controllerOnly(默认)/ .scopedPerUser / .controllerOrScoped
.hidden(field) // 从模式和默认投影中删除字段
.build()
.toEntity是OQL.Entity.new<T>(name, func () = coll.values(), …)的语法糖;它存在于Map、Set、List、[T]和[var T]上。它只迭代值——如果行的标识(PK 或所有者)存在于 Map 键中,则它不是字段:通过手动模式在.entries()上提升它,或者当它是所有者时使用OQL.Entity.newScoped。primaryKey以及任何.edge/.ownedBy字段必须命名行中一个真实的、非.hidden的列。.edge(name, target)将一个现有字段(它不添加字段)标记为 FK,启用查询中的点路径遍历"name.targetField"。FK/PK 类型必须是Text、Nat/Int或Bool(拒绝Float键),并且目标的主键不能是.hidden。.sample(template)播种模式发现;没有它,空集合会产生空模式。只有形状重要,值不重要。
模式字段按字典顺序列出(__record 组合器的规范形式);如果显示顺序重要,则在客户端排序。
手动模式 — .toEntityManual / OQL.Entity.manual
用于非记录 T、计算字段或具有嵌套/变体/选项/集合字段的记录:
authors.toEntityManual<Author>("author", "Author", "id")
.payload("name", func a = a.name) // 一个字段;extract 返回一个 _toRow 值
.flatten(func a = a.address) // 将嵌套记录的字段拼接为列
.payload("tagCount", func a = a.tags.size())
// .edge / .hidden 与自动模式相同,按字段名
.build()
.payload(name, extract)—name不能包含.。对于选项/变体,返回带有哨兵的Text/Nat(见下文)。.flatten(extract : T -> S)—S必须是扁平的;它的每个字段成为顶级列。用.hidden删除不需要的。名称冲突会得到__1、__2后缀(不会删除任何内容)。OQL.Entity.manual<T>(name, iter, typeName, primaryKey)用于任意行源(自定义展平器、过滤后的迭代器)。
OQL.Value 是 { #null_; #bool; #nat; #int; #float; #text }。数值变体可以相互比较,因此 JSON 整数阈值可以匹配 Float 值。
行类型 T |
模式 |
|---|---|
| 全基本类型记录 | .toEntity |
包含 ? / 变体 / 嵌套字段的记录 |
一旦你提供 <Type>Value.mo(见下文),则使用 .toEntity;否则手动 |
| 包含集合字段的记录 | 手动 — 使用 .size() 或 Text.join 作为 payload |
| 元组 / 基本类型 / 计算值 | 手动 |
转换非基本类型字段
为了在自动推导路径上保留记录,为每个非基本字段类型提供一个 _toRow : T -> OQL.Value:每个类型一个文件,命名为 <TypeName>Value.mo,一个单一的 public func _toRow,在声明实体的文件中顶级导入(解析器不会遍历子模块)。父记录随后使用 .toEntity(...),无需每个字段的 .payload。
// OptTextValue.mo — 选项 → 哨兵
module { public func _toRow(self : ?Text) : OQL.Value =
switch self { case null { #text("") }; case (?t) { #text(t) } }; };
// StatusValue.mo — 变体 → 标签文本
module { public func _toRow(self : Status) : OQL.Value =
#text(switch self { case (#draft) "draft"; case (#published) "published" }); };
// DepartmentValue.mo — 嵌套记录 → 子 PK(然后对字段使用 .edge)
module { public func _toRow(self : Department) : OQL.Value = #text(self.name); };
始终返回一个 Value 变体,即使对于 null(哨兵 "" / 0 / false)——有时返回 #null_ 的 _toRow 会使报告的模式类型按行顺序翻转。哨兵保持字段可查询(eq value "" 匹配 null)。对于一次性字段,在 .payload 中内联相同的转换,而不是使用模块;仅当 2 个以上实体需要时才提升为模块。既用作实体又用作嵌套字段的记录只需提供其 <Type>Value.mo——结构化的 Row 推导和你的 Value 折叠是不同的类型,可以共存。
超越一行一记录的实体模式
同一存储可以支持多个实体——选择客户端应该看到的内容:
- 重塑 — 将
Map<K1, Map<K2, V>>展平为行;让展平器发出一个扁平的记录(不是元组),以便它仍然自动推导,然后对提升的键使用.edge。 - 枚举 — 通过
OQL.Entity.manual从索引键(Map<Author, …>.keys())推导实体;没有行的条目不会出现。 - 合成 — 从数组字段投影一个连接,使多对多关系从两侧都可查询:
OQL.Entity.manual<(Article, Text)>("articleTag", func () = flattenTags(articles), "Pair", "pair")
.payload("article", func ((a, _)) = a.id) .edge("article", "article")
.payload("tag", func ((_, t)) = t) .edge("tag", "tag")
.build()
检查清单
- [ ]
mops add caffeineai-oql@0.4.0在第一次导入的同一批次中 - [ ] 每个实体:行迭代器存在;使用
.toEntity(全基本类型)或.toEntityManual/OQL.Entity.manual - [ ] 为每个跨实体重用的非基本类型字段提供
<Type>Value.mo,并在顶级导入 - [ ] 当集合在构建时可能为空时,使用
.sample(template) - [ ] FK 字段使用
.edge(name, target);仅过滤字段使用.hidden(name) - [ ] 每个哨兵转换返回一个
Value变体 - [ ] 每用户实体使用
.ownedBy/.ownedByWith并且一个限定范围级别(.scopedPerUser()/.controllerOrScoped())——永远不要单独使用.controllerOnly()






