extension-oql

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

OQL — 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({ ... })
adds two read-only
shared query
methods to the actor:
  • schema(token : ?Text) : Text
    — a JSON catalogue of entities, their fields, primary keys, and edges. The discovery endpoint.
  • execute(qJson : Text, token : ?Text) : Result
    — runs a JSON query (filter / order / paginate / project, aggregation, and dotted-path edge traversal) and returns typed Candid rows. The query endpoint.
Plus two controller-only update calls,
oqlMintToken
/
oqlRevokeToken
(bearer credentials, see Auth). Together these replace the "one
shared query
per question" pattern (
getCustomersInGermany()
,
getOrdersForCustomer(id)
, …) with a single generic surface the client drives dynamically.
You declare entities — query views over collections you already store. No storage restructuring, no per-question getters.
面向开发者的OQL查询数据暴露指南。查询的构建与执行由Data Intelligence agent负责——您只需基于应用已存储的数据声明实体并安装mixin即可。
include Expose({ ... })
会为actor添加两个只读的
shared query
方法:
  • schema(token : ?Text) : Text
    — 实体、字段、主键和关联关系的JSON目录,用于服务发现。
  • execute(qJson : Text, token : ?Text) : Result
    — 执行JSON格式的查询(过滤/排序/分页/投影、聚合以及关联关系的点路径遍历),并返回类型化的Candid行数据,即查询端点。
此外还包含两个仅控制器可调用的更新方法
oqlMintToken
/
oqlRevokeToken
(Bearer凭证,详见认证部分)。这些方法共同替代了“每个查询对应一个
shared query
方法”的模式(如
getCustomersInGermany()
getOrdersForCustomer(id)
等),转而提供一个由客户端动态驱动的通用接口。
您只需声明实体——即基于已有存储集合的查询视图。无需重构存储结构,也无需为每个查询单独编写getter方法。

Backend

后端实现

Setup

环境搭建

Run
mops add caffeineai-oql@0.1.0
in the same write batch as the first
mo:caffeineai-oql/...
import. The generated-app template already pins
moc 1.9.0
and sets the required
--default-persistent-actors --implicit-package=core
flags, so you do not configure the toolchain. (
moc 1.9.0
is required: auto-derivation uses the
__record
structural combiner that landed there.)
在首次导入
mo:caffeineai-oql/...
的同一写入批次中,运行
mops add caffeineai-oql@0.1.0
。生成的应用模板已固定
moc 1.9.0
并设置了所需的
--default-persistent-actors --implicit-package=core
标志,因此您无需配置工具链。(必须使用
moc 1.9.0
:自动推导功能依赖于该版本引入的
__record
结构合并器。)

Mixin install

Mixin安装

Each
.toEntity(name, typeName, primaryKey)
turns a collection of records into a queryable entity; the compiler auto-derives the fields.
Expose
adds exactly the four methods above — existing
shared
methods, state, and types are untouched.
motoko
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)
会将记录集合转换为可查询实体;编译器会自动推导字段信息。
Expose
仅添加上述四个方法——现有
shared
方法、状态和类型均不受影响。
motoko
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
Access
decision rather than a bare bool:
mo
public type Access = { #deny; #unrestricted; #scoped : Principal };
#deny
does not authorize;
#unrestricted
reads every row;
#scoped p
reads, but
#owner
-tagged entities yield only rows owned by
p
(see Per-user scoping below). A read is allowed when any check returns a non-
#deny
Access; the first non-deny in this order wins and its scope threads into the executor:
text
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 fieldShapePresets in
OQL.Auth
isPublic
() -> Bool
write the literal (
func () : Bool = false
)
authorizeUser
Principal -> Access
controllerOnly
(→
#unrestricted
),
selfScoped
(→
#scoped caller
),
noUsers
authorizeToken
Text -> Access
noExternalTokens
The check runs at the top of both
schema()
and
execute()
. A denied caller traps.
Default to controller-only. Set
isPublic = func () : Bool = false
,
authorizeUser = OQL.Auth.controllerOnly
,
authorizeToken = OQL.Auth.noExternalTokens
. The Data Intelligence agent reads as a platform controller, so it can answer questions while the data stays private to non-controllers. Set
isPublic = func () : Bool = true
only for intentionally world-readable data.
Custom policies are just plain functions returning
Access
:
mo
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 rows
Bearer tokens.
oqlMintToken(owner : ?Principal, ttlSeconds : ?Nat)
— a controller-only update call — returns a 64-hex token, valid until its TTL lapses or
oqlRevokeToken(token)
removes it. The minted-token store is
transient
(the mixin keeps no stable field, so it stays enhanced-migration compatible), so tokens reset on every canister upgrade — re-mint afterwards. Pass
owner = ?p
to mint a token that reads only
p
's rows;
owner = null
mints an unrestricted token. Readers pass it as the trailing
opt text
argument of
schema
/
execute
— 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.
authorizeToken
runs in addition to the minted-token store, for authors operating their own token scheme.
三个必填配置函数。每个函数返回一个
Access
决策,而非简单的布尔值:
mo
public type Access = { #deny; #unrestricted; #scoped : Principal };
#deny
表示拒绝授权;
#unrestricted
表示可读取所有行;
#scoped p
表示可读取,但标记为
#owner
的实体仅返回属于
p
的行(详见下文按用户范围划分)。当任意检查返回非
#deny
的Access时,读取操作被允许;按以下顺序第一个非拒绝的结果生效,其范围会传入执行器:
text
isPublic()                 -- 整个接口开放(#unrestricted)
authorizeUser(caller)      -- 基于主体的策略
<minted token>             -- 来自oqlMintToken的Bearer令牌;其所有者为范围
authorizeToken(token)      -- 自定义令牌方案
配置字段格式
OQL.Auth
中的预设值
isPublic
() -> Bool
直接写入字面量(
func () : Bool = false
authorizeUser
Principal -> Access
controllerOnly
(返回
#unrestricted
)、
selfScoped
(返回
#scoped caller
)、
noUsers
authorizeToken
Text -> Access
noExternalTokens
检查会在**
schema()
execute()
**的顶部执行。被拒绝的调用者会触发陷阱。
默认仅允许控制器访问。设置
isPublic = func () : Bool = false
authorizeUser = OQL.Auth.controllerOnly
authorizeToken = OQL.Auth.noExternalTokens
。Data Intelligence agent以平台控制器身份读取数据,因此它可以回答查询,同时数据对非控制器保持私有。仅当数据需要对所有人开放时,才设置
isPublic = func () : Bool = true
自定义策略只需返回
Access
的普通函数即可:
mo
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令牌
oqlMintToken(owner : ?Principal, ttlSeconds : ?Nat)
——仅控制器可调用的更新方法——返回一个64位十六进制令牌,有效期至TTL过期或
oqlRevokeToken(token)
将其移除。令牌存储是
transient
的(mixin不保留稳定字段,因此兼容增强迁移),因此每次canister升级后令牌会重置——需重新生成。传入
owner = ?p
生成仅能读取
p
行的令牌;
owner = null
生成无限制令牌。读取者将其作为
schema
/
execute
的末尾可选文本参数传入——无需控制器权限,也无需主体交换。令牌随查询参数传输(对节点运营商可见,在撤销或过期前可重放),因此对外共享时需设置TTL。
authorizeToken
会在令牌存储检查之外运行,适用于自定义令牌方案的开发者。

Per-user (row-level) scoping

按用户(行级)范围划分

Mark any entity per-user with one builder line —
.ownedBy(field)
— naming the column that holds the row's owner principal. When the caller resolves to
#scoped p
, that entity yields only rows whose owner equals
p
, both as a query's
start
and as a join target (so dotted-path traversal can't leak another owner's rows; dangling-under-scope FKs left-join to
null
).
#unrestricted
callers see everything. Entities you do not mark stay global (visible to any authorized caller).
The owner subject is either the direct caller (
authorizeUser
returning
#scoped caller
, e.g.
selfScoped
, for apps where each end user calls with its own principal) or the owner bound to a token (
oqlMintToken(?p, ...)
, for a backend minting one token per user). Both collapse to the same subject.
When rows are stored keyed by owner (e.g.
Map<Principal, List<T>>
), use
OQL.Entity.newScoped(name, scopedIter, typeName, primaryKey)
so the scan is O(user rows) instead of a full-table filter;
scopedIter
receives the subject (
null
= unrestricted, used for schema seeding — pair with
.sample
).
Override the scheme with your own predicate.
.ownedBy(field)
uses principal equality. For app-specific authorization use
.ownedByWith(field, canSee)
where
canSee : (Principal, OQL.Value) -> Bool
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.
mo
// `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
"owner"
in
schema()
, join-target scoping still applies, and
#unrestricted
callers still bypass the check. (
.ownedBy(field)
is exactly
.ownedByWith(field, OQL.Entity.ownerIsCaller)
.)
只需一行构建器代码即可将任意实体标记为按用户划分——
.ownedBy(field)
——指定存储行所有者主体的列。当调用者被解析为
#scoped p
时,该实体仅返回所有者等于
p
的行,无论是作为查询的
start
还是关联目标(因此点路径遍历不会泄露其他所有者的行;超出范围的外键会左连接为
null
)。
#unrestricted
调用者可查看所有数据。未标记的实体保持全局可见(任何授权调用者均可查看)。
所有者主体要么是直接调用者
authorizeUser
返回
#scoped caller
,例如
selfScoped
,适用于每个终端用户使用自身主体调用的应用)要么是令牌绑定的所有者
oqlMintToken(?p, ...)
,适用于后端为每个用户生成一个令牌的场景)。两种情况最终都会归为同一主体。
当行按所有者存储时(例如
Map<Principal, List<T>>
),使用
OQL.Entity.newScoped(name, scopedIter, typeName, primaryKey)
,这样扫描的复杂度为O(用户行数),而非全表过滤;
scopedIter
接收主体(
null
表示无限制,用于模式种子生成——需配合
.sample
)。
使用自定义谓词覆盖方案
.ownedBy(field)
使用主体相等性检查。对于应用特定的授权逻辑,使用
.ownedByWith(field, canSee)
,其中
canSee : (Principal, OQL.Value) -> Bool
接收解析后的调用者主体和行的所有者单元格值,返回true则暴露该行。在闭包中捕获actor状态;所有者列无需是主体类型。
mo
// `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
T
looks like.
OQL提供两种构建模式,根据行类型
T
的特性选择。

Auto-derivation (
.toEntity
)

自动推导(
.toEntity

For plain records whose fields are all primitives with a
_toRow
instance (
Nat
,
Int
,
Float
,
Text
,
Bool
, the sized widths
Nat8/16/32/64
and
Int8/16/32/64
, and
Principal
), the compiler walks the record type and synthesises the payload schema.
mo
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()
  • customers.toEntity(...)
    is contextual-dot sugar for
    OQL.Entity.new<Customer>(name, func () = customers.values(), typeName, primaryKey)
    . The same
    .toEntity
    exists on
    Map
    ,
    Set
    ,
    List
    ,
    [T]
    , and
    [var T]
    .
  • .edge(name, target)
    does NOT add a field — it tags an already-derived field as a foreign key into
    target
    . The field must exist on the record. The tag is what enables server-side traversal: queries can then filter/group/sort/project on
    "<name>.<targetField>"
    dotted paths. An undeclared FK stays a plain scalar, and dotted paths into it trap. Joinability: the target's primary key must not be
    .hidden
    , and FK/PK types must be
    Text
    ,
    Nat
    /
    Int
    (bridged), or
    Bool
    Float
    keys are rejected at query time.
  • .domain(name, values)
    declares the distinct values a field can hold (e.g. the text arms of a variant). They surface in
    schema()
    as the field's
    values
    array so clients filter with exact literals.
    values
    is a
    [OQL.Value]
    , e.g.
    [#text("draft"), #text("published")]
    .
  • .ownedBy(name)
    /
    .ownedByWith(name, canSee)
    — 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
    .edge
    or
    .hidden
    .
  • .hidden(name)
    drops a derived field from
    schema()
    and the default projection.
    select
    cannot bring it back.
  • .sample(template)
    is the schema-discovery seed. Required when the underlying collection may be empty at
    build()
    time
    — otherwise the schema materialises as
    []
    and stays empty until the first row arrives. Pass any well-typed instance of
    T
    ; only the shape matters.
The auto-derived schema lists fields in lexicographic order — the canonical form the
__record
combiner produces. If display order matters, sort client-side or pass an explicit
select
.
对于所有字段均为带有
_toRow
实例的基本类型(
Nat
Int
Float
Text
Bool
、定宽类型
Nat8/16/32/64
Int8/16/32/64
以及
Principal
)的普通记录,编译器会遍历记录类型并自动生成负载模式。
mo
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
    实例即可;仅结构会被使用。
自动推导的模式按字典序列出字段——这是
__record
合并器生成的标准形式。如果显示顺序重要,可在客户端排序或传入显式的
select

Manual mode (
.toEntityManual
/
OQL.Entity.manual
)

手动模式(
.toEntityManual
/
OQL.Entity.manual

For non-record
T
, records with nested fields / variants / options / collections, or any computed field, use the manual escape hatch.
mo
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()
  • .payload(name, extract)
    adds one field.
    extract
    returns any
    V
    whose
    _toRow
    instance is in scope. For options and variants, write a tiny local helper returning
    Text
    or
    Nat
    with a sentinel for absence. The name must not contain
    .
    — dots are the edge-traversal separator.
  • .flatten(extract)
    splices a nested record in as flat, top-level columns — one line instead of one
    .payload
    per inner field.
    extract : T -> S
    returns the sub-record; the combiner walks
    S
    and every field becomes its own column under its (unprefixed) name.
    S
    must be flat. Drop unwanted fields with
    .hidden(name)
    afterwards.
  • .edge
    /
    .hidden
    tag and drop already-declared fields by name; the name must match a
    .payload
    (or a field a
    .flatten
    produced).
  • For row sources that aren't a container shortcut (a custom flattener, a filtered iterator), call
    OQL.Entity.manual<T>(name, iter, typeName, primaryKey)
    directly.
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
__1
,
__2
, … suffix. Both columns survive and stay queryable — nothing is silently dropped.
OQL.Value
is
{ #null_; #bool : Bool; #nat : Nat; #int : Int; #float : Float; #text : Text }
. The built-in
_toRow
set covers the primitives and
Principal
(rendered as
#text
). Numeric variants (
#nat
/
#int
/
#float
) compare across each other in predicates, so a JSON integer threshold still matches a
Float
row value.
对于非记录类型
T
、包含嵌套字段/变体/可选类型/集合的记录,或任何计算字段,使用手动模式。
mo
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
__2
等后缀。所有列都会保留并可查询——不会被静默丢弃。
OQL.Value
的类型为
{ #null_; #bool : Bool; #nat : Nat; #int : Int; #float : Float; #text : Text }
。内置的
_toRow
集合覆盖了基本类型和
Principal
(渲染为
#text
)。数值变体(
#nat
/
#int
/
#float
)在谓词中可以相互比较,因此JSON整数阈值仍能匹配
Float
类型的行值。

Which mode?

如何选择模式?

Row type
T
Mode
Record of primitives onlyauto-derive (
.toEntity
)
Record with
?Field
auto-derive once you ship
Opt<T>Value.mo
, else manual
Record with nested record (
addr : Address
)
auto once you ship
AddressValue.mo
collapsing to its PK; else manual with
.flatten(func x = x.addr)
Record with a variant fieldauto once you ship
<Variant>Value.mo
, else manual with inline
f : MyVariant -> Text
Record with a collection field (
[Tag]
,
Set<…>
)
manual —
.size()
or
Text.join
into a payload
Tuple, primitive, or anything elsemanual —
T
isn't a record so the combiner doesn't apply
行类型
T
模式
仅包含基本类型的记录自动推导(
.toEntity
包含
?Field
的记录
若已引入
Opt<T>Value.mo
则自动推导,否则使用手动模式
包含嵌套记录(
addr : Address
)的记录
若已引入
AddressValue.mo
将其折叠为主键则自动推导,否则使用手动模式并配合
.flatten(func x = x.addr)
包含变体字段的记录若已引入
<Variant>Value.mo
则自动推导,否则使用手动模式并配合内联函数
f : MyVariant -> Text
包含集合字段(
[Tag]
Set<…>
)的记录
手动模式——使用
.size()
Text.join
生成负载
元组、基本类型或其他类型手动模式——
T
不是记录,合并器无法生效

Custom
_toRow
instances (extending auto-derivation)

自定义
_toRow
实例(扩展自动推导)

OQL'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
_toRow : T -> OQL.Value
becomes auto-derivable as a field of another record:
  1. One file per type, named
    <TypeName>Value.mo
    .
  2. A single
    public func _toRow(self : T) : OQL.Value
    .
  3. 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
.toEntity(...)
with no manual
.payload
per field.
mo
// 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
department : Department
now auto-derives a
department : Text
cell holding the FK; re-tag it with
.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
Value
variant
, even for the null case — a
_toRow
that returns
#null_
for some rows and
#text
for others makes the schema's reported type flip-flop based on row order. Sentinels keep the schema stable AND keep the field queryable (
{ "eq": { "field": "x", "value": "" } }
matches the null rows). Same shape for
?Nat
(sentinel 0),
?Bool
(false).
mo
// 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
T
used both as a top-level entity and as a nested field needs two
_toRow
shapes: the structural
_toRow : T -> Row
the combiner synthesises (you don't write it) and your
_toRow : T -> Value
collapse.
Row
and
Value
are distinct types, so the resolver picks the right one in each context. Ship one
TypeValue.mo
and both paths work.
When 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
.payload
extract is fine.
OQL的隐式解析器会查看actor组合根中导入的模块,而非仅库的内置模块。任何您编写了
_toRow : T -> OQL.Value
的类型,都可以作为其他记录的字段被自动推导:
  1. 每个类型对应一个文件,命名为
    <TypeName>Value.mo
  2. 包含一个
    public func _toRow(self : T) : OQL.Value
    函数。
  3. 在声明实体的文件顶层导入该模块(不能嵌套在子模块中——解析器不会遍历子模块)。
一旦导入,包含这些字段的父记录即可通过
.toEntity(...)
自动推导,无需为每个字段手动编写
.payload
mo
// DepartmentValue.mo — 嵌套记录→子记录的主键
import OQL   "mo:caffeineai-oql";
import Types "Types";
module {
  public func _toRow(self : Types.Department) : OQL.Value = #text(self.name);
};
现在所有包含
department : Department
的父记录都会自动推导生成
department : 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) };
  };
};
始终使用单一
Value
变体
,即使是空值情况——如果
_toRow
对某些行返回
#null_
,对其他行返回
#text
,会导致模式报告的类型根据行顺序发生变化。哨兵值既能保持模式稳定,又能让字段可查询(
{ "eq": { "field": "x", "value": "" } }
会匹配空值行)。
?Nat
(哨兵值0)、
?Bool
(false)同理。
mo
// 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 -> Value
折叠版本。
Row
Value
是不同的类型,因此解析器会在不同场景中选择正确的实例。只需提供一个
TypeValue.mo
,两种场景均可正常工作。
适用场景:如果两个或更多实体嵌入相同的嵌套记录类型,编写实例是值得的——第二次使用即可回本。对于一次性嵌入,内联
.payload
提取器更简洁。

The 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
customer
entity above).
reshaped — 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
.payload
:
mo
// 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
(Int, Nat, Measurement)
has no field names, so it can't auto-derive — emit a flat record instead.
enumerated — derive an entity from an existing index, no dedicated storage. Use when entities live inside other records and you have a
Map<Entity, _>
keyed by them:
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
.edge(name, target)
tags a payload field as a FK — the value still comes from the matching
.payload
, so you write both lines.
同一存储可以同时支持多种模式。选择与客户端需求匹配的模式即可。
具体实体 — 现有集合的每个元素对应一行。常见场景(如上述的
customer
实体)。
重构实体 — 扁平化嵌套存储,将内部键提升为列。让扁平化器输出扁平的记录(而非元组),这样自定义行源即可通过自动推导处理,无需为每个字段编写
.payload
mo
// 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)
将负载字段标记为外键——值仍来自对应的
.payload
,因此需要同时编写两行代码。

Manual-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
Value
variant and stay there.
mo
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
<TypeName>Value.mo
module and let auto-derive wire it. If it's strictly local, the inline helper is shorter.
当非基本类型字段仅在单个实体中使用,或仅用于一次性计算结构时,无需为其编写单独的类型模块,可在构建器中内联转换逻辑。同样遵循哨兵值原则——选择单一
Value
变体并保持一致。
mo
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.mo
模块,让自动推导处理。如果仅在本地使用,内联辅助函数更简洁。

What OQL does NOT do

OQL不支持的功能

  • No reverse joins (fan-out). Forward, single-valued edges ARE traversable server-side with dotted paths (
    "customerId.country"
    ); one-to-many needs a second
    execute()
    with
    in
    .
  • No writes.
    execute
    is
    shared query
    . Mutations stay in your own
    shared
    methods.
  • No query planner. Each
    execute
    is a linear scan over the row source. For fast point lookups, build a
    Map
    and have the row source walk it.
  • 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
    _toRow
    for Option, Variant, nested records, collections.
    Ship a
    <TypeName>Value.mo
    for the first three to stay on auto-derive, or drop to manual mode.
  • 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
execute
is one
shared query
call: the row source iterates once, each row materialises into extracted cells, the predicate runs per row, survivors are collected (and sorted
O(n log n)
if
orderBy
is set), then
offset
+
limit
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).
每个
execute
都是一个
shared query
调用:行源迭代一次,每行转换为提取的单元格,谓词逐行执行,收集符合条件的行(如果设置了
orderBy
则进行O(n log n)排序),然后通过
offset
+
limit
切片并生成投影结果。无缓存,无规划器——工作量与行源长度成正比。如果查询需要限制范围,需让行源本身是有界的(遍历索引而非全表)。

Checklist: adding an entity

添加实体检查清单

  • Storage iterator exists (
    func () = collection.values()
    ) or flattener written
  • Mode chosen: all-primitive fields →
    .toEntity
    ; custom
    _toRow
    instances in scope → still auto; tuples/collections/computed →
    .toEntityManual
    /
    OQL.Entity.manual
  • For every non-primitive field type used by 2+ entities, a
    <TypeName>Value.mo
    exists and is imported at the top of the composition root
  • Auto mode:
    .sample(template)
    declared (required if the collection may be empty at build time)
  • Manual mode: every exposed field has a
    .payload
    , or a nested record is spliced with
    .flatten(func x = x.sub)
  • FK fields tagged with
    .edge(name, targetEntity)
  • Filter-only fields use
    .hidden(name)
  • All sentinel conversions return ONE
    Value
    variant (never
    #null_
    mixed with
    #text
    )
  • Per-user entities marked with
    .ownedBy(field)
    /
    .ownedByWith(field, canSee)
  • .build()
    at the end, entity added to the
    entities = [...]
    array
  • Don't hand-write
    schema
    /
    execute
    — the mixin provides them
  • Compiles (
    mops build
    / deploy)
  • 存储迭代器已存在(
    func () = collection.values()
    )或已编写扁平化器
  • 已选择模式:全基本类型字段→
    .toEntity
    ;自定义
    _toRow
    实例已导入→仍使用自动推导;元组/集合/计算字段→
    .toEntityManual
    /
    OQL.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
    /
    execute
    ——mixin会提供这些方法
  • 已编译通过(
    mops build
    / 部署)