extension-oql

extension-oql

讓容器的資料可被 Caffeine Data Intelligence 代理查詢。當應用程式儲存結構化資料(Map/List/記錄陣列)且應能以自然語言回答時使用,例如「頂級客戶」、「各區域營收」、「進行中的專案」。透過 `caffeineai-oql` mops 套件的 `Expose` mixin 新增可發現的 `schema()` 和 JSON `execute()` 查詢端點。

0星標
0分支
更新於 2026/7/13
SKILL.md
readonlyread-only
name
extension-oql
description

讓容器的資料可被 Caffeine Data Intelligence 代理查詢。當應用程式儲存結構化資料(Map/List/記錄陣列)且應能以自然語言回答時使用,例如「頂級客戶」、「各區域營收」、「進行中的專案」。透過 `caffeineai-oql` mops 套件的 `Expose` mixin 新增可發現的 `schema()` 和 JSON `execute()` 查詢端點。

version
0.4.0

OQL — 物件查詢層

檢視 actor 的欄位(非暫存),針對每個值得查詢的集合,思考其資料如何對應到資料庫中的表格(一個實體)。每個表格只宣告一個實體 — Expose mixin 讓它們可被查詢。

後端

每個實體都帶有授權層級;預設的 .controllerOnly() 是安全的(對使用者私有,但仍可被 Data Intelligence 代理讀取)。先建立你的實體模型,再為每個實體選擇層級 — 請參閱 ## Auth

設定

同一個寫入批次中執行 mops add caffeineai-oql@0.4.0,並同時進行你的第一個 mo:caffeineai-oql/... 匯入。自動推導需要 moc >= 1.11(產生的應用程式範本已滿足此條件)。

宣告實體並安裝

.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 — 控制器可讀取所有;範圍使用者使用 canSeeDocument。
      documents.toEntity("document", "Document", "id")
        .sample({ id = 0; owner = anyP; title = "" })
        .ownedByWith("owner", canSeeDocument)
        .controllerOrScoped()
        .build(),
    ];
  });
}

Auth

授權是每個實體獨立的 — 每個建構器宣告一個層級,schema()execute() 都會針對當前的 caller 進行檢查。沒有應用程式層級的設定,沒有 token。未設定時的預設值為 #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) 回傳全部(schema 種子)。

實體建構器

兩種模式,由資料列型別 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)                 // 從 schema 和預設投影中移除欄位
  .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/IntBoolFloat 鍵會被拒絕),且目標的主鍵不能是 .hidden
  • .sample(template) 為 schema 發現提供種子;沒有它,空集合會產生空 schema。只有形狀重要,值不重要。

Schema 欄位按字典序列出(__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)— 如果 _toRow 有時回傳 #null_,會導致報告的 schema 型別因資料列順序而搖擺不定。哨兵讓欄位保持可查詢(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()