extension-oql
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseOQL — Object Query Layer
OQL — 对象查询层
Author-side recipe for exposing a canister's data to OQL queries. The
consumer side (forming and running queries) is handled by the Data
Intelligence agent — your job is to declare entities over the data the app
already keeps and install the mixin.
include Expose({ ... })shared query- — a JSON catalogue of entities, their fields, primary keys, and edges. The discovery endpoint.
schema(token : ?Text) : Text - — runs a JSON query (filter / order / paginate / project, aggregation, and dotted-path edge traversal) and returns typed Candid rows. The query endpoint.
execute(qJson : Text, token : ?Text) : Result
Plus two controller-only update calls, /
(bearer credentials, see Auth). Together these replace the
"one per question" pattern (,
, …) with a single generic surface the client
drives dynamically.
oqlMintTokenoqlRevokeTokenshared querygetCustomersInGermany()getOrdersForCustomer(id)You declare entities — query views over collections you already store. No
storage restructuring, no per-question getters.
面向开发者的OQL查询数据暴露指南。查询的构建与执行由Data Intelligence agent负责——您只需基于应用已存储的数据声明实体并安装mixin即可。
include Expose({ ... })shared query- — 实体、字段、主键和关联关系的JSON目录,用于服务发现。
schema(token : ?Text) : Text - — 执行JSON格式的查询(过滤/排序/分页/投影、聚合以及关联关系的点路径遍历),并返回类型化的Candid行数据,即查询端点。
execute(qJson : Text, token : ?Text) : Result
此外还包含两个仅控制器可调用的更新方法/(Bearer凭证,详见认证部分)。这些方法共同替代了“每个查询对应一个方法”的模式(如、等),转而提供一个由客户端动态驱动的通用接口。
oqlMintTokenoqlRevokeTokenshared querygetCustomersInGermany()getOrdersForCustomer(id)您只需声明实体——即基于已有存储集合的查询视图。无需重构存储结构,也无需为每个查询单独编写getter方法。
Backend
后端实现
Setup
环境搭建
Run in the same write batch as the first
import. The generated-app template already pins
and sets the required flags, so you do not configure the toolchain.
( is required: auto-derivation uses the structural
combiner that landed there.)
mops add caffeineai-oql@0.1.0mo:caffeineai-oql/...moc 1.9.0--default-persistent-actors --implicit-package=coremoc 1.9.0__record在首次导入的同一写入批次中,运行。生成的应用模板已固定并设置了所需的标志,因此您无需配置工具链。(必须使用:自动推导功能依赖于该版本引入的结构合并器。)
mo:caffeineai-oql/...mops add caffeineai-oql@0.1.0moc 1.9.0--default-persistent-actors --implicit-package=coremoc 1.9.0__recordMixin install
Mixin安装
Each turns a collection of records
into a queryable entity; the compiler auto-derives the fields. adds
exactly the four methods above — existing methods, state, and types
are untouched.
.toEntity(name, typeName, primaryKey)Exposesharedmotoko
import Map "mo:core/Map";
import Nat "mo:core/Nat";
import OQL "mo:caffeineai-oql";
import Expose "mo:caffeineai-oql/Expose";
actor {
type Customer = { id : Nat; name : Text; country : Text; monthlyRevenueUsd : Nat };
type Order = { id : Nat; customerId : Nat; amountUsd : Nat; paid : Bool };
let customers = Map.empty<Nat, Customer>();
let orders = Map.empty<Nat, Order>();
include Expose({
entities = [
customers.toEntity("customer", "Customer", "id")
.sample({ id = 0; name = ""; country = ""; monthlyRevenueUsd = 0 })
.build(),
orders.toEntity("order", "Order", "id")
.sample({ id = 0; customerId = 0; amountUsd = 0; paid = false })
// `customerId` points at the customer entity's primary key:
.edge("customerId", "customer")
.build(),
];
// Access is decided by three functions. controllerOnly lets the platform
// (a controller) read every row; everyone else is denied. isPublic stays
// false so the data is not world-readable, and there is no token scheme.
isPublic = func () : Bool = false;
authorizeUser = OQL.Auth.controllerOnly;
authorizeToken = OQL.Auth.noExternalTokens;
});
public shared func addCustomer(c : Customer) : async () { customers.add(c.id, c) };
public shared func addOrder(o : Order) : async () { orders.add(o.id, o) };
};每个会将记录集合转换为可查询实体;编译器会自动推导字段信息。仅添加上述四个方法——现有方法、状态和类型均不受影响。
.toEntity(name, typeName, primaryKey)Exposesharedmotoko
import Map "mo:core/Map";
import Nat "mo:core/Nat";
import OQL "mo:caffeineai-oql";
import Expose "mo:caffeineai-oql/Expose";
actor {
type Customer = { id : Nat; name : Text; country : Text; monthlyRevenueUsd : Nat };
type Order = { id : Nat; customerId : Nat; amountUsd : Nat; paid : Bool };
let customers = Map.empty<Nat, Customer>();
let orders = Map.empty<Nat, Order>();
include Expose({
entities = [
customers.toEntity("customer", "Customer", "id")
.sample({ id = 0; name = ""; country = ""; monthlyRevenueUsd = 0 })
.build(),
orders.toEntity("order", "Order", "id")
.sample({ id = 0; customerId = 0; amountUsd = 0; paid = false })
// `customerId`指向customer实体的主键:
.edge("customerId", "customer")
.build(),
];
// 访问权限由三个函数决定。controllerOnly允许平台(控制器)读取所有行;其他用户均被拒绝。isPublic保持为false,因此数据不会对所有人开放,也不启用令牌机制。
isPublic = func () : Bool = false;
authorizeUser = OQL.Auth.controllerOnly;
authorizeToken = OQL.Auth.noExternalTokens;
});
public shared func addCustomer(c : Customer) : async () { customers.add(c.id, c) };
public shared func addOrder(o : Order) : async () { orders.add(o.id, o) };
};Auth
认证(Auth)
Three mandatory config functions. Each resolves an decision rather
than a bare bool:
Accessmo
public type Access = { #deny; #unrestricted; #scoped : Principal };#deny#unrestricted#scoped p#ownerp#denytext
isPublic() -- the whole surface is open (#unrestricted)
authorizeUser(caller) -- principal-based policy
<minted token> -- bearer token from oqlMintToken; its owner is the scope
authorizeToken(token) -- your own token scheme| Config field | Shape | Presets in |
|---|---|---|
| | write the literal ( |
| | |
| | |
The check runs at the top of both and . A denied
caller traps.
schema()execute()Default to controller-only. Set ,
, . The Data Intelligence agent reads as a platform
controller, so it can answer questions while the data stays private to
non-controllers. Set only for
intentionally world-readable data.
isPublic = func () : Bool = falseauthorizeUser = OQL.Auth.controllerOnlyauthorizeToken = OQL.Auth.noExternalTokensisPublic = func () : Bool = trueCustom policies are just plain functions returning :
Accessmo
let admins : Set.Set<Principal> = Set.empty();
authorizeUser = func (p : Principal) : OQL.Auth.Access =
if (p.isController() or admins.contains(p)) #unrestricted
else if (p.isAnonymous()) #deny
else #scoped p; // every other signed-in user sees only their own rowsBearer tokens. — a
controller-only update call — returns a 64-hex token, valid until its TTL
lapses or removes it. The minted-token store is
(the mixin keeps no stable field, so it stays
enhanced-migration compatible), so tokens reset on every canister upgrade —
re-mint afterwards. Pass to mint a token that reads only 's
rows; mints an unrestricted token. Readers pass it as the
trailing argument of / — no controllerhood, no
principal exchange. Tokens travel in query arguments (visible to the node
operator, replayable until revoked or expired), so mint with a TTL when
sharing externally. runs in addition to the minted-token
store, for authors operating their own token scheme.
oqlMintToken(owner : ?Principal, ttlSeconds : ?Nat)oqlRevokeToken(token)transientowner = ?ppowner = nullopt textschemaexecuteauthorizeToken三个必填配置函数。每个函数返回一个决策,而非简单的布尔值:
Accessmo
public type Access = { #deny; #unrestricted; #scoped : Principal };#deny#unrestricted#scoped p#ownerp#denytext
isPublic() -- 整个接口开放(#unrestricted)
authorizeUser(caller) -- 基于主体的策略
<minted token> -- 来自oqlMintToken的Bearer令牌;其所有者为范围
authorizeToken(token) -- 自定义令牌方案| 配置字段 | 格式 | |
|---|---|---|
| | 直接写入字面量( |
| | |
| | |
检查会在**和**的顶部执行。被拒绝的调用者会触发陷阱。
schema()execute()默认仅允许控制器访问。设置、、。Data Intelligence agent以平台控制器身份读取数据,因此它可以回答查询,同时数据对非控制器保持私有。仅当数据需要对所有人开放时,才设置。
isPublic = func () : Bool = falseauthorizeUser = OQL.Auth.controllerOnlyauthorizeToken = OQL.Auth.noExternalTokensisPublic = func () : Bool = true自定义策略只需返回的普通函数即可:
Accessmo
let admins : Set.Set<Principal> = Set.empty();
authorizeUser = func (p : Principal) : OQL.Auth.Access =
if (p.isController() or admins.contains(p)) #unrestricted
else if (p.isAnonymous()) #deny
else #scoped p; // 其他已登录用户仅能查看自己的行Bearer令牌。——仅控制器可调用的更新方法——返回一个64位十六进制令牌,有效期至TTL过期或将其移除。令牌存储是的(mixin不保留稳定字段,因此兼容增强迁移),因此每次canister升级后令牌会重置——需重新生成。传入生成仅能读取行的令牌;生成无限制令牌。读取者将其作为/的末尾可选文本参数传入——无需控制器权限,也无需主体交换。令牌随查询参数传输(对节点运营商可见,在撤销或过期前可重放),因此对外共享时需设置TTL。会在令牌存储检查之外运行,适用于自定义令牌方案的开发者。
oqlMintToken(owner : ?Principal, ttlSeconds : ?Nat)oqlRevokeToken(token)transientowner = ?ppowner = nullschemaexecuteauthorizeTokenPer-user (row-level) scoping
按用户(行级)范围划分
Mark any entity per-user with one builder line — — naming
the column that holds the row's owner principal. When the caller resolves to
, that entity yields only rows whose owner equals , both as a
query's and as a join target (so dotted-path traversal can't leak
another owner's rows; dangling-under-scope FKs left-join to ).
callers see everything. Entities you do not mark stay
global (visible to any authorized caller).
.ownedBy(field)#scoped ppstartnull#unrestrictedThe owner subject is either the direct caller ( returning
, e.g. , for apps where each end user calls with
its own principal) or the owner bound to a token (,
for a backend minting one token per user). Both collapse to the same subject.
authorizeUser#scoped callerselfScopedoqlMintToken(?p, ...)When rows are stored keyed by owner (e.g. ), use
so the scan is
O(user rows) instead of a full-table filter; receives the
subject ( = unrestricted, used for schema seeding — pair with
).
Map<Principal, List<T>>OQL.Entity.newScoped(name, scopedIter, typeName, primaryKey)scopedIternull.sampleOverride the scheme with your own predicate. uses
principal equality. For app-specific authorization use where receives the resolved
caller subject and the row's owner-cell value and returns true to expose the
row. Capture actor state in the closure; the owner column need not be a
principal.
.ownedBy(field).ownedByWith(field, canSee)canSee : (Principal, OQL.Value) -> Boolmo
// `allowedUsers : Map<Principal, ()>` is your own actor state.
docs.toEntity("doc", "Doc", "id")
.ownedByWith("owner", func (caller, owner) =
allowedUsers.get(caller) != null // listed → see everything
or owner == #text(caller.toText())) // otherwise → only your own
.build()The column is still tagged role in , join-target scoping
still applies, and callers still bypass the check.
( is exactly .)
"owner"schema()#unrestricted.ownedBy(field).ownedByWith(field, OQL.Entity.ownerIsCaller)只需一行构建器代码即可将任意实体标记为按用户划分————指定存储行所有者主体的列。当调用者被解析为时,该实体仅返回所有者等于的行,无论是作为查询的还是关联目标(因此点路径遍历不会泄露其他所有者的行;超出范围的外键会左连接为)。调用者可查看所有数据。未标记的实体保持全局可见(任何授权调用者均可查看)。
.ownedBy(field)#scoped ppstartnull#unrestricted所有者主体要么是直接调用者(返回,例如,适用于每个终端用户使用自身主体调用的应用)要么是令牌绑定的所有者(,适用于后端为每个用户生成一个令牌的场景)。两种情况最终都会归为同一主体。
authorizeUser#scoped callerselfScopedoqlMintToken(?p, ...)当行按所有者存储时(例如),使用,这样扫描的复杂度为O(用户行数),而非全表过滤;接收主体(表示无限制,用于模式种子生成——需配合)。
Map<Principal, List<T>>OQL.Entity.newScoped(name, scopedIter, typeName, primaryKey)scopedIternull.sample使用自定义谓词覆盖方案。使用主体相等性检查。对于应用特定的授权逻辑,使用,其中接收解析后的调用者主体和行的所有者单元格值,返回true则暴露该行。在闭包中捕获actor状态;所有者列无需是主体类型。
.ownedBy(field).ownedByWith(field, canSee)canSee : (Principal, OQL.Value) -> Boolmo
// `allowedUsers : Map<Principal, ()>`是您自己的actor状态。
docs.toEntity("doc", "Doc", "id")
.ownedByWith("owner", func (caller, owner) =
allowedUsers.get(caller) != null // 白名单用户→查看所有内容
or owner == #text(caller.toText())) // 其他用户→仅查看自己的内容
.build()该列仍会在中标记为角色,关联目标范围划分仍然生效,调用者仍会绕过检查。(等价于。)
schema()"owner"#unrestricted.ownedBy(field).ownedByWith(field, OQL.Entity.ownerIsCaller)Entity builder
实体构建器
OQL ships two builder modes, picked by what the row type looks like.
TOQL提供两种构建模式,根据行类型的特性选择。
TAuto-derivation (.toEntity
)
.toEntity自动推导(.toEntity
)
.toEntityFor plain records whose fields are all primitives with a instance
(, , , , , the sized widths and
, and ), the compiler walks the record type and
synthesises the payload schema.
_toRowNatIntFloatTextBoolNat8/16/32/64Int8/16/32/64Principalmo
customers.toEntity(name, typeName, primaryKey)
.sample(template) // optional, see below
.edge(fieldName, targetEntity) // re-tag an auto-derived field as FK
.ownedBy(fieldName) // owner column → per-user scoping
.ownedByWith(fieldName, canSee) // ...with an app-defined predicate
.domain(fieldName, [values]) // declare a field's allowed values
.hidden(fieldName) // drop a field from the schema
.build()- is contextual-dot sugar for
customers.toEntity(...). The sameOQL.Entity.new<Customer>(name, func () = customers.values(), typeName, primaryKey)exists on.toEntity,Map,Set,List, and[T].[var T] - does NOT add a field — it tags an already-derived field as a foreign key into
.edge(name, target). The field must exist on the record. The tag is what enables server-side traversal: queries can then filter/group/sort/project ontargetdotted paths. An undeclared FK stays a plain scalar, and dotted paths into it trap. Joinability: the target's primary key must not be"<name>.<targetField>", and FK/PK types must be.hidden,Text/Nat(bridged), orInt—Boolkeys are rejected at query time.Float - declares the distinct values a field can hold (e.g. the text arms of a variant). They surface in
.domain(name, values)as the field'sschema()array so clients filter with exact literals.valuesis avalues, e.g.[OQL.Value].[#text("draft"), #text("published")] - /
.ownedBy(name)— owner column → per-user scoping (see Auth). At most one owner column; it must be a real declared/derived field and may not also be.ownedByWith(name, canSee)or.edge..hidden - drops a derived field from
.hidden(name)and the default projection.schema()cannot bring it back.select - is the schema-discovery seed. Required when the underlying collection may be empty at
.sample(template)time — otherwise the schema materialises asbuild()and stays empty until the first row arrives. Pass any well-typed instance of[]; only the shape matters.T
The auto-derived schema lists fields in lexicographic order — the
canonical form the combiner produces. If display order matters,
sort client-side or pass an explicit .
__recordselect对于所有字段均为带有实例的基本类型(、、、、、定宽类型和以及)的普通记录,编译器会遍历记录类型并自动生成负载模式。
_toRowNatIntFloatTextBoolNat8/16/32/64Int8/16/32/64Principalmo
customers.toEntity(name, typeName, primaryKey)
.sample(template) // 可选,见下文
.edge(fieldName, targetEntity) // 将自动推导的字段标记为外键
.ownedBy(fieldName) // 所有者列→按用户范围划分
.ownedByWith(fieldName, canSee) // ...使用应用自定义谓词
.domain(fieldName, [values]) // 声明字段的允许值
.hidden(fieldName) // 从模式中移除字段
.build()- 是
customers.toEntity(...)的上下文语法糖。OQL.Entity.new<Customer>(name, func () = customers.values(), typeName, primaryKey)同样适用于.toEntity、Map、Set、List和[T]。[var T] - 不会添加新字段——它将已推导的字段标记为指向
.edge(name, target)的外键。该字段必须存在于记录中。此标记是启用服务端遍历的关键:查询随后可以使用target点路径进行过滤/分组/排序/投影。未声明的外键仍为普通标量,点路径访问会触发陷阱。关联兼容性:目标的主键不能被"<name>.<targetField>",且外键/主键类型必须为.hidden、Text/Nat(可桥接)或Int——Bool类型的主键在查询时会被拒绝。Float - 声明字段的允许值(例如变体的文本分支)。这些值会在
.domain(name, values)中显示为字段的schema()数组,以便客户端使用精确字面量进行过滤。values是values类型,例如[OQL.Value]。[#text("draft"), #text("published")] - /
.ownedBy(name)——所有者列→按用户范围划分(详见认证部分)。最多只能有一个所有者列;它必须是真实的已声明/推导字段,且不能同时被标记为.ownedByWith(name, canSee)或.edge。.hidden - 从
.hidden(name)和默认投影中移除推导字段。schema()无法恢复该字段。select - 是模式发现的种子。当底层集合在
.sample(template)时可能为空时必须声明——否则模式会生成为build(),并保持为空直到第一行数据到来。传入任何类型正确的[]实例即可;仅结构会被使用。T
自动推导的模式按字典序列出字段——这是合并器生成的标准形式。如果显示顺序重要,可在客户端排序或传入显式的。
__recordselectManual mode (.toEntityManual
/ OQL.Entity.manual
)
.toEntityManualOQL.Entity.manual手动模式(.toEntityManual
/ OQL.Entity.manual
)
.toEntityManualOQL.Entity.manualFor non-record , records with nested fields / variants / options /
collections, or any computed field, use the manual escape hatch.
Tmo
articles.toEntityManual<Article>(name, typeName, primaryKey)
.payload(fieldName, extract : T -> V) // V picks a _toRow instance
.flatten(extract : T -> S) // splice a nested record's fields in
.domain (fieldName, [values]) // declare a field's allowed values
.edge (fieldName, targetEntity) // tags a declared payload as FK
.hidden (fieldName) // drops a declared payload
.build()- adds one field.
.payload(name, extract)returns anyextractwhoseVinstance is in scope. For options and variants, write a tiny local helper returning_toRoworTextwith a sentinel for absence. The name must not containNat— dots are the edge-traversal separator.. - splices a nested record in as flat, top-level columns — one line instead of one
.flatten(extract)per inner field..payloadreturns the sub-record; the combiner walksextract : T -> Sand every field becomes its own column under its (unprefixed) name.Smust be flat. Drop unwanted fields withSafterwards..hidden(name) - /
.edgetag and drop already-declared fields by name; the name must match a.hidden(or a field a.payloadproduced)..flatten - For row sources that aren't a container shortcut (a custom flattener, a
filtered iterator), call directly.
OQL.Entity.manual<T>(name, iter, typeName, primaryKey)
mo
// Author = { id : Nat; name : Text; address : Address; tags : [Text] }
// Address = { city : Text; country : Text; postalCode : Text }
authors.toEntityManual<Author>("author", "Author", "id")
.payload("id", func a = a.id)
.payload("name", func a = a.name)
.flatten(func a = a.address) // → city, country, postalCode columns
.payload("tagCount", func a = a.tags.size())
.build()Colliding column names. If a flattened field clashes with another column,
the first occurrence keeps the bare name and each later one gets a ,
, … suffix. Both columns survive and stay queryable — nothing is
silently dropped.
__1__2OQL.Value{ #null_; #bool : Bool; #nat : Nat; #int : Int; #float : Float; #text : Text }_toRowPrincipal#text#nat#int#floatFloat对于非记录类型、包含嵌套字段/变体/可选类型/集合的记录,或任何计算字段,使用手动模式。
Tmo
articles.toEntityManual<Article>(name, typeName, primaryKey)
.payload(fieldName, extract : T -> V) // V需有可用的_toRow实例
.flatten(extract : T -> S) // 将嵌套记录的字段拼接为顶级字段
.domain (fieldName, [values]) // 声明字段的允许值
.edge (fieldName, targetEntity) // 将已声明的负载标记为外键
.hidden (fieldName) // 移除已声明的负载
.build()- 添加一个字段。
.payload(name, extract)返回任何具有可用extract实例的_toRow类型。对于可选类型和变体,编写一个小型本地辅助函数返回V或Text,并为缺失值设置哨兵值。名称不能包含Nat——点是关联遍历的分隔符。. - 将嵌套记录拼接为扁平的顶级列——一行代码替代为每个内部字段编写一个
.flatten(extract)。.payload返回子记录;合并器会遍历extract : T -> S,每个字段会成为独立的顶级列(无前缀)。S必须是扁平结构。之后可使用S移除不需要的字段。.hidden(name) - /
.edge按名称标记和移除已声明的字段;名称必须匹配.hidden(或.payload生成的字段)。.flatten - 对于非容器快捷方式的行源(自定义扁平化器、过滤迭代器),直接调用。
OQL.Entity.manual<T>(name, iter, typeName, primaryKey)
mo
// Author = { id : Nat; name : Text; address : Address; tags : [Text] }
// Address = { city : Text; country : Text; postalCode : Text }
authors.toEntityManual<Author>("author", "Author", "id")
.payload("id", func a = a.id)
.payload("name", func a = a.name)
.flatten(func a = a.address) // → 生成city、country、postalCode列
.payload("tagCount", func a = a.tags.size())
.build()列名冲突。如果扁平化字段与其他列冲突,第一个出现的字段保留原名,后续字段会添加、等后缀。所有列都会保留并可查询——不会被静默丢弃。
__1__2OQL.Value{ #null_; #bool : Bool; #nat : Nat; #int : Int; #float : Float; #text : Text }_toRowPrincipal#text#nat#int#floatFloatWhich mode?
如何选择模式?
Row type | Mode |
|---|---|
| Record of primitives only | auto-derive ( |
Record with | auto-derive once you ship |
Record with nested record ( | auto once you ship |
| Record with a variant field | auto once you ship |
Record with a collection field ( | manual — |
| Tuple, primitive, or anything else | manual — |
行类型 | 模式 |
|---|---|
| 仅包含基本类型的记录 | 自动推导( |
包含 | 若已引入 |
包含嵌套记录( | 若已引入 |
| 包含变体字段的记录 | 若已引入 |
包含集合字段( | 手动模式——使用 |
| 元组、基本类型或其他类型 | 手动模式—— |
Custom _toRow
instances (extending auto-derivation)
_toRow自定义_toRow
实例(扩展自动推导)
_toRowOQL's implicit resolver looks at modules imported in the actor's composition
root, not just the library's built-ins. Any type for which you write becomes auto-derivable as a field of another record:
_toRow : T -> OQL.Value- One file per type, named .
<TypeName>Value.mo - A single .
public func _toRow(self : T) : OQL.Value - Import the module at the top level of the file that declares your entities (not nested in a submodule — the resolver does not walk submodules).
Once in scope, parent records carrying those fields ride
with no manual per field.
.toEntity(...).payloadmo
// DepartmentValue.mo — nested record → child's primary key
import OQL "mo:caffeineai-oql";
import Types "Types";
module {
public func _toRow(self : Types.Department) : OQL.Value = #text(self.name);
};Every parent carrying now auto-derives a
cell holding the FK; re-tag it with .
department : Departmentdepartment : Text.edge("department", "department")mo
// OptTextValue.mo — optional field → pick a sentinel
import OQL "mo:caffeineai-oql";
module {
public func _toRow(self : ?Text) : OQL.Value = switch self {
case null { #text("") };
case (?t) { #text(t) };
};
};Always commit to one variant, even for the null case — a
that returns for some rows and for others makes the schema's
reported type flip-flop based on row order. Sentinels keep the schema stable
AND keep the field queryable (
matches the null rows). Same shape for (sentinel 0), (false).
Value_toRow#null_#text{ "eq": { "field": "x", "value": "" } }?Nat?Boolmo
// StatusValue.mo — variant → per-tag text
module {
public func _toRow(self : Status) : OQL.Value = #text(switch self {
case (#draft) { "draft" };
case (#published) { "published" };
case (#archived) { "archived" };
});
};Two instances for the same record coexist. A record used both as a
top-level entity and as a nested field needs two shapes: the
structural the combiner synthesises (you don't write it)
and your collapse. and are distinct types,
so the resolver picks the right one in each context. Ship one
and both paths work.
T_toRow_toRow : T -> Row_toRow : T -> ValueRowValueTypeValue.moWhen it's worth it: if two or more entities embed the same nested record
type, write the instance — it pays for itself the second time. For one-off
embeds, an inline extract is fine.
.payloadOQL的隐式解析器会查看actor组合根中导入的模块,而非仅库的内置模块。任何您编写了的类型,都可以作为其他记录的字段被自动推导:
_toRow : T -> OQL.Value- 每个类型对应一个文件,命名为。
<TypeName>Value.mo - 包含一个函数。
public func _toRow(self : T) : OQL.Value - 在声明实体的文件顶层导入该模块(不能嵌套在子模块中——解析器不会遍历子模块)。
一旦导入,包含这些字段的父记录即可通过自动推导,无需为每个字段手动编写。
.toEntity(...).payloadmo
// DepartmentValue.mo — 嵌套记录→子记录的主键
import OQL "mo:caffeineai-oql";
import Types "Types";
module {
public func _toRow(self : Types.Department) : OQL.Value = #text(self.name);
};现在所有包含的父记录都会自动推导生成字段,存储外键;只需使用标记即可。
department : Departmentdepartment : Text.edge("department", "department")mo
// OptTextValue.mo — 可选字段→设置哨兵值
import OQL "mo:caffeineai-oql";
module {
public func _toRow(self : ?Text) : OQL.Value = switch self {
case null { #text("") };
case (?t) { #text(t) };
};
};始终使用单一变体,即使是空值情况——如果对某些行返回,对其他行返回,会导致模式报告的类型根据行顺序发生变化。哨兵值既能保持模式稳定,又能让字段可查询(会匹配空值行)。(哨兵值0)、(false)同理。
Value_toRow#null_#text{ "eq": { "field": "x", "value": "" } }?Nat?Boolmo
// StatusValue.mo — 变体→按分支转换为文本
module {
public func _toRow(self : Status) : OQL.Value = #text(switch self {
case (#draft) { "draft" };
case (#published) { "published" };
case (#archived) { "archived" };
});
};同一记录的两个实例可以共存。同时作为顶级实体和嵌套字段的记录需要两个结构:合并器自动生成的结构化(无需手动编写)和您编写的折叠版本。和是不同的类型,因此解析器会在不同场景中选择正确的实例。只需提供一个,两种场景均可正常工作。
T_toRow_toRow : T -> Row_toRow : T -> ValueRowValueTypeValue.mo适用场景:如果两个或更多实体嵌入相同的嵌套记录类型,编写实例是值得的——第二次使用即可回本。对于一次性嵌入,内联提取器更简洁。
.payloadThe four entity patterns
四种实体模式
The same storage can back several patterns at once. Pick whichever matches
what the client should see.
concrete — one row per element of an existing collection. The common case
(the entity above).
customerreshaped — flatten nested storage, promote inner keys to columns. Have the
flattener emit a flat record (not a tuple) so the custom row source rides
auto-derivation with no per-field :
.payloadmo
// metrics : Map<Int (bucket), Map<Nat (sensor), Measurement>>
type MeasurementRow = { bucket : Int; sensor : Nat; metric : Text; value : Nat; okay : Bool };
OQL.Entity.new<MeasurementRow>(
"measurement", func () = flattenMetrics(metrics), "MeasurementRow", "sensor",
)
.edge("bucket", "bucket")
.edge("sensor", "sensor")
.build()A tuple has no field names, so it can't
auto-derive — emit a flat record instead.
(Int, Nat, Measurement)enumerated — derive an entity from an existing index, no dedicated
storage. Use when entities live inside other records and you have a
keyed by them:
Map<Entity, _>mo
// articlesByAuthor : Map<Author, List<Article>> already exists
OQL.Entity.manual<Author>("author", func () = articlesByAuthor.keys(), "Author", "id")
.payload("id", func a = a.id)
.payload("name", func a = a.name)
.flatten(func a = a.address)
.build()Trade-off: authors with no articles never appear, by construction.
synthetic — project rows from an array-typed field, no junction table on
disk. Makes many-to-many relationships queryable from both sides:
mo
// Article = { id : Nat; tags : [Text]; ... }
OQL.Entity.manual<(Article, Text)>(
"articleTag", func () = flattenArticleTags(articles), "Pair", "pair",
)
.payload("article", func ((a, _)) = a.id)
.edge ("article", "article")
.payload("tag", func ((_, t)) = t)
.edge ("tag", "tag")
.build()In manual mode tags a payload field as a FK — the value
still comes from the matching , so you write both lines.
.edge(name, target).payload同一存储可以同时支持多种模式。选择与客户端需求匹配的模式即可。
具体实体 — 现有集合的每个元素对应一行。常见场景(如上述的实体)。
customer重构实体 — 扁平化嵌套存储,将内部键提升为列。让扁平化器输出扁平的记录(而非元组),这样自定义行源即可通过自动推导处理,无需为每个字段编写:
.payloadmo
// metrics : Map<Int (bucket), Map<Nat (sensor), Measurement>>
type MeasurementRow = { bucket : Int; sensor : Nat; metric : Text; value : Nat; okay : Bool };
OQL.Entity.new<MeasurementRow>(
"measurement", func () = flattenMetrics(metrics), "MeasurementRow", "sensor",
)
.edge("bucket", "bucket")
.edge("sensor", "sensor")
.build()元组没有字段名,无法自动推导——因此需输出扁平记录。
(Int, Nat, Measurement)枚举实体 — 从现有索引推导实体,无需专用存储。适用于实体存在于其他记录中且您有一个以实体为键的的场景:
Map<Entity, _>mo
// articlesByAuthor : Map<Author, List<Article>>已存在
OQL.Entity.manual<Author>("author", func () = articlesByAuthor.keys(), "Author", "id")
.payload("id", func a = a.id)
.payload("name", func a = a.name)
.flatten(func a = a.address)
.build()权衡:没有文章的作者不会出现在实体中,这是由构造方式决定的。
合成实体 — 从数组类型字段投影行,无需磁盘上的关联表。让多对多关系可从双方查询:
mo
// Article = { id : Nat; tags : [Text]; ... }
OQL.Entity.manual<(Article, Text)>(
"articleTag", func () = flattenArticleTags(articles), "Pair", "pair",
)
.payload("article", func ((a, _)) = a.id)
.edge ("article", "article")
.payload("tag", func ((_, t)) = t)
.edge ("tag", "tag")
.build()在手动模式下,将负载字段标记为外键——值仍来自对应的,因此需要同时编写两行代码。
.edge(name, target).payloadManual-mode helpers for options, variants, collections
可选类型、变体、集合的手动模式辅助函数
When a non-primitive field is too local to be worth a per-type module (used in
exactly one entity, or a one-off computed shape), inline the conversion in the
builder. Same sentinel discipline — pick one variant and stay there.
Valuemo
func optText(o : ?Text) : Text = switch o { case null ""; case (?t) t };
func optNat(o : ?Nat) : Nat = switch o { case null 0; case (?n) n };
func statusText(s : Status) : Text = switch s {
case (#draft) "draft"; case (#published) "published"; case (#archived) "archived";
};
func tagSummary(tags : [Text]) : Text = Text.join(tags.values(), ",");…then in the builder:
mo
.payload("terminationDate", func e = optText(e.terminationDate))
.payload("status", func a = statusText(a.status))
.payload("tagCount", func a = a.tags.size())
.payload("tagSummary", func a = tagSummary(a.tags))Rule of thumb. If the same conversion would appear in two or more
entities, lift it to a module and let auto-derive wire
it. If it's strictly local, the inline helper is shorter.
<TypeName>Value.mo当非基本类型字段仅在单个实体中使用,或仅用于一次性计算结构时,无需为其编写单独的类型模块,可在构建器中内联转换逻辑。同样遵循哨兵值原则——选择单一变体并保持一致。
Valuemo
func optText(o : ?Text) : Text = switch o { case null ""; case (?t) t };
func optNat(o : ?Nat) : Nat = switch o { case null 0; case (?n) n };
func statusText(s : Status) : Text = switch s {
case (#draft) "draft"; case (#published) "published"; case (#archived) "archived";
};
func tagSummary(tags : [Text]) : Text = Text.join(tags.values(), ",");…然后在构建器中使用:
mo
.payload("terminationDate", func e = optText(e.terminationDate))
.payload("status", func a = statusText(a.status))
.payload("tagCount", func a = a.tags.size())
.payload("tagSummary", func a = tagSummary(a.tags))经验法则。如果相同的转换逻辑会在两个或更多实体中出现,将其提取到模块,让自动推导处理。如果仅在本地使用,内联辅助函数更简洁。
<TypeName>Value.moWhat OQL does NOT do
OQL不支持的功能
- No reverse joins (fan-out). Forward, single-valued edges ARE traversable
server-side with dotted paths (); one-to-many needs a second
"customerId.country"withexecute().in - No writes. is
execute. Mutations stay in your ownshared querymethods.shared - No query planner. Each is a linear scan over the row source. For fast point lookups, build a
executeand have the row source walk it.Map - No nested-record paths. Dotted paths cross edges (max 4 hops); nested
records flatten to top-level columns at declaration time ().
.flatten - No built-in for Option, Variant, nested records, collections. Ship a
_toRowfor the first three to stay on auto-derive, or drop to manual mode.<TypeName>Value.mo - Per-user scoping is opt-in per entity. Unmarked entities are visible to any authorized caller.
- 不支持反向关联(扇出)。单向单值关联可通过服务端点路径遍历();一对多关系需要第二个
"customerId.country"调用并使用execute()。in - 不支持写入操作。是
execute。修改操作仍需使用您自己的shared query方法。shared - 无查询规划器。每个都是对行源的线性扫描。如需快速点查询,构建
execute并让行源遍历该Map。Map - 不支持嵌套记录路径。点路径仅能跨越关联关系(最多4跳);嵌套记录在声明时需通过转换为顶级列。
.flatten - 没有针对可选类型、变体、嵌套记录、集合的内置。前三者可通过编写
_toRow模块继续使用自动推导,否则需切换到手动模式。<TypeName>Value.mo - 按用户范围划分需按实体手动启用。未标记的实体对任何授权调用者可见。
Cost model
成本模型
Every is one call: the row source iterates once, each
row materialises into extracted cells, the predicate runs per row, survivors
are collected (and sorted if is set), then +
slice and the projection materialises. No caching, no planner — work
is proportional to the row source's length. If a query must be bounded, make
the row source bounded (walk an index, not a full table).
executeshared queryO(n log n)orderByoffsetlimit每个都是一个调用:行源迭代一次,每行转换为提取的单元格,谓词逐行执行,收集符合条件的行(如果设置了则进行O(n log n)排序),然后通过+切片并生成投影结果。无缓存,无规划器——工作量与行源长度成正比。如果查询需要限制范围,需让行源本身是有界的(遍历索引而非全表)。
executeshared queryorderByoffsetlimitChecklist: adding an entity
添加实体检查清单
- Storage iterator exists () or flattener written
func () = collection.values() - Mode chosen: all-primitive fields → ; custom
.toEntityinstances in scope → still auto; tuples/collections/computed →_toRow/.toEntityManualOQL.Entity.manual - For every non-primitive field type used by 2+ entities, a exists and is imported at the top of the composition root
<TypeName>Value.mo - Auto mode: declared (required if the collection may be empty at build time)
.sample(template) - Manual mode: every exposed field has a , or a nested record is spliced with
.payload.flatten(func x = x.sub) - FK fields tagged with
.edge(name, targetEntity) - Filter-only fields use
.hidden(name) - All sentinel conversions return ONE variant (never
Valuemixed with#null_)#text - Per-user entities marked with /
.ownedBy(field).ownedByWith(field, canSee) - at the end, entity added to the
.build()arrayentities = [...] - Don't hand-write /
schema— the mixin provides themexecute - Compiles (/ deploy)
mops build
- 存储迭代器已存在()或已编写扁平化器
func () = collection.values() - 已选择模式:全基本类型字段→;自定义
.toEntity实例已导入→仍使用自动推导;元组/集合/计算字段→_toRow/.toEntityManualOQL.Entity.manual - 对于被2个及以上实体使用的非基本字段类型,已创建模块并在组合根顶层导入
<TypeName>Value.mo - 自动模式:已声明(当集合在构建时可能为空时必填)
.sample(template) - 手动模式:每个暴露的字段都有,或已通过
.payload拼接嵌套记录.flatten(func x = x.sub) - 外键字段已通过标记
.edge(name, targetEntity) - 仅用于过滤的字段已使用
.hidden(name) - 所有哨兵值转换仅返回单一变体(绝不能混合
Value和#null_)#text - 按用户划分的实体已通过/
.ownedBy(field)标记.ownedByWith(field, canSee) - 已添加,实体已加入
.build()数组entities = [...] - 不要手动编写/
schema——mixin会提供这些方法execute - 已编译通过(/ 部署)
mops build