讓容器的資料可被 Caffeine Data Intelligence 代理查詢。當應用程式儲存結構化資料(Map/List/記錄陣列)且應能以自然語言回答時使用,例如「頂級客戶」、「各區域營收」、「進行中的專案」。透過 `caffeineai-oql` mops 套件的 `Expose` mixin 新增可發現的 `schema()` 和 JSON `execute()` 查詢端點。
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
適用於所有欄位都是基本型別且具有內建 _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) // 從 schema 和預設投影中移除欄位
.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)為 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()






