🐥

NimでDuckDBのClient APIを勝手に作ってみた

2024/08/03に公開

皆さん、DuckDBって、ご存じでしょうか?
DuckDBはOLAP(オンライン分散処理)分析に特化したデータベースだそうです。
2024年6月に、DuckDBはバージョン1.0.0が正式に公開され、軽量・高速であり安定したデーターベースとして注目されています。
私も3日前に、たまたまYoutubeの動画を視聴して知りました。(笑)
今回はNimとは関係なく、高速なDuckDBについて記載していきます。
DuckDBについては、下記の記事が詳細に記載されています。

はじめは、Sqlite3と同様かな?って思ってましたが、Sqlite3に較べて数倍高速で
csvファイルを登録しなくとも、その場でSQL文が書けてしまう優れ物でもあり、
且つ、驚くべき事に、AWS S3やDataFrameへの連携も可能と言う、とんでもない程、優れています。
また、JavaやRustなどの多数の言語のAPIが揃っており、APIがあるなら、Nim言語でも書いてみようかなっと思ってDuckDBの公式HPでサポート言語を見た訳ですよ!

そうなんですよ。Nim言語はサポート外だった訳ですよ!。そらね、超マイナーな言語かもしれませんが、Pythonより動作が速く、Rustより分かりやすい言語がサポート外とかふざけてますよね。
色々調べた結果、有志の方がNimでDuckDBのラッパーAPIを作られていました。
https://github.com/ayman-albaz/nim-duckdb

ただ、最新のVersion1.0.0じゃなく、0.10.0のバージョンのサポートだったし、C言語の構造体がわからないのか、多数の訳のわからないツールがインポートされるので、これは使えないと思い、それなら、ソースを見て勝手に改変して作ってみようと思った次第です。

(良い子のみんなはマネしちゃダメですよ!)

※ちなみに、非同期処理とか何も考えずに1日で作っちゃってますので、ご容赦願います。

環境

私のPC環境はWindowsで動作させています。

  • OS: Windows11
  • Nim 2.0.8
  • DuckDB 1.0.0

DuckDBのインストールとツールの設定

1. DuckDB.exeのダウンロード

DuckDB公式のダウンロードHPに移動して、各OSのダウンロードモジュールをダウンロードします。
※Windows環境であれば、duckdb_cli-windows-amd64.zipをダウンロードします。
ZIPファイルを解凍すると、duckdb.exeの実行モジュールのみしか入っていません。
それをC:\Windows配下にコピーします。

2. DuckDB.exeの確認

DOSプロンプトを開き、duckdbと入力します。問題なくDuckDBの画面が表示されれば、問題なくインストール完了した事になります。
ちなみに、終了はCtrl+Cで通常のプロンプト画面に戻ります。

3. DuckDBのC言語Client APIをダウンロード

DuckDB公式のダウンロードHPで、Environment:C/C++を押して、各自のOS毎のC言語Client APIがダウンロードできます。
※Windows環境であれば、libduckdb-windows-amd64.zipをダウンロードします。

4. DuckDB用プロジェクトの作成

VSCodeを開き、Nim用のプロジェクトを作成します。
プロジェクト直下に、duckdbフォルダを作成し、先ほどのlibduckdb-windows-amd64.zipを解凍して入れます。
解凍したモジュール「duckdb.dllduckdb.hduckdb.hppduckdb.lib」の4つのファイルを、duckdbフォルダに移動します。
更に、duckdbフォルダに、duckdb.nimファイルを新規に作成します。
duckdb.nimファイルの中身を、以下に記載します。
※GitHubへソースを上げれれば良かったのですが、超ド素人なので、GitHubへの登録さえ出来ません。(笑)
 また、duckdb/duckdb.nimの1行目は、自分個人用としてWindowsのDLLファイルだけ指定しています。もしお使いのOSがMacやLinuxなら、それようのライブラリを指定してください。

duckdb/duckdb.nim
const Lib = "duckdb/duckdb.dll" # windows特化

const DuckDBSuccess = 0
const DuckDBError = 1

type
  Db = distinct pointer  ## Generic database pointer.
  Con = distinct pointer  ## Generic connection pointer.
  DuckDBRow* = seq[string]
  DuckDBOperationError* = object of CatchableError

# DuckDB側のC言語構造体
type
  DuckDBConn* = ref object
    database: Db
    connection: Con
  DuckDBColum* = object
    deprecated_data: pointer
    deprecated_nullmask: pointer
    deprecated_type: cint   # duckdb_type
    deprecated_name: cstring
    internal_data: pointer
  DuckDBResult* = object
    deprecated_column_count: int
    deprecated_row_count: int
    deprecated_rows_changed: int
    deprecated_columns: ptr DuckDBColum
    deprecated_error_message: cstring
    internal_data: pointer
  DuckDBPreparedStatement* = pointer

# DuckDBのC言語API 必要と思う物のみを抽出
{.push importc, cdecl, dynlib: Lib.}

proc duckdb_open(path: cstring, database: ptr Db): int
proc duckdb_close(database: ptr Db)
proc duckdb_connect(database: Db, connection: ptr Con): int
proc duckdb_disconnect(connection: ptr Con)
proc duckdb_query(connection: Con, query: cstring, result: ptr DuckDBResult): int
proc duckdb_value_varchar(result: ptr DuckDBResult, col: int, row: int): cstring
proc duckdb_free(v: pointer): void
proc duckdb_column_count(result: DuckDBResult): int
proc duckdb_row_count(result: DuckDBResult): int
proc duckdb_prepare(connection: Con, query: cstring, statement: ptr DuckDBPreparedStatement): int
proc duckdb_bind_varchar(statement: DuckDBPreparedStatement, param_idx: int, val: cstring): int
proc duckdb_execute_prepared(statement: DuckDBPreparedStatement, result: ptr DuckDBResult): int
proc duckdb_result_error(result: ptr DuckDBResult): cstring
proc duckdb_prepare_error(statement: DuckDBPreparedStatement): cstring
proc duckdb_destroy_result(result: ptr DuckDBResult)
proc duckdb_destroy_prepare(statement: ptr DuckDBPreparedStatement)

{.pop.}

# エラーチェック
proc checkStatus(status: int) =
  if status != DuckDBSuccess :
    raise newException(DuckDBOperationError, "DuckDB operation did not complete sucessfully.")

proc checkStatus(status: int, resultParam: DuckDBResult) =
  if status != DuckDBSuccess :
    let msg = duckdb_result_error(resultParam.addr)
    raise newException(DuckDBOperationError, "DuckDB operation did not complete sucessfully. Reason:\n" & $msg)

proc checkStatus(status: int, statement: DuckDBPreparedStatement) =
  if status != DuckDBSuccess :
    let msg = duckdb_prepare_error(statement)
    raise newException(DuckDBOperationError, "DuckDB operation did not complete sucessfully. Reason:\n" & $msg)

# 接続処理
proc connect*(path: string): DuckDBConn =
  result = new DuckDBConn
  duckdb_open(path.cstring, result.database.addr).checkStatus()
  duckdb_connect(result.database, result.connection.addr).checkStatus()

# 接続処理(in-memory)
proc connect*(): DuckDBConn =
  result = connect(":memory:")

# クローズ処理
proc close*(conn: DuckDBConn) =
  duckdb_close(conn.database.addr)

# 切断処理
proc disconnect*(conn: DuckDBConn) =
  duckdb_disconnect(conn.connection.addr)

iterator getRows(resultParam: DuckDBResult): DuckDBRow =
  var clmCnt = duckdb_column_count(resultParam)
  var rowCnt = duckdb_row_count(resultParam)
  var duckDBRow = newSeq[string](clmCnt)
  var v: cstring
  for row in 0 ..< rowCnt:
    for clm in 0 ..< clmCnt:
      v = duckdb_value_varchar(resultParam.addr, clm, row)
      duckDBRow[clm] = (
        if v.isNil():
          "NULL"
        else:
          $v
      )
      duckdb_free(v)
    yield duckDBRow

# 行毎にSelect処理
iterator rows*(conn: DuckDBConn, sql: string, args: varargs[string,`$`]): DuckDBRow =
  var resultParam: DuckDBResult
  var statement: DuckDBPreparedStatement
  defer:
    if args.len() != 0:
      duckdb_destroy_prepare(statement.addr)
    duckdb_destroy_result(resultParam.addr)

  if args.len() == 0:
    duckdb_query(conn.connection, sql.cstring, resultParam.addr).checkStatus(resultParam)
  else:
    duckdb_prepare(conn.connection, sql.cstring, statement.addr).checkStatus(statement)

    for i, arg in args:
      duckdb_bind_varchar(statement, i+1, arg.cstring).checkStatus()
    duckdb_execute_prepared(statement, resultParam.addr).checkStatus(resultParam)

  for duckDBRow in getRows(resultParam):
    yield duckDBRow

# 実行処理
proc exec*(conn: DuckDBConn, sql: string) =
  var resultParam: DuckDBResult
  duckdb_query(conn.connection, sql.cstring, resultParam.addr).checkStatus(resultParam)
  duckdb_destroy_result(resultParam.addr)

次に、呼び出し側のメイン部分のソースを作成します。
プロジェクトフォルダ直下に、sample.nimファイルを作成し、下記のように記載します。

sample.nim
import duckdb/duckdb

var con: DuckDBConn
try:
  con = connect("mydb.db")
  con.exec("CREATE TABLE IF NOT EXISTS integers(i INTEGER, j INTEGER);")
  con.exec("INSERT INTO integers VALUES (3, 4), (5, 6), (7, NULL);")
  for item in con.rows("SELECT * FROM integers WHERE i = ? or j = ?", 3, "6"):
    echo item
except:
  echo getCurrentExceptionMsg()
finally:
  con.disconnect()
  con.close()

メインソースをコンパイル実行します。
メインソースは、integersテーブルが存在しないなら作成し、データを3レコード登録し、i項目が3とj項目が6のレコードを抽出するだけのプログラムです。
結果はシーケンステーブルとして、表示されて出力されるはずです。
また、プロジェクト直下にmydb.dbファイルが作成されているはずです。
mydb.dbファイルがデーターベースとなります。

vscodeで実行と結果の表示
$ nim c .\sample.nim
$ .\sample.exe

@["3", "4"]
@["5", "6"]

次に、コマンドプロンプト側でduckdbを操作します。
duckdb mydb.dbと入力して、以下のような表示なっているか確認します。

duckdbを起動
$ duckdb mydb.db
D .tables
integers
D select * from integers;
┌───────┬───────┐
│   i   │   j   │
│ int32 │ int32 │
├───────┼───────┤
│     34 │
│     56 │
│     7 │       │
└───────┴───────┘
D

おわりに

DuckDBのラッパーを作ったのには、理由がありまして、有志の方が作成されたラッパーだと抽出したデータのメモリエリアを解放していないため、メモリリークの原因になる事がわかって作ってみた次第です。
それに、Nim言語だけAPIがないのも解せない話です。
出来たら、Nim用のDataFrameであるDatamancerへの抽出・登録なども作ろうかと思いましたが、面倒でした。

注意:
高速に登録するAppenderや、高速に読み込みを行うDataChunksやVectorは今回はサポートしていません。
「NimでDuckDB用のインポート・エキスポートAPIを追加してみた」にて、高速に登録するAPIは追加しました。)
また、DuckDBのC APIのサンプルを見返すと、SQL処理結果をクリーンアップする部分も抜けているみたいです。どうするか検討しておきます。
(修正済み、1万レコードの読み書きを行っても、メモリ増加は見られなかったです。)

Discussion