Zenn

DuckDB Local UI みたいな拡張を Rust で書いてみようとしたときのメモ(v1.2.1 時点)

2025/03/20に公開
1

え、DuckDB の拡張って Rust で書けんの??、という人は、以下の記事に状況をまとめたのでそっちを読んでください。ここではもっと細かい話をします。

https://zenn.dev/yutannihilation/articles/663c879b74343c

DuckDB Local UI

MotherDuck の UI みたいなやつが表示できる拡張です。CALL start_ui() とやると、ウェブブラウザが立ち上がってブラウザ上からいろいろできるようになります。

https://duckdb.org/2025/03/12/duckdb-ui.html

私がこれを見て思ったのは、「え、拡張ってこんなことできるの??」ということでした。上にリンクを貼ったように、DuckDB の拡張というのは、

  • table function: テーブルを返す関数(例:read_parquet()
  • scalar function: 値を受け取って同じ長さの値を返す関数(例:sqrt()
  • aggregate function: 値を受け取って1つに集約した値を返す関数(例:sum()
  • replacement scan: table function を自動で使えるようにする仕組み

というようなもので、こんな飛び道具的な拡張が書けるとは思ってもいませんでした。おもしろそうなので自分でも何か作ってみたい、と思って調べたことをメモります。

拡張のタイプ

DuckDB Local UI の start_ui() といった関数は、table function として実装されています。目的は UI のサーバーを起動するという副作用ですが、形式としてはテーブルを返しています。ということで、自分で作るのもこれに従ってみることにします。

https://github.com/duckdb/duckdb-ui/blob/25ff9b6fa0f37853b92e9f5a7ea517bf78656f3f/src/ui_extension.cpp#L123-L132

ブラウザを立ち上げる

duckdb-ui の実装を読んでみる

まず、duckdb の中からブラウザを立ち上げるのどうやるんだろう、と思ったら、ふつうにコマンドを実行してるだけでした。

https://github.com/duckdb/duckdb-ui/blob/25ff9b6fa0f37853b92e9f5a7ea517bf78656f3f/src/ui_extension.cpp#L14-L20
https://github.com/duckdb/duckdb-ui/blob/25ff9b6fa0f37853b92e9f5a7ea517bf78656f3f/src/ui_extension.cpp#L33-L37

Rust で実装する

Rust の場合は、std::process::Command が Windows だとうまく動かないらしく、Command::new("start") だと command not found になるんですが、 cmd を介して立ち上げるようにするとうまくいきました。それ以外は特筆すべき点は特になさそうです。

use std::process::Command;

#[cfg(target_os = "windows")]
fn open_browser(url: &str) {
    // It seems new("start").arg(url) doesn't work...

    Command::new("cmd")
        .args(["/c", "start", url])
        .spawn()
        .unwrap();
}
#[cfg(target_os = "macos")]
fn open_browser(url: &str) {
    Command::new("open").arg(url).spawn().unwrap();
}
#[cfg(target_os = "linux")]
fn open_browser(url: &str) {
    Command::new("xdg-open").arg(url).spawn().unwrap();
}

サーバーを立ち上げる

duckdb-ui の実装を読んでみる

httplib という header-only のシンプルな実装の HTTP サーバーを使っているようです。

https://github.com/yhirose/cpp-httplib

Rust で実装する

HTTP サーバーは何でもいいと思いますが、私は warp を使ってみました。#[tokio::main] じゃなくて、自分で tokio::runtime::Runtime を作って使うところが少し情報が少ないところかもしれません。私もあんまりわかってませんが、とりあえずこれで動きました。

(注:このままだと、関数を実行するたびにサーバーが重複して起動してしまいますが、そのあたりの考慮はとりあえず抜きにしています)

use tokio::runtime::Runtime;
use warp::Filter;

static RUNTIME: LazyLock<Runtime> = LazyLock::new(|| Runtime::new().unwrap());

struct HelloVTab;

impl VTab for HelloVTab {
    ...
    fn init(_: &InitInfo) -> Result<Self::InitData, Box<dyn std::error::Error>> {
        let get = warp::get().map(|| {
            warp::reply::html("ここに HTML が入る")
        });

        RUNTIME.spawn(async move { warp::serve(get).run(([127, 0, 0, 1], 3030)).await });

        // ほんとはサーバーの準備ができるまで待ってからの方がいいのかも
        open_browser("http://127.0.0.1:3030");

        Ok(HelloInitData { ... })
    }
    ...

拡張の側から DuckDB を操作する

私が DuckDB Local UI を見て一番びっくりしたところはここで、それまで拡張というのは受動的なもの(値を受け取って返す、みたいな感じ)だというイメージだったので、拡張の側から DuckDB 内のデータを覗いたりクエリを実行したりできるのはどういう仕掛けなんだろう?と調べてみました。

duckdb-ui の実装を読んでみる

うまく追いきれなかったのですが、start_ui() の中身の関数は ClientContext というオブジェクトを受け取っています。

https://github.com/duckdb/duckdb-ui/blob/25ff9b6fa0f37853b92e9f5a7ea517bf78656f3f/src/ui_extension.cpp#L24

このオブジェクトは、いろいろな情報を保持しています。特に db に注目です。

https://github.com/duckdb/duckdb/blob/1d19381c1383366f5c32160d6836e556ccc82ab8/src/include/duckdb/main/client_context.hpp#L63-L90

HttpServer は、この ClientContext から db を取り出して内部に保持していて、これに対してクエリを投げたりしているようでした。

https://github.com/duckdb/duckdb-ui/blob/25ff9b6fa0f37853b92e9f5a7ea517bf78656f3f/src/ui_extension.cpp#L30

https://github.com/duckdb/duckdb-ui/blob/25ff9b6fa0f37853b92e9f5a7ea517bf78656f3f/src/http_server.cpp#L22-L31

Rust で実装する

さて、では Rust でこれをどう実装するかというと...

どうも、いい方法がなさそうです。table function を実装するための trait(VTab)は以下のようになっていますが、見るとわかるように、 ClientContext に類するようなものは存在しません。そもそもインターフェースが違うみたいです。

pub trait VTab: Sized {
    type InitData: Sized + Send + Sync;
    type BindData: Sized + Send + Sync;

    // Required methods
    fn bind(bind: &BindInfo) -> Result<Self::BindData, Box<dyn Error>>;
    fn init(init: &InitInfo) -> Result<Self::InitData, Box<dyn Error>>;
    fn func(
        func: &TableFunctionInfo<Self>,
        output: &mut DataChunkHandle,
    ) -> Result<(), Box<dyn Error>>;

    // Provided methods
    fn supports_pushdown() -> bool { ... }
    fn parameters() -> Option<Vec<LogicalTypeHandle>> { ... }
    fn named_parameters() -> Option<Vec<(String, LogicalTypeHandle)>> { ... }
}

いちおう、↑の関数をそのセッションに読み込むための関数があって、そこでは Connection があるので、それを持っておいて使うことはできそうです。ただ、この拡張が読み込まれた後に DB が切り替わると何が起こるかとか、これが安全な方法なのかはいまいち自信が持てません。。まあ、これで動いてはいます。

static CONN: OnceLock<Mutex<Connection>> = OnceLock::new();

#[duckdb_entrypoint_c_api()]
pub unsafe fn extension_entrypoint(con: Connection) -> Result<(), Box<dyn Error>> {
    con.register_table_function::<HelloVTab>(EXTENSION_NAME)
        .expect("Failed to register hello table function");

    let _ = CONN.set(Mutex::new(con.try_clone()?));

    Ok(())
}

ちなみに、Connection はこんな感じで使えます。.pragma_query()PRAGMAを実行するためのもので、通常の SELECT 文などは query_row() で実行できます。

let mut tables = vec![];
let guard = CONN.get().unwrap().lock().unwrap();
guard
    .pragma_query(None, "show_tables", |row| {
        let t: String = row.get(0)?;
        tables.push(format!("<li>{t}</li>"));
        Ok(())
    })
    .unwrap();

感想

ということで何の成果も得られませんでした。もうちょっと仕組みが整ってくれないとなんともなあ...という感じなので、ここで力尽きるかもしれませんが、いちおう作りかけのコードは以下のレポジトリにあります。

https://github.com/yutannihilation/duckdb-ext-simple-http-server

1

Discussion

ログインするとコメントできます