mcpField
E
extension-oql

extension-oql

github.com/caffeinelabsデータ・データベース228.1k 回インストール6 日前に更新
リポジトリ

extension-oql について

canister のコレクションをクエリ可能なエンティティとして公開する、Motoko 向け Object Query Layer のスキルです。スキーマ生成、エンティティ単位の認可、ユーザー別の行制御、関連データのたどり方を扱います。

主な機能

  1. 01コレクションからエンティティへの変換
  2. 02自動・手動のスキーマ導出
  3. 03エンティティ単位の読み取り認可
  4. 04所有者別の行制御とビュー
  5. 05エンティティ間の外部キー参照

活用シーン

  1. 01canister のレコードをクエリ可能にする
  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 — オブジェクトクエリレイヤー

アクターのフィールド(非一時フィールド)を確認し、クエリ対象として有用な各コレクションについて、そのデータをデータベースのテーブル(エンティティ)にどう対応させるかを検討します。テーブルごとに宣言するエンティティは1つだけです。Expose ミックスインを使うと、エンティティをクエリ可能にできます。

バックエンド

各エンティティには認可レベルが設定されます。デフォルトの .controllerOnly() は安全です(ユーザーには非公開ですが、Data Intelligence エージェントは読み取り可能です)。まずエンティティをモデル化し、その後、各エンティティにレベルを選択してください。## Auth を参照してください。

セットアップ

最初の mo:caffeineai-oql/... インポートと同じ書き込みバッチで mops add [email protected] を実行してください。自動導出には moc >= 1.11 が必要です(生成されたアプリのテンプレートはすでにこの条件を満たしています)。

ビルドフラグ

--implicit-package=core は任意の利便機能です。すべてのスニペットとソースファイルで、使用する mo:core モジュールをインポートする必要があります。アプリで OQL.Table を使用し、Region に 4 GiB を超える容量が必要な場合は、--max-stable-pages 1638400 を追加してください。依存関係に指定されたフラグは、その依存関係を使用するプロジェクトには適用されないため、アプリ自体のビルドで設定する必要があります。

インポート — レゾルバーモジュールごとに1つ

.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 と、含まれるプリミティブフィールド型ごとに1つの <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 を参照)。以下の例では、レベルごとに1つのテーブルを示しています。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() (デフォルト) — エージェントが回答に使うものの、エンドユーザーが直接読むことのないアプリのプライベートデータ(注文、メトリクス、監査ログ、設定など)。エージェントはコントローラーとして呼び出されるため、データはユーザーに対してプライベートな状態で、すべてを読み取ります。
  • .public_() — ログアウト中の訪問者も含め、誰でも読み取り可能なデータ(公開カタログ、公開済みコンテンツ、リーダーボードなど)。
  • .controllerOrScoped() — 各ユーザーが自分の行だけを読み取る一方、エージェントは引き続き集計に関する質問にも回答できるユーザーごとのデータ(プロフィール、ユーザーの注文など)。所有者カラムが必要です。
  • .scopedPerUser() — 厳密にプライベートなユーザーごとのデータです。各ユーザーは自分の行だけを読み取り、エージェントにもスコープが適用されるため、このテーブルのデータに基づく回答はできません(DM、プライベートな日記など)。所有者カラムが必要です。エージェントにデータを見せない必要がある場合を除き、.controllerOrScoped() を優先してください。

ユーザーはエンティティごとに設定を上書きできます。リクエストがユーザーごとのデータを示唆しているものの、曖昧な場合は確認してください。

ユーザーごとのスコープ(行レベル)

スコープ付きレベル(.scopedPerUser()、.controllerOrScoped())では、どの行が呼び出し元に属するかを判定する方法として、所有者カラムまたはサブジェクトを尊重するデータソースが必要です。スコープ付きエンティティにどちらもない場合、.build() はエラーになります。また、.public_() エンティティに所有者が宣言されている場合もエラーになります(このチェックは実行されないためです)。これは、よくあるデータ漏えいの落とし穴を防ぐガードレールです。

タグ付けするタイミング: Principal フィールドが手掛かりになります。

  • .ownedBy(field) — このフィールドが所有者です。可視性は同一性の一致によって決まります。
  • .ownedByWith(field, canSee) — カスタムの可視性(チーム、管理者、共有など)。canSee : (caller : Principal, owner : Value) -> Bool が行ごとの可視性を判定します。field は Principal でなくてもよく、クロージャーからアクターの状態を参照できます。

スコープ付きの呼び出し元には、自分が所有する行だけが表示されます。これはクエリ対象としての場合も、結合を通じての場合も同様です。そのため、たどっても他の所有者の行が漏れることはありません。

// 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適用後の行を評価するため、述語からビューが隠す値を調べることはできません。ビューはスコープ付きのサブジェクトに対してのみ実行されます。.build()の時点で.viewWithと.public_()` を組み合わせるとエラーになります(無制限の読み取りでは、常に生の行が表示されます)。

.ownedBy(f) は .ownedByWith(f, OQL.Entity.ownerIsCaller) とまったく同じです。所有者カラムは最大1つまでです。実在するフィールドである必要があり、.edge や .hidden と兼用することはできません。所有者キーを使ったストレージ(Map<Principal, List<T>>)では、OQL.Entity.newScoped(name, scopedIter, typeName, primaryKey) を使用すると、スキャンの計算量は O(ユーザーの行数) になります。scopedIter(?p) は p の行だけを返し、scopedIter(null) はすべての行を返します(スキーマのシード処理)。

エンティティビルダー

行の型 T に応じて選択する、2つのモードがあります。

自動導出 — .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) は既存のフィールドを FK としてタグ付けします (フィールドは追加しません)。これにより、クエリで "name.targetField" というドット区切りのパスをたどれるようになります。FK/PK の型は Text、Nat/Int、Bool のいずれかである必要があります(Float のキーは拒否されます)。また、ターゲットの主キーは .hidden にできません。
  • エッジのターゲットは、この canister の Expose に登録されているエンティティで なければなりません。 存在しないエンティティへのエッジ(タイプミス、または別の canister 内の FK)は、 schema() からフィールド全体を暗黙に除外します。カラムへの保存やフィルター処理は 引き続き行われますが、スキーマを利用するクライアントはそのフィールドを検出できません。 別の canister への FK は .edge ではなく、通常のペイロードフィールドとして扱ってください。
  • .sample(template) はスキーマの検出に使われます。必ず呼び出してください。 呼び出さないと、空のコレクションではスキーマも空になります(fields: [])。 値ではなく、形状だけが重要です。
  • .hidden(name) は導出可能なフィールドをスキーマとデフォルトの投影から除外します。 _toRow の処理を省略するものではありません。サポートされていないフィールド型には、 引き続き手動モードまたは <Type>Value が必要です。

スキーマフィールドは辞書順(__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 の関数です。 別の caffeineai-oql バージョンを mops add しても、このエラーは解消されません。 mo:caffeineai-oql だけをインポートしても不十分です。また、OQL.Entity.… の再エクスポート 経由でモジュールにアクセスするだけでも不十分です。レシーバー記法の解決にはトップレベルの インポートのみが使われるため、OQL.Entity.manual(...).payload(...) を呼び出すファイルには、 独自の import Entity "mo:caffeineai-oql/Entity"; も必要です。 .toEntity とは異なり、このエラーには「Did you mean to import …?」というヒントは表示されません。

  • .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 型のダミー行を 1 つ指定してください。修飾付きの呼び出しは 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 の 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 という名前のファイルを1つ作成し、エンティティを宣言するファイルで public func _toRow をトップレベルにインポートします。これは組み込み値モジュールと同じトップレベルのルールです(## Setup → Imports を参照)。リゾルバーはサブモジュールを検索しません。親レコードは、フィールドごとの .payload を使わずに .toEntity(...) を利用できます。

// 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 は、必ず1つの Value バリアントを返してください。行ごとに異なるバリアントを返すと、報告されるスキーマ型がどの行をサンプルしたかによって変わります。単発のフィールドでは、モジュールを作成する代わりに .payload 内に同じ変換を記述してください。2つ以上のエンティティで必要な場合にのみ、モジュールに切り出します。エンティティとネストされたフィールドの両方で使われるレコードでは、<Type>Value.mo だけを用意します。構造体の Row の導出と、独自の Value への変換は別個の型であり、共存します。

1レコード1行を超えるエンティティのパターン

同じストレージを複数のエンティティで利用できます。クライアントにどのように見せるかを選んでください。

  • 再構成 — Map<K1, Map<K2, V>> をフラット化して行にします。フラット化処理では、自動導出を維持できるよう、タプルではなくフラットなレコードを出力し、その後、昇格したキーに .edge を適用します。
  • 列挙 — OQL.Entity.manual を使い、インデックスキー(Map<Author, …>.keys())からエンティティを導出します。行のないエントリは表示されません。
  • 合成 — 配列フィールドからジャンクションを射影し、両側からクエリ可能な多対多のデータにします。
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 に保存します。ヒープコレクションで保持できる量を大幅に超えて拡張でき、行は追加位置をキーとして保存されます。

宣言時に決めることは3つです。列、インデックス、行関数です。

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ビットです。
  • 一括ロードするテーブルは、インデックスなしで宣言します — ロード後にインデックスを作成します(次のセクション)。
  • Table にクエリできるのは、Expose 内のエンティティを通じてのみです — テーブルを宣言するだけではデータが保存されるだけで、何も公開されません。schema()/execute() が参照するのは、entities リストに含まれるものだけです。

インデックスが提供する機能に加えて、Table は列全体の sum / avg をセグメント統計から直接計算します。テーブルサイズに左右されず、クエリがアクセスする列だけを読み込みます。

一括アップロード(ImportData) — 既存データを Table に読み込む

ユーザーのデータがすでに存在する場合(スプレッドシート、またはアプリが置き換えるシステムからの CSV エクスポートなど)、append で少しずつ書き込むのではなく、ImportData ミックスインを含めて、あらかじめ構築された セグメントイメージ として読み込みます。ローダーはチェーン外で、フラッシュ済みセグメントとまったく同じバイトレイアウトに行を配置し、キャニスターがそのバイトを検証してコピーするため、1 回のメッセージにかかるコストは O(rows) ではなく O(columns) となり、処理中もヒープ使用量は一定に保たれます。

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") ]);
};

このミックスインは コントローラー専用 エンドポイント(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 はキャニスターで宣言された importTarget 名です。スキーマの基準となるのはキャニスターの layout() です。CSV ヘッダーは列名 で照合 されるため、ファイル内の列の順序は問いません(余分な CSV 列は無視されます。宣言済みの列がファイルにない場合はエラーになります)。
  • 接続フラグは、ほかのキャニスター呼び出しと同様に icp-cli に渡されます。-e <environment>(デプロイ済みアプリでは -e ic)、-n <network>、--identity <name>(デフォルト:現在のアイデンティティ)を指定します。エンドポイントはコントローラー専用のため、アイデンティティはキャニスターの コントローラー である必要があります。いずれも指定しない場合は icp のデフォルトが適用されます。その場合、キャニスター NAME はプロジェクトのデフォルト環境で解決されるため、アプリのプロジェクトディレクトリからツールを実行してください。
  • --index col:kind(繰り返し指定可能。kind = hash)を指定すると、行の後にその列のインデックスをチェーン外で構築してアップロードします。構築できないもの(#ordered、複合インデックス)は、ミックスインのエンドポイントを使ってオンチェーンで構築してください。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 の先頭行チェックにより、二重送信があれば行が重複する代わりに明確なトラップが発生します。

運用ルール(違反した場合はデータを破損するのではなく、明確に拒否されます)。

  1. アプリが書き込みを開始する前に、最初の読み込みをデータとインデックスの両方について完全に終わらせてください。 読み込み中に書き込みが行われると、再実行してもインデックスを完成できません。その場合は buildIndex でキャニスター内にインデックスを再構築してください(処理は遅くなりますが、必ず動作します)。
  2. 以降は、読み込みと書き込みを交互に行ってください。 読み込みの合間はアプリが自由に書き込めます。同じコマンドを次に実行すると、追加の準備なしで増分読み込みとして増えた分を取り込みます。
  3. 両方を同時に行わないでください — キャニスターは誤った回答を返すのではなく、処理を拒否します。
  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) — アプリが書き込みを開始する前にデータとインデックスの両方を準備する

コメント