Open9

Wasm版DuckDBのクライアントAPI調査

ktz_aliasktz_alias

データベースのインスタンス化

モジュールの種類

WASM版のduckdbは、直接触るフロントエンドと、WebWorkerで作成されたバックエンドとの連携で構成されている。

それぞれエラーハンドリングの具合により2種類に分類される。

  • mvp

    • エラーハンドリングを行えない。
    • エラー発生で、意味不明なメッセージを残して死ぬ
    • frontend: @duckdb/duckdb-wasm/dist/duckdb-eh.wasm
    • backend: @duckdb/duckdb-wasm/dist/duckdb-browser-eh.worker.js
  • eh

    • エラーハンドリングを行なってくれる
    • try/catchでエラーメッセージを捕捉できる
    • frontend: @duckdb/duckdb-wasm/dist/duckdb-mvp.wasm
    • backend: @duckdb/duckdb-wasm/dist/duckdb-browser-mvp.worker.js

通常はehモジュールを使用しておけばいんじゃないかな?

インスタンス化

import * as duckdb from '@duckdb/duckdb-wasm'
import duckdb_wasm from '@duckdb/duckdb-wasm/dist/duckdb-eh.wasm?url'
import duckdb_worker from '@duckdb/duckdb-wasm/dist/duckdb-browser-eh.worker.js?worker'

let db: AsyncDuckDB | null = null

const initDb = async () => {
    if (db) {
        return db
    }

    const logger = new duckdb.ConsoleLogger()
    const worker = new duckdb_worker()

    db = new duckdb.AsyncDuckDB(logger, worker)
    await db.instantiate(duckdb_wasm)

    return db
}
ktz_aliasktz_alias

APIによるJSONデータの取り込み

AsyncDuckDBConnection.insertJSONFromPathを使用する。

第2引数のoptionsは以下の通り

  • columns
    • インポート対象フィールド
  • columnsFlat
  • create
    • trueの場合、テーブルの作成も行う
    • 上記以外は、insertのみを行う
  • name
    • 投入先のテーブル名
  • schema
    • 未指定の場合、main
  • shape
    • "column-object" - ネスト構造
    • "row-array" - フラット構造
    • 未指定の場合は、ファイルを読んで自動判定
    • 型定義がexportされてないような気が・・・

https://github.com/duckdb/duckdb-wasm/blob/58fcb9a46b73eac1abb9b0dee9d7c46d1a84f628/lib/src/webdb.cc#L616

enumの扱い

enumフィールドをJSONとして出力すると文字列にされる。
このデータをインポートしようとすると、文字列を数値に変換できないと怒られる。
それならと、数値化してエクスポートすると、今度は数値をenumに変換できないと怒られ八方塞がりとなる。

現状、enumフィールドを含むテーブルに対して、APIでのインポートはできなさそう・・・。

ktz_aliasktz_alias

SQLによるJSONの取り込み

以下のように、インポート元をプレースホルダにした場合、パースエラーにされる

copy <TABLE> from ?

また、インポート元は文字列リテラルのように見えるが、以下のように内部で組み立てようとしてもパースエラーになる。

copy <TABLE> from format('/path/to/{}', ?) /* ERROR ! */

プレースホルダを使いたい場合は、以下のように読み込み関数を経由する。

insert into <TABLE> select * from read_json_auto(?); /* OK ! */

関数経由でのインポートであれば、パスの整形も可能。

insert into <TABLE> select * from read_json_auto(format('/path/to/{}', ?)) /* OK ! */
insert into <TABLE> select * from read_json_auto('/path/to/' || ?); /* OK ! */

加えて、WASM版の制限なのか、gzip圧縮したファイルのインポートを行うとネットワークエラー扱いされる。CLI版は問題なくできるのに・・・謎い。

ktz_aliasktz_alias

プリペアードステートメントAPI

AsyncDuckDBConnection.prepareSQLを渡すことでプリペアードステートメントを構成できるが、複文で構成されたSQLはパースエラーとなる。(SQLは一つずつ渡してねと諭される)

複数のインポートを1SQLでやろうとして発覚。
これならAPI使っても大差ないような気が・・・。

enumフィールド再び

APIではenumフィールドの型変換を行えなかったが、insert selectを使用したインポートの場合、文字列からenumへの自動変換が働く。

enumをフィールドに持つテーブルは、SQLからの投入一択となる。

プリペアードステートメントに渡せる値

  • 数値、文字列、ブール、nullのみ
  • 配列(リスト)を渡すことはできない
    • CLI版では渡せるため、WASM版の制限

https://github.com/duckdb/duckdb-wasm/blob/ffa9210415e394fa84f0c769a54ef86b169bc2ab/lib/src/webdb.cc#L226

プリペアードステートメントに渡された値は、内部で一度JSON文字列に変換して引き渡す。
引き渡されたJSON文字列をパース後、値を適用している。
これはおそらく、数値とポインタしか値の連携が行えないWASMの制約に起因していそう。

またJSONにすることからも察せるように、BigIntを渡すとエラーになる。[1]

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#解説

脚注
  1. モンキーパッチについては未確認 ↩︎

ktz_aliasktz_alias

APIでSQLを発行した際の戻り値

AsyncDuckDBConnection.queryおよび、AsyncPreparedStatement.queryメソッドの戻り値は、apache-arrowパッケージのTable型。

https://arrow.apache.org/docs/js/classes/Arrow_dom.Table.html

selectの結果を取り出す方法はいくつかあるが、以下はその一例

import type { Table, StructRowProxy } from '@apache-arrow/ts'

// 変数dbはduckDbのインスタンスを想定している
const conn = await db.connect()
// SQLの発行
const results: Table = await conn.query("select a, b, c from T")

// StructRowProxyは結果セットの1レコードをJavascriptのオブジェクトのように扱えるプロキシ
// 具体的には、 [column: string]: any のように扱える型
const rows: StructRowProxy<any>[] = result.toArray()
const entities = rows.map(row => {
    return {
        a: row.a,
        b: row.b,
        c: row.c,
    }
}

なお、queryメソッドは、すべての結果を一度に返してるっぽくて、メモリ負荷が高いかも。
こまめにhydrateするなら、sendメソッドを使ったほうが良さそう。
sendメソッドの戻り値は、RecordBatchReaderの非同期版であるAsyncRecordBatchReader

https://arrow.apache.org/docs/js/classes/Arrow_dom.RecordBatchReader.html

RecordBatchReaderは反復可能オブジェクト
イテレータ要素のRecordBatch型はなぜかドキュメントに記載がなくて謎い(ver 6.0まではかろうじて記載が残ってた)。

RecordBatchから先はTable型の中でやってることを実演すれば良さそう。

https://github.com/apache/arrow/blob/e03105e/js/src/table.ts#L220

ktz_aliasktz_alias

github.io へのデプロイ

  • ローカルでは問題なく動くが、github.io へデプロイすると、4kBのJSONファイルでも読み込みでエラーになった。
    • Apache Parquetフォーマットにしたら、エラーなく実行できた。
ktz_aliasktz_alias

AsyncDuckDBのregisterFileBufferの使い方

httpfs機能拡張を介した外部のparquetファイルの読み込みを行うと、Content-Rangeによる分割ロードが行われる。

https://duckdb.org/docs/data/parquet/overview#partial-reading

この機能は早期にフィルタを行い余計なデータを読み込まなくするFilter pushdownによるものだが、全件ロードするケースでは通信回数の増加により逆に遅くなる。

AsyncDuckDB.registerFileBufferを使った回避策が見つかったので記録として残す。

const res = await fetch('https://example.com/some_table.parquet')
const buffer = await res.arrayBuffer()

const wasm_url: string = ...
const worker_url: string = ...
const pthread: string = ...

const worker = new Worker(worker_url)
const db = new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(), worker)
await db.instanciate(wasm_url, pthread)
// ファイルを作成
await db.registerFileBuffer('some_table', new Uint8Array(buffer))

const conn = await db.connect()
// 登録したファイル名と同じファイル名を指定
await conn.query("copy t1 from 'some_table'")
// ファイルを破棄
await db.dropFile('some_table')