Wasm版DuckDBのクライアントAPI調査
データベースのインスタンス化
モジュールの種類
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
}
APIによるJSONデータの取り込み
AsyncDuckDBConnection.insertJSONFromPath
を使用する。
第2引数のoptions
は以下の通り
- columns
- インポート対象フィールド
- columnsFlat
-
columns
が指定された場合、columns
の内容をもとに再構成される。 - 最終的に、
columns
に戻される - 明示的に指定する必要はなさそう
- https://github.com/duckdb/duckdb-wasm/blob/58fcb9a46b73eac1abb9b0dee9d7c46d1a84f628/packages/duckdb-wasm/src/bindings/bindings_base.ts#L374
-
- create
-
true
の場合、テーブルの作成も行う - 上記以外は、
insert
のみを行う
-
- name
- 投入先のテーブル名
- schema
- 未指定の場合、
main
- 未指定の場合、
- shape
- "column-object" - ネスト構造
- "row-array" - フラット構造
- 未指定の場合は、ファイルを読んで自動判定
- 型定義がexportされてないような気が・・・
enumの扱い
enumフィールドをJSONとして出力すると文字列にされる。
このデータをインポートしようとすると、文字列を数値に変換できないと怒られる。
それならと、数値化してエクスポートすると、今度は数値をenumに変換できないと怒られ八方塞がりとなる。
現状、enumフィールドを含むテーブルに対して、APIでのインポートはできなさそう・・・。
JSONとしてエクスポートする
copy <TABLE> to '<FILE PATH>' と入力すると、1行1オブジェクトとして、行数分出力される。
{ "a":1, "b":2}
{"a":4, "b":8}
配列で出す場合は、以下のように入力する。
copy <TABLE> to '<FILE PATH>' (array true)
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
版は問題なくできるのに・・・謎い。
プリペアードステートメントAPI
AsyncDuckDBConnection.prepare
にSQL
を渡すことでプリペアードステートメントを構成できるが、複文で構成されたSQL
はパースエラーとなる。(SQL
は一つずつ渡してねと諭される)
複数のインポートを1SQLでやろうとして発覚。
これならAPI使っても大差ないような気が・・・。
enumフィールド再び
APIではenumフィールドの型変換を行えなかったが、insert selectを使用したインポートの場合、文字列からenumへの自動変換が働く。
enumをフィールドに持つテーブルは、SQLからの投入一択となる。
プリペアードステートメントに渡せる値
- 数値、文字列、ブール、nullのみ
- 配列(リスト)を渡すことはできない
- CLI版では渡せるため、WASM版の制限
プリペアードステートメントに渡された値は、内部で一度JSON
文字列に変換して引き渡す。
引き渡されたJSON
文字列をパース後、値を適用している。
これはおそらく、数値とポインタしか値の連携が行えないWASM
の制約に起因していそう。
またJSON
にすることからも察せるように、BigInt
を渡すとエラーになる。[1]
-
モンキーパッチについては未確認 ↩︎
APIでSQLを発行した際の戻り値
AsyncDuckDBConnection.query
および、AsyncPreparedStatement.query
メソッドの戻り値は、apache-arrow
パッケージのTable
型。
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
RecordBatchReader
は反復可能オブジェクト
イテレータ要素のRecordBatch
型はなぜかドキュメントに記載がなくて謎い(ver 6.0まではかろうじて記載が残ってた)。
RecordBatch
から先はTable
型の中でやってることを実演すれば良さそう。
github.io へのデプロイ
- ローカルでは問題なく動くが、github.io へデプロイすると、4kBの
JSON
ファイルでも読み込みでエラーになった。-
Apache Parquet
フォーマットにしたら、エラーなく実行できた。
-
AsyncDuckDBのregisterFileBufferの使い方
httpfs
機能拡張を介した外部のparquet
ファイルの読み込みを行うと、Content-Range
による分割ロードが行われる。
この機能は早期にフィルタを行い余計なデータを読み込まなくする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')