🤠

pgAdmin を開くのが面倒だからRustでPostgres閲覧TUIアプリ作った話

に公開

みなさんDBの中を閲覧するためにpgAdminやDbVisualizerを都度開いていませんか?
ガッツリ確認するなら良いけどさらっと確認したい時にアプリの立ち上げを待つのって面倒ですよね(私だけかもしれませんが…)

タイトルにあるように今回私が開発中のTUIアプリは、手元のターミナルから直接 Postgres の状態を閲覧できるツールです。
pgAdminなどを立ち上げるほどでもない時など素早くテーブルを確認したいときに役立つよう、Rust を選び、描画は ratatui、データ取得はsqlxを採用しました。
この記事では今開発中のプログラムを一部抜粋しながら、どのように作ったのかを振り返ります。

ちなみにこのTUIアプリは「dbtui」と名付けました。
(検索したらよく分からんツール出てきたけど一旦これで名付け)

作ったもの

ターミナルから起動します。

指定したデータベースからテーブル一覧を表示して、そこから更にテーブルの詳細を表示、
右キーを押すと右に移動するだけのシンプルツール。

dbtui の全体像

アプリは「構成を読み込む → DB に接続 → 端末を TUI モードに切り替え → イベントループを回す」という至ってシンプルな流れで動きます。
まずはmain.rsの抜粋です。

// src/main.rs
#[tokio::main]
async fn main() -> Result<()> {
    dotenvy::dotenv().ok();
    let config = Config::from_env()?;
    let db = Database::connect(&config.database_url).await?;

    let mut terminal = setup_terminal()?;
    let mut app = App::new(config, db);

    let result = run_app(&mut terminal, &mut app).await;
    restore_terminal(&mut terminal)?;
    result
}

.env を読み、ConfigDatabase を初期化し、setup_terminalcrosstermの代替スクリーンに入る、という流れです。

TUI アプリは異常終了すると画面モードが壊れたままになることがあります。
そのためresultを一旦受け取ったあと、必ず最後に restore_terminalを呼ぶ設計にしています。

tokio と ratatui をつなぐイベントループ

run_appではratatuiの描画とtokioの非同期イベントを同時に扱います。

  • キー入力 → EventStream
  • 定期更新 → tokio::time::interval(200ms)
async fn run_app(terminal: &mut Term, app: &mut App) -> Result<()> {
    let mut events = EventStream::new();
    let mut ticker = tokio::time::interval(Duration::from_millis(200));

    loop {
        terminal.draw(|frame| ui::draw(frame, app))?;

        tokio::select! {
            _ = ticker.tick() => {
                if app.should_refresh() {
                    if let Err(err) = app.refresh().await {
                        app.set_error(err);
                    }
                }
            }
            maybe_event = events.next() => {
                // キー入力やリサイズを判定
            }
        }
    }
}

terminal.drawはratatuiの描画クロージャで、実際の処理はui::drawに任せています。
一方でtokio::select!の中ではキー入力の処理や設定変更の適用を行います。

描画と非同期処理を干渉させないため、

  • 描画は同期
  • I/O は App 側に閉じ込める
    という役割分担にしています。

Config モジュールの役割

src/config.rs.envおよび環境変数の読み込みと正規化を担当します。
クエリや更新間隔は妥当な初期値を決めてあり、ゼロや空文字を max(1) で矯正するのもここで済ませます。

// src/config.rs
pub fn from_env() -> Result<Self> {
    let database_url = env::var("DATABASE_URL")
        .or_else(|_| env::var("DBTUI_DATABASE_URL"))
        .context("環境変数 DATABASE_URL が設定されていません")?;

    let query = env::var("DBTUI_QUERY").unwrap_or_else(|_| {
        "SELECT table_name AS name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name"
            .to_string()
    });

    let refresh_secs = env::var("DBTUI_REFRESH_SECS")
        .ok()
        .and_then(|value| value.parse::<u64>().ok())
        .unwrap_or(5);

    let detail_limit = env::var("DBTUI_DETAIL_LIMIT")
        .ok()
        .and_then(|value| value.parse::<usize>().ok())
        .unwrap_or(100)
        .max(1);

    Ok(Self::new(database_url, query, refresh_secs, detail_limit))
}

フォームから設定を上書きしても、Config::new で秒数と件数に再度 max をかけておくので、アプリ側は「常に 1 以上の値が来る」と安心して扱ルト思います。

sqlx でデータ取得

src/data.rsではsqlxのPgPoolを使い、任意のクエリを投げた結果を画面表示用のTableSnapshotに整形します。

// src/data.rs
pub async fn fetch(&self, query: &str) -> Result<TableSnapshot> {
    let rows = sqlx::query(query)
        .fetch_all(&self.pool)
        .await
        .with_context(|| format!("クエリの実行に失敗しました: {query}"))?;

    let columns: Vec<String> = match rows.first() {
        Some(row) => row.columns().iter().map(|c| c.name().to_string()).collect(),
        None => Vec::new(),
    };

    let formatted_rows: Vec<Vec<String>> = rows
        .iter()
        .map(|row| format_row(row, columns.len()))
        .collect::<Result<_>>()?;

    Ok(TableSnapshot {
        columns,
        rows: formatted_rows,
        fetched_at: Local::now(),
    })
}

sqlxのRowから列名を抜き出し、各セルを format_cell で文字列にしてからratatuiに渡します。
ratatuiの処理が楽になるかと思います。

型変換

Postgres は型が豊富なので、format_cell では型ごとにフォーマットしています。
ポイントは

  • NULL判定
  • 整数や浮動小数、文字列、UUIDなどの個別処理
fn format_cell(row: &PgRow, idx: usize) -> Result<String> {
    if row.try_get_raw(idx)?.is_null() {
        return Ok("NULL".to_string());
    }

    let column = row.column(idx);
    let type_name = column.type_info().name().to_ascii_uppercase();

    let value = match type_name.as_str() {
        "BOOL" => row.try_get::<bool, _>(idx)?.to_string(),
        "INT2" => row.try_get::<i16, _>(idx)?.to_string(),
        "INT4" | "OID" => row.try_get::<i32, _>(idx)?.to_string(),
        "INT8" => row.try_get::<i64, _>(idx)?.to_string(),
        "FLOAT4" => row.try_get::<f32, _>(idx)?.to_string(),
        "FLOAT8" => row.try_get::<f64, _>(idx)?.to_string(),
        "NUMERIC" | "MONEY" => row.try_get::<String, _>(idx)?,
        "UUID" | "TEXT" | "VARCHAR" | "CHAR" | "BPCHAR" | "NAME" => row.try_get::<String, _>(idx)?,
        "DATE" => row.try_get::<NaiveDate, _>(idx)?.to_string(),
        "TIME" => row.try_get::<NaiveTime, _>(idx)?.format("%H:%M:%S%.f").to_string(),
        "TIMESTAMP" => row.try_get::<NaiveDateTime, _>(idx)?.format("%Y-%m-%d %H:%M:%S").to_string(),
        "TIMESTAMPTZ" => row.try_get::<DateTime<Local>, _>(idx)?.format("%Y-%m-%d %H:%M:%S%:z").to_string(),
        "JSON" | "JSONB" => {
            let value: Value = row.try_get(idx)?;
            value.to_string()
        }
        "BYTEA" => {
            let bytes = row.try_get::<Vec<u8>, _>(idx)?;
            format!("0x{}", hex::encode(bytes))
        }
        _ => row.try_get::<String, _>(idx).unwrap_or_else(|_| format!("<{type_name}>")),
    };

    Ok(value)
}

ここをしっかり書いておけば、UI 側では文字幅の計算とレイアウト調整だけに集中できます。JSONやBYTEAも文字化していると、監視中に型エラーを踏んでアプリが止まる心配がないと思います。

App 構造体の責務

src/app.rsApp 構造体は状態管理を担います。
モード、選択行、列スクロール、スナップショット、設定フォーム、ステータスなどを詰め込んでいます。
refreshrefresh_detail_if_neededはsqlx経由でデータを取り、必要に応じてフラグを立て直します。

pub async fn refresh(&mut self) -> Result<()> {
    let snapshot = self.db.fetch(&self.config.query).await?;
    let row_count = snapshot.rows.len();
    self.list_snapshot = Some(snapshot);
    self.detail_snapshot = None;
    self.force_refresh = false;
    self.last_refresh = Some(Instant::now());
    if row_count == 0 {
        self.selected_row = 0;
    } else {
        self.selected_row = self.selected_row.min(row_count.saturating_sub(1));
    }
    self.detail_dirty = true;
    self.refresh_detail_if_needed().await?;
    Ok(())
}

AppModeConfigEditorのときはキー入力の扱いを切り替え、閲覧中は gや矢印キーで行移動、rで再読み込みなどの処理します。selected_table_targetでスキーマとテーブル名を抽出し、安全なSQLを組み立ててれるように心がけました。
MAXMINを使って選択位置や列のオフセットを有効範囲内に保っているので、ビューポート外を参照してpanicすることは予防しました。

ratatui で描面

src/ui.rs が画面描画の所です。
draw関数では縦三分割のレイアウトを作り、上段をさらに割って「テーブル一覧」と「テーブル詳細」を配置しています。

pub fn draw(f: &mut Frame<'_>, app: &App) {
    let layout = Layout::default()
        .direction(Direction::Vertical)
        .margin(1)
        .constraints([
            Constraint::Min(12),
            Constraint::Length(3),
            Constraint::Length(2),
        ])
        .split(f.size());

    let table_chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Percentage(45), Constraint::Percentage(55)].as_ref())
        .split(layout[0]);

    draw_table_list(f, table_chunks[0], app);
    draw_table_detail(f, table_chunks[1], app);
    draw_status(f, layout[1], app);
    draw_help(f, layout[2]);

    if matches!(app.mode(), AppMode::ConfigEditor) {
        draw_config_editor(f, app);
    }
}

ratatui のLayoutBlockをシンプルに使い、描画関数は App の参照を読むだけで状態を変えません。UI をイベントハンドラから切り離しているので、描画だけを読むと構成がわかりやすくなったと思います。

テーブル表示の実装

一覧・詳細を共通化しているのがrender_snapshotです。
TableウィジェットとTableStateを組み合わせて縦スクロールはratatuiに任せ、横スクロールは column_offset を使って手動で管理します。

fn render_snapshot(
    f: &mut Frame<'_>,
    area: Rect,
    snapshot: Option<&TableSnapshot>,
    title: &str,
    empty_message: &str,
    highlight_row: Option<usize>,
    column_offset: usize,
) {
    let block = Block::default().title(title).borders(Borders::ALL);

    match snapshot {
        Some(snapshot) if snapshot.columns.is_empty() => {
            let paragraph = Paragraph::new("結果セットに列がありません")
                .block(block)
                .alignment(Alignment::Center);
            f.render_widget(paragraph, area);
        }
        Some(snapshot) => {
            // 省略: visible_columns と display_lengths を計算して Table を描画
        }
        None => {
            let paragraph = Paragraph::new(empty_message)
                .block(block)
                .alignment(Alignment::Center);
            f.render_widget(paragraph, area);
        }
    }
}

列ごとにtext_widthで表示幅を測ってConstraint::Ratioで割り当てているため、全角文字混じりのテーブルでも崩れません。

実行手順と日常的な使い方

実行方法と使い方

  1. .envにDATABASE_URL を書く
  2. 'cargo run'するだけ(いつかコマンドにしたい)
  3. 数百msで一覧表示

主な操作キー

  • ↑↓:行移動
  • ←→:横スクロール
  • r:更新
  • c:設定パネル(ここはセンスないかもが接続するDBなど切り替え)

tmux で stderr を tail -f しておくとエラーが追いやすいです。

パフォーマンスと安定性

  • 200ms interval × sqlx でも破綻しない
  • UI は文字列だけ扱うので軽い
  • 設定変更時にPgPoolを再生成

今後の拡張に向けて

現状でも個人的に満足していますが、改善案はいくつか温めています。

  • sqlxのquery_as!を併用して型安全な定義済みクエリを差し込む
  • ratatuiのSparklineでリフレッシュ時間をグラフ化する
  • TableTargetを拡張して複合キーのテーブルでも詳細取得できるようにする
    などなど…
    他にもApp をマルチビュー対応にし、同じプロセスで複数クエリを並列に監視できるようにするのも面白そうです。いずれにせよ、sqlx で値を確実に取得し、ratatui で分かりやすく描画するという基本方針は変えずに育てていくつもりです。

まとめ

dbtui(地味に名前気に入っている)は私が欲しかった「すぐ開けて、すぐ閉じられるDB閲覧ツール」を Rust × sqlx × ratatui で形にしたものです。

  • 型意識したフォーマット
  • Appの緻密な状態管理
  • ratatuiの読みやすいUI

これらが組み合わさり、ターミナル環境だけで快適にPostgresの中身を見れるツールになったかと思います。

同じようなツールを作る人の参考になれば幸いです!

Discussion