extension-oql

extension-oql

使容器数据可被 Caffeine Data Intelligence 智能体查询。当应用存储结构化数据(Map/List/记录数组)且应能通过自然语言回答(如“顶级客户”、“按区域统计收入”、“活跃项目”)时使用。通过 `caffeineai-oql` mops 包的 `Expose` 混入,添加可发现的 `schema()` 和 JSON `execute()` 查询端点。

0Star
0Fork
更新于 2026/7/13
SKILL.md
readonly只读
name
extension-oql
description

使容器数据可被 Caffeine Data Intelligence 智能体查询。当应用存储结构化数据(Map/List/记录数组)且应能通过自然语言回答(如“顶级客户”、“按区域统计收入”、“活跃项目”)时使用。通过 `caffeineai-oql` mops 包的 `Expose` 混入,添加可发现的 `schema()` 和 JSON `execute()` 查询端点。

version
0.4.0

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

对于字段都是基本类型且具有内置 _toRowNatIntFloatTextBool、有符号 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()
  • .toEntityOQL.Entity.new<T>(name, func () = coll.values(), …) 的语法糖;它存在于 MapSetList[T][var T] 上。它只迭代——如果行的标识(PK 或所有者)存在于 Map 键中,则它不是字段:通过手动模式在 .entries() 上提升它,或者当它是所有者时使用 OQL.Entity.newScoped
  • primaryKey 以及任何 .edge / .ownedBy 字段必须命名行中一个真实的、非 .hidden 的列。
  • .edge(name, target) 将一个现有字段(它不添加字段)标记为 FK,启用查询中的点路径遍历 "name.targetField"。FK/PK 类型必须是 TextNat/IntBool(拒绝 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()