mcpField
E
extension-oql

extension-oql

github.com/caffeinelabs数据与数据库228.1k 次安装6 天前更新
代码仓库

关于 extension-oql

面向 Motoko 的 Object Query Layer 技能,可将 canister 集合公开为可查询实体,涵盖 Schema 派生、实体级授权、按用户限制行访问及关联遍历。

主要功能

  1. 01将集合映射为实体
  2. 02自动或手动派生 Schema
  3. 03按实体设置读取授权
  4. 04按所有者限制行并定义视图
  5. 05实体间外键遍历

适用场景

  1. 01让 canister 记录支持查询
  2. 02将行访问限制为数据所有者
  3. 03公开目录或管理私有审计数据

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/caffeinelabs/skills --skill extension-oql

安装后在 Claude Code 中输入/extension-oql确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

scripts/
csv.mjs
encode.mjs
hash-segment.mjs
ingest.mjs
lib.mjs
package.json

详细介绍

OQL — 对象查询层

遍历 Actor 的字段(非临时字段),并针对每个值得查询的集合, 考虑其数据如何映射到数据库中的一张表(一个实体)。每张表只 声明一个实体 — Expose mixin 会使其可查询。

后端

每个实体都有一个授权级别;默认的 .controllerOnly() 是安全的(对用户私有,但数据智能 Agent 仍可读取)。先建模实体,再为每个实体选择级别 — 参见 ## Auth。

设置

在首次导入 mo:caffeineai-oql/... 的同一写入批次中运行 mops add [email protected]。自动派生需要 moc >= 1.11(生成的应用模板已满足此要求)。

构建标志

--implicit-package=core 是可选的便利设置;每个代码片段和源文件都必须导入其使用的 mo:core 模块。如果应用使用 OQL.Table,且需要超过 4 GiB 的 Region,请添加 --max-stable-pages 1638400;依赖项自身的标志不会应用到依赖它的项目,因此必须在应用自身的构建中设置。

导入 — 每个解析器模块各导入一次

.toEntity、构建器链(.sample / .build / .public_ / …)以及记录的 _toRow 派生,都会从声明实体的文件中顶层导入的模块解析 — 解析器不会遍历子模块,因此仅导入 mo:caffeineai-oql 并不足够。请准确导入代码所用的解析器模块:

  • mo:caffeineai-oql/Entity — 始终需要(.sample / .build / .edge / .ownedBy / 授权级别构建器链,以及手动模式下的 .payload / .flatten)。
  • 每个 .toEntity / .toEntityManual 接收者对应的集合模块 — MapEntity、SetEntity、ListEntity、ArrayEntity 或 VarArrayEntity。
  • 对每个自动派生(.toEntity)记录:RecordValue,以及每种出现的基本字段类型对应的一个 <Type>Value — NatValue、TextValue、PrincipalValue、BoolValue、IntValue、FloatValue、定宽 Nat/Int 类型,以及 BlobValue(也涵盖 Storage.ExternalBlob)。可选字段(?T)无需额外导入 — Entity 自带 ?T 实例。手动模式下 .payload 的返回类型也需要对应的 <Type>Value;仅手动实体无需 RecordValue。

如果缺少集合模块,编译器会指出模块名称 — "field toEntity does not exist … Did you mean to import mo:caffeineai-oql/MapEntity?" — 请添加所提示的导入。缺少 Entity 导入时不会出现此类提示:它会直接报错 "field payload does not exist in type Builder<…>"(M0072)。请将任何 field <builderMethod> does not exist 错误视为此列表中缺少顶层导入,而非包版本错误。

声明实体并安装

.toEntity(name, typeName, primaryKey) 会将记录集合转换为可查询实体;编译器会自动派生字段。每个实体都设置自己的授权级别(参见 ## Auth);下面的示例展示每个级别对应一张表。Expose 只会添加 OQL 查询方法(schema / execute)— 不会更改现有状态、类型和 shared 方法。

  • 每条 .toEntity / Entity.manual 链都必须调用 .sample({...});虚拟值即可。集合为空且未提供样本 → 空架构(fields: [] / "record { }")。
include Expose({ entities = [tasks.toEntity("task", "Task", "id").sample({ id = 0; title = "" }).public_().build()] })
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";
// Resolver modules, imported top-level (see "Imports" above). This app derives
// Map entities over records of Nat / Text / Principal fields:
import MapEntity      "mo:caffeineai-oql/MapEntity";
import Entity         "mo:caffeineai-oql/Entity";
import RecordValue    "mo:caffeineai-oql/RecordValue";
import NatValue       "mo:caffeineai-oql/NatValue";
import TextValue      "mo:caffeineai-oql/TextValue";
import PrincipalValue "mo:caffeineai-oql/PrincipalValue";

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; ciphertext : Text };
  type User     = { id : Principal; isAdmin : Bool };

  let products  : Map.Map<Nat, Product>;
  let vendors   : Map.Map<Nat, Vendor>;
  let supplies  : Map.Map<Product, Vendor>;
  let auditLogs : Map.Map<Nat, AuditLog>;
  let notes     : Map.Map<Nat, Note>;
  let documents : Map.Map<Nat, Document>;
  // not all collections need to be exposed if there is no need — `users` backs
  // auth only, so it is intentionally never turned into an entity below
  let users     : Map.Map<Principal, User>;

  transient let anyP = Principal.fromText("aaaaa-aa");   // sample owner; the value is ignored

  // Look up whether a caller is an admin.
  func isAdmin(p : Principal) : Bool =
    switch (users.get(p)) { case (?u) u.isAdmin; case null false };

  // A custom .ownedByWith rule: admins see every document, everyone else only
  // their own. `owner` is the field's Value — a Principal column arrives as #text.
  func canSeeDocument(caller : Principal, owner : OQL.Value) : Bool =
    isAdmin(caller) or owner == #text(caller.toText());

  include Expose({
    entities = [
      // #public_ — anyone, incl. anonymous, reads the whole catalogue
      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>` — a map between two non-primitive types.
      // The identity lives in the key/value records, not a field, so iterate
      // .entries() in manual mode, promote each side's id, and .edge both — a
      // query can then traverse "product.name" and "vendor.name".
      OQL.Entity.manual<(Product, Vendor)>("supply", func () = supplies.entries(), "Supply", "key")
        .sample(({ id = 0; name = ""; priceUsd = 0 }, { id = 0; name = "" }))
        .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 (the default, shown explicitly) — only the platform reads
      auditLogs.toEntity("auditLog", "AuditLog", "id")
        .sample({ id = 0; action = ""; atNs = 0 })
        .controllerOnly()
        .build(),
      // #scopedPerUser — each signed-in user reads only their own rows
      notes.toEntity("note", "Note", "id")
        .sample({ id = 0; user = anyP; body = "" })
        .ownedBy("user")
        .scopedPerUser()
        .build(),
      // #controllerOrScoped — controller reads all; scoped reads use canSeeDocument.
      // `.hidden` — opaque column absent from schema + default projection
      documents.toEntity("document", "Document", "id")
        .sample({ id = 0; owner = anyP; title = ""; ciphertext = "" })
        .hidden("ciphertext")
        .ownedByWith("owner", canSeeDocument)
        .controllerOrScoped()
        .build(),
    ];
  });
}

迁移链的头部:

import Map "mo:core/Map";

module {
  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; ciphertext : Text };
  type User     = { id : Principal; isAdmin : Bool };

  type NewActor = {
    products  : Map.Map<Nat, Product>;
    vendors   : Map.Map<Nat, Vendor>;
    supplies  : Map.Map<Product, Vendor>;
    auditLogs : Map.Map<Nat, AuditLog>;
    notes     : Map.Map<Nat, Note>;
    documents : Map.Map<Nat, Document>;
    users     : Map.Map<Principal, User>;
  };

  public func migration(_old : {}) : NewActor {
    {
      products  = Map.empty<Nat, Product>();
      vendors   = Map.empty<Nat, Vendor>();
      supplies  = Map.empty<Product, Vendor>();
      auditLogs = Map.empty<Nat, AuditLog>();
      notes     = Map.empty<Nat, Note>();
      documents = Map.empty<Nat, Document>();
      users     = Map.empty<Principal, User>();
    };
  };
};

Auth

授权按实体设置 — 每个构建器都会声明一个级别,且 schema() 和 execute() 都会针对当前 caller 执行检查。无需应用级配置,也无需令牌。未设置时的默认值为 #controllerOnly。

构建器调用可读取者返回行
.public_()任何人(包括匿名用户)全部
.controllerOnly() (默认)仅控制器全部
.scopedPerUser()任何已登录的调用者仅调用者自己的行
.controllerOrScoped()控制器 + 已登录的调用者控制器:全部;用户:自己的行
选择级别

根据应由谁读取实体中的行来选择;不确定时,保留默认值。

  • .controllerOnly() (默认) — Agent 应当据此作答的应用私有数据,但最终用户不会直接读取(订单、指标、审计日志、配置)。Agent 以控制器身份调用,因此可以读取所有数据,同时数据对用户保持私有。
  • .public_() — 任何人(包括已登出的访客)都可读取的数据(公开目录、已发布内容、排行榜)。
  • .controllerOrScoped() — 每位用户只能读取自己行的用户数据,但 Agent 仍须能够回答聚合类问题(个人资料、用户订单)。需要所有者列。
  • .scopedPerUser() — 严格私有的用户数据:每位用户只能读取自己的数据,Agent 也受范围限制,因此无法基于此表作答(私信、私人日记)。需要所有者列 — 除非 Agent 必须无法访问这些数据,否则优先使用 .controllerOrScoped()。

用户可以按实体覆盖设置;如果请求暗示这是用户数据,但含义不明确,请先询问。

按用户(行级)限定范围

限定范围的级别(.scopedPerUser()、.controllerOrScoped())需要一种方式来判断哪些行属于调用者 — 所有者列或遵循主体的来源。限定范围的实体若两者都没有,.build() 会报错;如果 .public_() 实体声明了所有者,也会报错(因为检查永远不会运行)。这是防止常见数据泄露隐患的安全机制。

何时标记: Principal 字段是判断依据。

  • .ownedBy(field) — 该字段就是所有者;可见性由身份相等决定。
  • .ownedByWith(field, canSee) — 自定义可见性(团队、管理员、共享)。canSee : (caller : Principal, owner : Value) -> Bool 按行决定可见性;field 不必是 Principal,闭包也可以读取 actor 状态。

限定范围的调用者只能看到其拥有的行 — 无论是作为查询目标,还是通过联接访问 — 因此遍历永远不会泄露其他所有者的行。

// Per-user notes: each signed-in user reads only their own rows.
notes.toEntity("note", "Note", "id")
  .sample({ id = 0; owner = Principal.fromText("aaaaa-aa") /* any principal */; body = "" })
  .ownedBy("owner")
  .scopedPerUser()
  .build()

// .ownedByWith custom rule: the owner sees their own docs, listed admins see
// everyone's, and the platform controller sees all (#controllerOrScoped).
// `owner` is the field's Value — a Principal column arrives as #text(principal).
docs.toEntity("doc", "Doc", "id")
  .sample({ id = 0; owner = Principal.fromText("aaaaa-aa"); title = "" })
  .ownedByWith("owner", func (caller, owner) =
    admins.get(caller) != null or owner == #text(caller.toText()))
  .controllerOrScoped()
  .build()

所有权决定限定范围的调用者能看到哪些行,而 .viewWith(view) 决定这些行呈现的形式 — 它会按主体进行脱敏,且只会对已通过所有权检查的行运行:

// Everyone sees their own bookings; exact amounts only on their own rows is
// not needed here — but coarsen the contact field for non-owners of a shared
// calendar, say:
bookings.toEntity("booking", "Booking", "id")
  .sample({ id = 0; calendarId = 0; contact = "" })
  .ownedByWith("calendarId", canSeeCalendar)
  .viewWith(func (subject, b) = if (isOwner(subject, b)) b else { b with contact = "" })
  .scopedPerUser()
  .build()

view : (subject : Principal, row : T) -> T 会重塑类型化的行;整个查询管道(包括筛选条件)都会针对经 VIEW 处理的行执行,因此谓词绝不会探查视图隐藏的值。视图只会针对限定范围的主体运行 — 将 .viewWith 与 .public_() 配对会在 .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

对于字段全为基本类型且内置 _toRow 的记录(Nat、 Int、Float、Text、Bool、定宽的 Nat/Int、Principal):

customers.toEntity(name, typeName, primaryKey)
  .sample(template)              // REQUIRED — empty collection + no sample → empty schema
  .edge(field, targetEntity)     // tag an existing field as a foreign key
  .ownedBy(field)                // (or .ownedByWith(field, canSee)) per-user scoping
  .scopedPerUser()               // auth level: .public_ / .controllerOnly (default) / .scopedPerUser / .controllerOrScoped
  .hidden(field)                 // opaque/sensitive — drop from schema + default projection
  .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) 会将现有字段标记为外键(不会添加字段),从而支持在查询中使用点路径 "name.targetField" 遍历。外键/主键类型必须为 Text、Nat/Int 或 Bool(不接受 Float 键),且目标的主键不能是 .hidden。
  • 边的目标必须是已在此 canister 的 Expose 中注册的实体。 指向不存在实体的边(拼写错误,或指向其他 canister 的外键)会静默地从 schema() 中移除整个字段——该列仍会存储并可用于筛选,但客户端无法通过 schema 发现它。跨 canister 外键应作为普通载荷字段,而非 .edge。
  • .sample(template) 用于提供 schema 发现所需的种子。务必调用;若未调用,空集合会产生空 schema(fields: [])。只有结构重要,值无关紧要。
  • .hidden(name) 会从 schema 和默认投影中移除可推导字段;它不会跳过 _toRow——不支持的字段类型仍需使用手动模式或 <Type>Value。

Schema 字段按字典序列出(__record 合并器的规范形式);若显示顺序很重要,请在客户端排序。

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

适用于非记录类型 T、计算字段,或包含嵌套 / 变体 / 可选值 / 集合字段的记录:

// REQUIRED, top-level in this file — `.payload` / `.flatten` are Entity
// functions reached by receiver notation, not fields of the builder:
import Entity "mo:caffeineai-oql/Entity";

authors.toEntityManual<Author>("author", "Author", "id")
  .sample({ id = 0; name = ""; address = { street = ""; city = "" }; tags = [] })
  .payload("name", func a = a.name)        // one field; extract returns a _toRow value
  .flatten(func a = a.address)             // splice a nested record's fields as columns
  .payload("tagCount", func a = a.tags.size())
  // .edge tags a declared column; .hidden drops one you already added via
  // `.payload` / `.flatten` — omit fields by not adding them
  .build()

field payload does not exist in type Builder<…>(M0072)表示缺少上方的 Entity 导入——这绝不是软件包版本问题。 .payload 和 .flatten 从 0.1.0 起就是 Entity 函数;通过 mops add 安装不同版本的 caffeineai-oql 无法修复此错误。仅导入 mo:caffeineai-oql 不够,通过 OQL.Entity.… 重新导出访问模块也不够:接收者记法只会解析顶层导入,因此调用 OQL.Entity.manual(...).payload(...) 的文件仍需自行添加 import Entity "mo:caffeineai-oql/Entity";。与 .toEntity 不同,此错误不会附带“是否要导入……?”提示。

  • .payload(name, extract) — name 不得包含 .。优先使用 func r = r.field(让 Motoko 推断类型;避免冗余标注)。可选的 ?T 载荷可直接使用;对于变体,请返回 Text/Nat(见下文)。
  • .flatten(extract : T -> S) — S 必须是扁平类型;它的每个字段都会成为 顶层列。使用 .hidden 移除不需要的字段。名称冲突会添加 __1、__2 后缀(不会丢弃任何字段)。
  • 对任意行来源(自定义扁平化器、经过筛选的迭代器)使用 OQL.Entity.manual<T>(name, iter, typeName, primaryKey)。务必链接 .sample(...), 并提供一个类型为 T 的虚拟行。限定调用通过 OQL 导入解析,但链接到其结果上的任何 .payload / .flatten 仍需要顶层 Entity 导入。

OQL.Value 是 { #null_; #bool; #nat; #int; #float; #text }。数值变体之间 可以比较,因此 JSON 整数阈值可以匹配 Float 值。

行类型 T模式
全基本类型记录.toEntity
带 ? 字段的记录.toEntity(照常导入内部类型的 <Type>Value)
带变体 / 嵌套字段的记录提供 <Type>Value.mo 后使用 .toEntity(见下文);否则使用手动模式
带集合字段的记录手动模式 — 使用 .size() 或将其通过 Text.join 转为载荷
元组 / 基本类型 / 计算值手动模式

可选字段

每个具有实例的 ?T(?Text、?Nat、?Storage.ExternalBlob,……)都会通过 Entity 中的 ?T 实例派生:null 存储为 #null_,?v 存储为 v。无需额外导入或为每种类型单独创建文件。#null_ 单元格匹配 eq value null,不匹配有序比较,并会被聚合操作跳过。

在 .sample(...) 行中,设置每个可选字段(note = ?""、score = ?0、photo = ?("" : Storage.ExternalBlob)):架构会根据样本推断每列的类型,因此在那里留为 null 的字段会报告类型 "Null"。

转换非基本类型字段

若要让记录保留在自动派生路径上,请为每种非基本字段类型提供一个 _toRow : T -> OQL.Value:每种类型一个文件,命名为 <TypeName>Value.mo,其中包含一个 public func _toRow,并在声明实体的文件中顶层导入——与内置值模块的顶层规则相同(参见 ## Setup → Imports);解析器不会遍历子模块。父记录随后可通过 .toEntity(...) 处理,无需为每个字段单独使用 .payload。

// StatusValue.mo — variant → tag text
module { public func _toRow(self : Status) : OQL.Value =
  #text(switch self { case (#draft) "draft"; case (#published) "published" }); };

// DepartmentValue.mo — nested record → child PK (then .edge the field)
module { public func _toRow(self : Department) : OQL.Value = #text(self.name); };

自定义 _toRow 始终只返回一个 Value 变体——如果它针对不同行返回不同变体,报告的架构类型就会取决于抽取了哪一行作为样本。对于一次性字段,可将相同的转换内联到 .payload 中,而不是创建模块;只有在 2 个或更多实体需要时才提取为模块。既用作实体又用作嵌套字段的记录只需提供其 <Type>Value.mo——结构化的 Row 派生类型与你的 Value 转换结果是不同的类型,可以共存。

超越每条记录一行的实体模式

同一存储可以支持多个实体——选择客户端应看到的内容:

  • reshaped — 将 Map<K1, Map<K2, V>> 展平为行;让展平器输出一个扁平的记录(而不是元组),以便继续自动派生,然后对提升后的键使用 .edge。
  • enumerated — 通过 OQL.Entity.manual,从索引键(Map<Author, …>.keys())派生实体;没有行的条目不会出现。
  • synthetic — 从数组字段投影出一个联结表,使多对多查询可以从两侧进行:
OQL.Entity.manual<(Article, Text)>("articleTag", func () = flattenTags(articles), "Pair", "pair")
  .sample(({ id = 0 }, ""))
  .payload("article", func ((a, _)) = a.id) .edge("article", "article")
  .payload("tag",     func ((_, t)) = t)    .edge("tag", "tag")
  .build()

更大的数据集 — OQL.Table

对于预计会增长到较大的表(数万行或更多,例如事件、交易、日志、导入的数据集),请将其存储在 OQL.Table 中。它的扩展能力远超堆集合;行以追加位置作为键。

声明一个表需要做出三个决定——列、索引、行函数:

import OQL    "mo:caffeineai-oql";
import Expose "mo:caffeineai-oql/Expose";
import Entity "mo:caffeineai-oql/Entity";   // the builder chain — always import
import Table  "mo:caffeineai-oql/Table";

actor {
  type Event = { kind : Nat; amount : Nat; note : Text };

  // decisions 1 + 2 (columns, indexes) are fixed where `Table.new` runs —
  // the migration chain entry below
  let events : Table.Table;

  // 3. Row function: your record → one (name, Value) per column, names matching.
  func eventRow(e : Event) : [(Text, OQL.Value)] =
    [("kind", #nat(e.kind)), ("amount", #nat(e.amount)), ("note", #text(e.note))];

  // Writes: the returned position is the row's primary key.
  // A modify is delete + append.
  public func addEvent(e : Event) : async Nat {
    Table.append(events, e, eventRow);
  };

  // REQUIRED: register the table's entity in the Expose mixin — schema() and
  // execute() only see what Expose registers; a Table that is not in this list
  // exists but is invisible to every query. Same list, same auth levels, and
  // FK edges to other entities, as for any entity. `entity` defaults the type
  // name to the entity name and the primary key to "id";
  // `entityWith(events, "event", "Event", "id")` overrides either.
  include Expose({
    entities = [
      Table.entity(events, "event").public_().build(),
      // ... the app's other entities ...
    ];
  });
};

Table.new 在引入该表的迁移链条目中运行:

import Table "mo:caffeineai-oql/Table";

module {
  public func migration(_old : {}) : { events : Table.Table } {
    {
      // 1. Columns: (name, type) pairs. Order matters and the set is FIXED for
      //    the table's life. Types: #nat / #int / #float / #bool (64-bit cells) + #text.
      // 2. Indexes: the columns queries will filter or order by —
      //    #hash for equality, #ordered for ranges / orderBy.
      events = Table.new(
        [("kind", #nat), ("amount", #nat), ("note", #text)],   // columns
        [("kind", #hash), ("amount", #ordered)],               // indexes
      );
    };
  };
};

重要规则:

  • 数据刷新后,架构即固定——不能添加、删除或重新指定列类型;形状不同就意味着要建新表。
  • 值的种类必须与列类型相同——不匹配会在 append 时直接触发错误,而不会存储损坏的单元格;数值单元格为 64 位。
  • 要批量加载的表声明时不得包含索引——加载后再创建索引(见下一节)。
  • 只能通过 Expose 中的实体查询 Table——仅声明表只会存储数据,不会暴露任何内容;schema() / execute() 只会看到 entities 列表中的内容。

除了索引所服务的查询之外,Table 还可以直接根据分段统计信息计算整列的 sum / avg——耗时不随表大小增长——并且只读取查询涉及的列。

批量上传(ImportData)——将现有数据载入 Table

当用户的数据已经存在(例如从电子表格或应用所替代的系统导出的 CSV)时,不要通过 append 慢慢写入——引入 ImportData mixin,并将数据作为预构建的 段映像 载入:加载器在链下按照已刷写段的字节布局精确排列行,canister 会验证并复制这些字节,因此每条消息的开销是 O(columns),而非 O(rows),整个过程中堆内存保持平稳。

import Expose     "mo:caffeineai-oql/Expose";
import Entity     "mo:caffeineai-oql/Entity";   // the builder chain — always import
import Table      "mo:caffeineai-oql/Table";
import ImportData "mo:caffeineai-oql/ImportData";

actor {
  // Declare the table INDEX-FREE for the load — loadSegment traps on a table
  // that declares an index (a ready index missing the loaded rows would
  // silently under-fetch). The index is built after the load, in the background.
  // Constructed in the migration chain entry that introduces it:
  //   events = Table.new([("kind", #nat), ("amount", #nat), ("note", #text)], []);
  let events : Table.Table;

  include Expose({
    entities = [
      Table.entity(events, "event").public_().build(),
      // ... the app's other entities ...
    ];
  });
  include ImportData([ events.importTarget("event") ]);
};

该 mixin 添加了 仅控制器可调用 的端点(layout、rows、putSegment、importFlush、buildIndex、indexStatus)——映像中的统计数据是可信的,因此加载接口应只对本就能够安装代码的主体开放。

运行加载器。 该工具随此技能一同提供,位于 scripts/ 中——使用原生 Node(≥ 20),无需安装依赖;每次调用都会通过 icp canister call 执行,因此必须先配置好 icp-cli:

node <this skill's directory>/scripts/ingest.mjs \
  --canister <canister-id> --target event --file events.csv \
  -e ic --index kind:hash
  • --target 是 canister 中声明的 importTarget 名称;canister 的 layout() 是架构的权威来源——CSV 表头会按名称与列匹配,因此文件中的列顺序无关紧要(多余的 CSV 列会被忽略;缺少已声明列则会报错)。
  • 连接标志会传递给 icp-cli,和其他 canister 调用的方式相同:-e <environment>(已部署的应用使用 -e ic)、-n <network>、--identity <name>(默认值:当前身份)。这些端点仅控制器可调用,因此该身份必须是 canister 的 控制器。如果未指定这些标志,则使用 icp 的默认值——此时 canister NAME 会根据项目的默认环境解析,因此请从应用的项目目录运行该工具。
  • --index col:kind(可重复)会在行数据之后,在链下构建并上传对应列的索引(kind = hash)。对于无法在链下构建的索引(#ordered、复合索引),请改用该 mixin 的端点在链上构建:icp canister call <id> buildIndex '("event", vec {"kind"}, variant {hash})' — 构建完成前,查询会扫描数据(结果正确,只是较慢);之后由索引提供服务;indexStatus '("event")' 会报告进度。
  • 按照客户端读取数据的方式验证加载结果: icp canister call <id> execute '("{\"start\":\"event\",\"aggregate\":[{\"fn\":\"count\"}]}")' --query 必须返回 CSV 的行数。
  • CSV 单元格会根据声明的类型解码。未加引号的空字段是 null 单元格;#text 列中带引号的 "" 表示空字符串;#bytes(w) 列的字段必须是 base64,且解码后必须恰好为 w 个字节。
  • 重新运行相同命令会从上次进度继续:表中已有的行会被跳过,且 putSegment 的首行校验会在重复发送时直接触发 trap 并明确报错,而不是产生重复行。

操作规则(违反规则时会明确拒绝,而非破坏数据):

  1. 首次加载必须完整完成——包括数据和索引——之后应用才能开始写入。 如果加载过程中有写入,重新运行将无法完成索引;请通过 buildIndex 在 canister 内重建(速度较慢,但始终有效)。
  2. 此后,加载和写入轮流进行。 两次加载之间,应用可以自由写入;下次运行相同命令时会将新增数据作为增量加载,无需任何准备。
  3. 绝不能同时进行——canister 会拒绝操作,不会返回错误答案。
  4. 查询在整个过程中都能正常工作,包括加载期间;索引仍在上传的列会先通过扫描提供查询服务,直至索引构建完成。

检查清单

  • 在首次导入时,同一批执行 mops add [email protected]
  • 在顶层导入解析器模块(参见 ## Setup → Imports):Entity (始终如此——包括 .payload / .flatten 在内的每个构建器方法 都通过它进行解析)、集合模块(MapEntity / …),以及 每种自动派生记录都要导入 RecordValue 和每种原始字段类型对应的 <Type>Value
  • 每个实体:都存在行迭代器;使用 .toEntity(全原始类型)或 .toEntityManual / OQL.Entity.manual,否则
  • .sample 为每个可选(?T)字段设置非 null 虚拟值
  • 每个在多个实体间复用的非原始字段都要有 <Type>Value.mo, 并在顶层导入
  • 每条 .toEntity / Entity.manual 链都调用 .sample(template)(虚拟值即可)
  • FK 字段使用 .edge(name, target);自动派生的不透明/敏感字段使用 .hidden(name)(手动定义时:不添加 .payload 即可省略,或者仅对 已添加的列使用 .hidden)
  • 每个自定义 _toRow 都返回一个 Value 变体
  • 按用户划分的实体使用 .ownedBy / .ownedByWith 并且设置作用域级别 (.scopedPerUser() / .controllerOrScoped())——绝不单独使用 .controllerOnly()
  • 大型、主要采用追加方式的表 → OQL.Table;将句柄保存在持久化 字段中,并在迁移中传递
  • 要导入现有数据集 → 声明不含索引的 Table,添加 ImportData([t.importTarget(name)]),使用 scripts/ingest.mjs 加载, 加载后再建立索引(--index 或 buildIndex)——在应用开始写入之前 完成数据和索引的准备

评论

支持 Markdown