Skip to content

Instantly share code, notes, and snippets.

@voluntas
Last active March 8, 2026 15:28
Show Gist options
  • Select an option

  • Save voluntas/97b5560cb4702e35a0a431897f00729b to your computer and use it in GitHub Desktop.

Select an option

Save voluntas/97b5560cb4702e35a0a431897f00729b to your computer and use it in GitHub Desktop.
Ruri — PostgreSQL / DuckDB 向け SQL ツールチェーン

Ruri — PostgreSQL / DuckDB 向け SQL ツールチェーン

Note

このドキュメントは LLM によって生成され、人間による校正を経て公開されています。

Caution

このツールは現在開発中で、オープンソースとして公開される予定はありません。 このツールは一部機能を制限して無料で利用できるプランを提供する予定です。

Ruri は PostgreSQL を中心とした SQL ツールチェーンです。SQL クエリファイルから型安全なコードを生成する ruri generate、SQL Formatter の ruri fmt、SQL Linter の ruri lint、宣言的スキーママイグレーションの ruri migrate を提供します。DuckDB への対応も並行して進めています。

各機能は独立して使えます。 コード生成が不要なプロジェクトでも ruri fmtruri lint だけを導入できます。マイグレーションに別のツール (golang-migrate, goose 等) を使っているプロジェクトでも、手書きマイグレーション SQL の検証に ruri migrate lint だけを使えます。必要な機能だけを選んで組み合わせてください。


Ruri について

Ruri は sqlcpg-schema-diff という優れたツールに強くインスパイアされています。

  • sqlc は SQL から型安全なコードを生成
  • pg-schema-diff は宣言的なスキーママイグレーション

これらのツールを使っていく中で「こんな機能があったらいいのに」と感じた点を自分たちで実装したものです。PostgreSQL 専用に絞ることで、PostgreSQL の型システムに踏み込んだ型推論を実現しています。

設定ファイルなどは意図的に sqlc や pg-schema-diff の仕様に寄せています。sqlc や pg-schema-diff を使ってきた人が新しい文法を覚え直さなくて済むようにするためです。

対応エンジンと言語

エンジン 言語 ドライバ
PostgreSQL Rust tokio-postgres
PostgreSQL Go pgx/v5
PostgreSQL TypeScript pg (node-postgres)
DuckDB Go duckdb-go + database/sql
DuckDB-Wasm TypeScript @duckdb/duckdb-wasm

PostgreSQL / DuckDB の文法定義から Lexer / Parser / AST を自動生成

Ruri は PostgreSQL および DuckDB のソースコードに含まれる scan.l (字句定義) と kwlist.h (キーワード一覧) から DFA ベースの Lexer を、gram.y (文法定義) から LALR Parser と AST ノード型を自動生成しています。各データベース本体と同じ文法規則を使うため、それぞれが受け付ける SQL はすべてパースでき、独自の文法解釈による誤差が生じません。

生成されるコードはすべて pure Rust であり、C ライブラリへの依存はありません。

PostgreSQL 16 / 17 / 18、DuckDB v1.4 それぞれのバージョンの gram.y / scan.l / kwlist.h を保持しており、バージョンごとに Lexer / Parser / AST を生成することでマルチバージョンに対応しています。

CST / AST のアーキテクチャは rust-analyzer を参考にしており、構文木の基盤には rust-analyzer が開発した lossless syntax tree ライブラリ Rowan を使用しています。

パース結果はコメントや空白を含むすべてのトークンを保持する CST (具体構文木) として構築されます。Formatter や Linter の位置情報付きエラー報告はこの CST の上に実現されており、型推論やコード生成には CST から自動生成された型付き AST (抽象構文木) を使用しています。

ダブルバリデーション

内部テストでは各データベースのパーサーを併用し、Ruri 独自のパーサーとの結果を突き合わせるダブルバリデーションを行っています。これにより両者のパース結果が一致することを継続的に検証しています。

さらに実際の PostgreSQL に生成 SQL を PREPARE で送信し、構文・型チェックが通ることも確認しています。

これらはテスト専用の依存であり、Ruri の実行時には使用しません。


サブコマンド一覧

サブコマンド 説明
ruri version バージョンを表示する
ruri init 設定ファイルのテンプレートを生成する
ruri generate SQL から Rust / Go / TypeScript コードを生成する
ruri fmt SQL ファイルをフォーマットする
ruri lint SQL クエリのアンチパターンを検出する
ruri compile コードを生成せずスキーマとクエリを検証する
ruri verify PostgreSQL に PREPARE を実行してクエリを検証する
ruri parse SQL ファイルをパースして CST / AST を JSON で出力する
ruri inspect SQL ファイルをパースして型推論結果を出力する
ruri migrate plan マイグレーションプランを生成して表示する
ruri migrate apply マイグレーションプランを生成して DB に直接適用する
ruri migrate dump DB スキーマを DDL としてダンプする
ruri migrate lint マイグレーション SQL のアンチパターンを検出する

ruri generate — コード生成の便利な機能

ruri generate は ruri.jsonc の設定ファイルに基づいて SQL クエリファイルを解析し、型安全なコードを生成します。sqlc と同様に、クエリごとに -- name: ... で関数名とオプションを指定できます。

PostgreSQL 向けには Rust (tokio-postgres)、Go (pgx/v5)、TypeScript (node-postgres) のコードを生成できます。DuckDB 向けには Go (duckdb-go)、TypeScript (@duckdb/duckdb-wasm) に対応しています。Go の pgx/v5 を利用した場合は sqlc 互換のコードを生成します。

クエリアノテーション

コマンド 戻り値 説明
:one Result<XxxRow, Error> 1 行を返す
:maybeone Result<Option<XxxRow>, Error> 0 行または 1 行を返す
:many Result<Vec<XxxRow>, Error> 複数行を返す
:exec Result<(), Error> 結果を返さない
:execrows Result<u64, Error> 更新行数を返す
:execresult Result<u64, Error> :execrows と同じ (互換性)
:batchone Result<Vec<XxxRow>, Error> 複数パラメータで :one を実行
:batchmany Result<Vec<Vec<XxxRow>>, Error> 複数パラメータで :many を実行
:batchexec Result<(), Error> 複数パラメータで :exec を実行
:copyfrom Result<u64, Error> 一括挿入

:maybeone コマンド

主キーやユニークキーで検索する場合、行が存在しないことは正常系です。:many では Vec が返るため 0 件か 1 件かをコードで判断する必要があり、:one では行が見つからないとエラーになります。:maybeone を使うと「0 行または 1 行を返すクエリ」を Result<Option<T>> として自然に扱えます。

-- name: GetUser :maybeone
SELECT
  id,
  name,
  email
FROM
  users
WHERE
  id = @id;
pub async fn get_user(
    client: &impl tokio_postgres::GenericClient,
    id: &i32,
) -> Result<Option<GetUserRow>, tokio_postgres::Error> { ... }

IN (@id1, @id2, ...) の直接サポート

WHERE col IN (@id1, @id2, ...) をそのまま記述できます。パラメータの型はカラム型から自動で推論されるため、型注釈を書く必要はありません。

-- name: GetItems :many
SELECT
  *
FROM
  bar
WHERE
  id IN (@id1, @id2);
pub async fn get_items(
    client: &impl tokio_postgres::GenericClient,
    id1: &i32,
    id2: &i32,
) -> Result<Vec<GetItemsRow>, tokio_postgres::Error> { ... }

型推論エンジン

SQL の型は単純なカラム参照だけではありません。COALESCE で nullable が消える、CASE のフローガードで分岐後が非 NULL になる、LAG にデフォルト値があれば先頭行も非 NULL になる — Ruri はこうした型推論を行います。今後も積極的に推論ルールを追加していきます。

Option<T> が本当に必要な列だけに Option が付き、そうでない列には付きません。

  • COALESCE / GREATEST / LEAST: 非 NULL リテラルが含まれる場合に NOT NULL と推論
  • IS NULL / IS NOT NULL フローガード: WHEN 条件で NULL 排除が保証される場合に THEN 節を NOT NULL と推論
  • WHERE IS NOT NULL 昇格: WHERE col IS NOT NULL で絞り込んだカラムを引数に持つ関数・式を NOT NULL と推論
  • JOIN: 複数テーブルの JOIN でも各カラムの NOT NULL を正確に追跡
  • 集計関数 + GROUP BY: SUM(CASE ELSE 0)array_agg + GROUP BY → NOT NULL 昇格
  • ウィンドウ関数: RANK(), ROW_NUMBER(), BOOL_AND() OVER, AVG() OVER など多数対応
  • スカラーサブクエリ: (SELECT COUNT(*) ...) のような必ず 1 行返すサブクエリを NOT NULL と推論
  • 配列: ANY(@ids)Vec<T> として推論、UNNEST の per-arg 型対応
  • 280 以上の組み込み関数: 個別に型と nullability のルールを定義

型推論の実例

再帰 CTE・複数 JOIN・CASE フローガード・ウィンドウ関数が組み合わさった複雑なクエリに対して、Ruri がどこまで NOT NULL / nullable を正確に推論できるかを示します。

schema.sql
CREATE TYPE order_status AS ENUM ('pending', 'paid', 'shipped', 'cancelled');

CREATE TABLE categories (
  id int4 NOT NULL,
  parent_id int4,
  name text NOT NULL
);

CREATE TABLE products (
  id int4 NOT NULL,
  sku text NOT NULL,
  name text NOT NULL,
  base_price numeric NOT NULL,
  cost numeric NOT NULL,
  tags text[] NOT NULL,
  category_id int4 NOT NULL,
  published bool NOT NULL
);

CREATE TABLE orders (
  id int4 NOT NULL,
  status order_status NOT NULL,
  created_at timestamptz NOT NULL
);

CREATE TABLE order_items (
  id int4 NOT NULL,
  order_id int4 NOT NULL,
  product_id int4 NOT NULL,
  quantity int4 NOT NULL,
  unit_price numeric NOT NULL,
  returned bool NOT NULL
);

CREATE TABLE events (
  id int4 NOT NULL,
  product_id int4,
  user_id int4,
  type text NOT NULL,
  created_at timestamptz NOT NULL
);
query.sql
-- name: GetProductScoreBoard :many
-- カテゴリ木 (再帰 CTE + SEARCH BREADTH FIRST) + 売上/エンゲージメント集計 +
-- COALESCE / CASE フロー / NULLIF / LAG-LEAD デフォルト値 / 名前付き WINDOW / ROWS BETWEEN
WITH RECURSIVE cat_tree (
  id,
  name,
  depth,
  path
) AS (
  SELECT
    id,
    name,
    0 AS depth,
    name AS path
  FROM
    categories
  WHERE
    parent_id IS NULL
  UNION ALL
  SELECT
    c.id,
    c.name,
    t.depth + 1,
    t.path || ' > ' || c.name
  FROM
    categories c
    INNER JOIN cat_tree t ON c.parent_id = t.id
) SEARCH BREADTH FIRST BY id SET seq_col,
sales AS (
  SELECT
    oi.product_id,
    SUM(oi.quantity) AS units_sold,
    SUM(oi.quantity * oi.unit_price) AS revenue,
    COUNT(*) AS order_count,
    COUNT(*) FILTER (WHERE oi.returned) AS returns
  FROM
    order_items oi
    INNER JOIN orders o ON o.id = oi.order_id
  WHERE
    o.status IN ('paid'::order_status, 'shipped'::order_status)
    AND o.created_at >= @from::timestamptz
  GROUP BY
    oi.product_id
),
engagement AS (
  SELECT
    product_id,
    COUNT(*) FILTER (WHERE type = 'view') AS views,
    COUNT(*) FILTER (WHERE type = 'add_cart') AS add_carts,
    COUNT(DISTINCT user_id) AS unique_users
  FROM
    events
  WHERE
    product_id IS NOT NULL
    AND created_at >= @from::timestamptz
  GROUP BY
    product_id
),
scored AS (
  SELECT
    p.id,
    p.sku,
    p.name,
    p.base_price,
    p.cost,
    p.tags,
    cat.path AS category_path,
    cat.depth AS category_depth,
    COALESCE(s.revenue, 0) AS revenue,
    COALESCE(s.units_sold, 0) AS units_sold,
    COALESCE(e.views, 0) AS views,
    COALESCE(e.unique_users, 0) AS unique_users,
    CASE WHEN s.units_sold IS NULL THEN
      0::numeric
    WHEN e.views IS NULL THEN
      0::numeric
    WHEN e.views = 0 THEN
      0::numeric
    ELSE
      s.units_sold::numeric / e.views::numeric * 100
    END AS cvr_pct,
    CASE WHEN p.cost > 0
    THEN
      ROUND((p.base_price - p.cost) / p.cost * 100, 2)
    END AS margin_pct
  FROM
    products p
    INNER JOIN cat_tree cat ON cat.id = p.category_id
    LEFT JOIN sales s ON s.product_id = p.id
    LEFT JOIN engagement e ON e.product_id = p.id
  WHERE
    p.published = TRUE
    AND p.category_id = ANY(@category_ids::int4[])
)
SELECT
  sc.id,
  sc.sku,
  sc.name,
  sc.base_price,
  sc.tags,
  sc.category_path,
  sc.category_depth,
  sc.revenue,
  sc.units_sold,
  sc.views,
  sc.cvr_pct,
  sc.margin_pct,
  RANK() OVER w_rev AS revenue_rank,
  RANK() OVER w_cvr AS cvr_rank,
  SUM(sc.revenue) OVER (
  ORDER BY sc.revenue DESC
  ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS cumulative_revenue,
  ROUND(sc.revenue / NULLIF(SUM(sc.revenue) OVER (), 0) * 100, 2) AS revenue_share_pct,
  LAG(sc.revenue, 1, 0::numeric) OVER w_rev AS prev_revenue,
  LEAD(sc.revenue, 1, 0::numeric) OVER w_rev AS next_revenue,
  LAG(sc.cvr_pct) OVER w_cvr AS prev_cvr_pct
FROM
  scored sc
WINDOW
  w_rev AS (ORDER BY sc.revenue DESC),
  w_cvr AS (ORDER BY sc.cvr_pct DESC);
ruri inspect 出力 (実際の実行結果)
$ ruri inspect -s schema.sql -q query.sql

-- GetProductScoreBoard :many --
  params:
    $1: timestamptz (NOT NULL)
        name: from
    $2: int4[] (NOT NULL)
        name: category_ids
  columns:
    id: int4 (NOT NULL)
    sku: text (NOT NULL)
    name: text (NOT NULL)
    base_price: numeric (NOT NULL)
    tags: text[] (NOT NULL)
    category_path: text (NOT NULL)
    category_depth: int4 (NOT NULL)
    revenue: numeric (NOT NULL)
    units_sold: int8 (NOT NULL)
    views: int8 (NOT NULL)
    cvr_pct: numeric (NOT NULL)
    margin_pct: numeric (nullable)
    revenue_rank: int8 (NOT NULL)  -- ウィンドウ関数は常に NOT NULL
    cvr_rank: int8 (NOT NULL)  -- ウィンドウ関数は常に NOT NULL
    cumulative_revenue: numeric (NOT NULL)  -- SUM() OVER ROWS BETWEEN で NOT NULL を保証
    revenue_share_pct: numeric (nullable)  -- NULLIF で意図的に nullable
    prev_revenue: numeric (NOT NULL)  -- LAG/LEAD のデフォルト値あり
    next_revenue: numeric (NOT NULL)  -- LAG/LEAD のデフォルト値あり
    prev_cvr_pct: numeric (nullable)  -- LAG/LEAD のデフォルト値なし
生成される Rust コード (models.rs)
// Code generated by ruri. DO NOT EDIT.

use chrono::{DateTime, Utc};
use rust_decimal::Decimal;

#[derive(Debug, Clone, PartialEq)]
pub enum OrderStatus {
    Pending,
    Paid,
    Shipped,
    Cancelled,
}

#[derive(Debug, Clone, PartialEq)]
pub struct GetProductScoreBoardRow {
    pub id: i32,
    pub sku: String,
    pub name: String,
    pub base_price: Decimal,
    pub tags: Vec<String>,
    pub category_path: String,
    pub category_depth: i32,
    pub revenue: Decimal,                     // NOT NULL -> Option なし
    pub units_sold: i64,
    pub views: i64,
    pub cvr_pct: Decimal,                     // NOT NULL -> Option なし
    pub margin_pct: Option<Decimal>,          // CASE に ELSE なし -> Option<Decimal>
    pub revenue_rank: i64,
    pub cvr_rank: i64,
    pub cumulative_revenue: Decimal,
    pub revenue_share_pct: Option<Decimal>,   // NULLIF -> Option<Decimal>
    pub prev_revenue: Decimal,                // LAG デフォルト値あり -> Option なし
    pub next_revenue: Decimal,
    pub prev_cvr_pct: Option<Decimal>,        // LAG デフォルト値なし -> Option<Decimal>
}
生成される Rust コード (query.sql.rs)
// Code generated by ruri. DO NOT EDIT.

#[allow(unused_imports)]
use super::models::*;
use chrono::{DateTime, Utc};

pub async fn get_product_score_board(
    client: &impl tokio_postgres::GenericClient,
    from: &DateTime<Utc>,
    category_ids: &[i32],
) -> Result<Vec<GetProductScoreBoardRow>, tokio_postgres::Error> {
    const SQL: &str = r"WITH RECURSIVE cat_tree ..."; // SQL はそのまま埋め込まれる
    let rows = client.query(SQL, &[from, &category_ids]).await?;
    Ok(rows
        .iter()
        .map(|row| GetProductScoreBoardRow {
            id:                 row.get(0),
            sku:                row.get(1),
            name:               row.get(2),
            base_price:         row.get(3),
            tags:               row.get(4),
            category_path:      row.get(5),
            category_depth:     row.get(6),
            revenue:            row.get(7),
            units_sold:         row.get(8),
            views:              row.get(9),
            cvr_pct:            row.get(10),
            margin_pct:         row.get(11),
            revenue_rank:       row.get(12),
            cvr_rank:           row.get(13),
            cumulative_revenue: row.get(14),
            revenue_share_pct:  row.get(15),
            prev_revenue:       row.get(16),
            next_revenue:       row.get(17),
            prev_cvr_pct:       row.get(18),
        })
        .collect())
}

コンパイル時の型チェック

Ruri はスキーマ定義に基づいてパラメータの型を検証します。キャスト型がカラムの実際の型と一致しない場合、コンパイル時にエラーを報告します。ruri compile でコード生成せずに検証だけを実行できます。

スキーマで created_attimestamptz 型のカラムに対して、誤って ::timestamp でキャストした場合:

CREATE TABLE orders (
    id         INT4        NOT NULL,
    total      NUMERIC     NOT NULL,
    created_at TIMESTAMPTZ NOT NULL
);
-- name: GetRecentOrders :many
SELECT id, total FROM orders WHERE created_at > @since::timestamp;
$ ruri compile -f ruri.jsonc

ruri::type::mismatch

  × パラメータの型不一致: カラム 'created_at' は timestamptz 型だがキャスト型
  │ は timestamp
   ╭─[queries/orders.sql:2:55]
 1 │ -- name: GetRecentOrders :many
 2 │ SELECT id, total FROM orders WHERE created_at > @since::timestamp;
   ·                                                       ─────┬─────
   ·                                                            ╰── timestamp ではなく timestamptz
   ╰────
  help: キャスト型を timestamptz に変更してください

ruri inspect — 型推論結果の確認

ruri inspect はスキーマとクエリを解析し、各クエリのパラメータ型・カラム型・nullability を表示します。コード生成前に型推論の結果を確認したいときに使います。

ruri inspect -s schema.sql -q queries.sql

上記の GetProductScoreBoard クエリに対する実際の出力:

$ ruri inspect -s schema.sql -q query.sql

-- GetProductScoreBoard :many --
  params:
    $1: timestamptz (NOT NULL)
        name: from
    $2: int4[] (NOT NULL)
        name: category_ids
  columns:
    id: int4 (NOT NULL)
    sku: text (NOT NULL)
    name: text (NOT NULL)
    base_price: numeric (NOT NULL)
    tags: text[] (NOT NULL)
    category_path: text (NOT NULL)
    category_depth: int4 (NOT NULL)
    revenue: numeric (NOT NULL)
    units_sold: int8 (NOT NULL)
    views: int8 (NOT NULL)
    cvr_pct: numeric (NOT NULL)
    margin_pct: numeric (nullable)
    revenue_rank: int8 (NOT NULL)  -- ウィンドウ関数は常に NOT NULL
    cvr_rank: int8 (NOT NULL)  -- ウィンドウ関数は常に NOT NULL
    cumulative_revenue: numeric (NOT NULL)  -- SUM() OVER ROWS BETWEEN で NOT NULL を保証
    revenue_share_pct: numeric (nullable)  -- NULLIF で意図的に nullable
    prev_revenue: numeric (NOT NULL)  -- LAG/LEAD のデフォルト値あり
    next_revenue: numeric (NOT NULL)  -- LAG/LEAD のデフォルト値あり
    prev_cvr_pct: numeric (nullable)  -- LAG/LEAD のデフォルト値なし

再帰 CTE・複数 JOIN・CASE フローガード・ウィンドウ関数・COALESCE・NULLIF が組み合わさった 19 カラムのクエリに対して、各カラムの NOT NULL / nullable が正確に推論されています。


ruri fmt — SQL Formatter

SQL Formatter です。@name 形式の名前付きパラメータを正しく処理するため、Ruri のクエリファイルをそのままフォーマットできます。

PostgreSQL / DuckDB の公式文法定義 (gram.y) から生成した CST パーサーで構文木を構築し、トークン単位でレイアウトを再構成するため、正規表現ベースのフォーマッタでは対応が難しいネストした CTE・サブクエリ・CASE 式・ウィンドウ関数なども正確にフォーマットできます。

# ファイルをフォーマット (上書き)
ruri fmt queries.sql

# 標準入力からフォーマット (標準出力へ)
cat queries.sql | ruri fmt -

# インデント幅を 2 に変更してフォーマット
ruri fmt -s 2 queries.sql

# DuckDB の SQL をフォーマット
ruri fmt --dialect duckdb -s 2 queries.sql

# フォーマットチェックのみ (CI 向け、差分があれば exit 1)
ruri fmt --check

ruri.jsonc による設定

{
  "sql": [{
    "fmt": {
      "indent_width": 4,
      "use_tabs": false,
      "print_width": 0,
      "keyword_case": "upper",
      "function_case": "unchanged",
      "type_case": "lower",
      "comma_position": "end",
      "comma_break": false,
      "no_space_function": false,
      "keep_newline": false,
      "no_extra_line": false,
      "wrap_after": 0
    }
  }]
}
キー デフォルト 説明
indent_width 整数 (1-24) 4 インデント幅
use_tabs bool false タブでインデントする
print_width 整数 (0-320) 0 折り返し列数 (0 で無効)
keyword_case 文字列 "upper" キーワードの大文字小文字
function_case 文字列 "unchanged" 関数名の大文字小文字
type_case 文字列 "lower" 型名の大文字小文字
comma_position 文字列 "end" カンマ位置 ("start" / "end")
comma_break bool false カンマごとに改行する
no_space_function bool false 関数呼び出しの括弧前のスペースを除去する
keep_newline bool false PL/pgSQL ブロック内の空行を保持する
no_extra_line bool false EOF に余分な改行を追加しない
wrap_after 整数 0 N 要素ごとにリストを折り返す (0 で無効)

ケース指定には "unchanged" / "lower" / "upper" / "capitalize" を使用します。

フォーマット例

フォーマット前(1 行に詰め込まれた状態):

-- name: GetProductScoreBoard :many
WITH RECURSIVE cat_tree(id,name,depth,path) AS (SELECT id,name,0 AS depth,name AS path FROM categories WHERE parent_id IS NULL UNION ALL SELECT c.id,c.name,t.depth+1,t.path||' > '||c.name FROM categories c JOIN cat_tree t ON c.parent_id=t.id) SEARCH BREADTH FIRST BY id SET seq_col,
sales AS (SELECT oi.product_id,SUM(oi.quantity) AS units_sold,SUM(oi.quantity*oi.unit_price) AS revenue,COUNT(*) FILTER(WHERE oi.returned) AS return_count FROM order_items oi JOIN orders o ON o.id=oi.order_id WHERE o.status IN('paid'::order_status,'shipped'::order_status) AND o.created_at>=@from::timestamptz GROUP BY oi.product_id),
engagement AS (SELECT product_id,COUNT(*) FILTER(WHERE type='view') AS views,COUNT(DISTINCT user_id) AS unique_users FROM events WHERE product_id IS NOT NULL AND created_at>=@from::timestamptz GROUP BY product_id),
scored AS (SELECT p.id,p.sku,p.name,COALESCE(s.revenue,0) AS revenue,COALESCE(s.units_sold,0) AS units_sold,COALESCE(e.views,0) AS views,CASE WHEN s.units_sold IS NULL THEN 0::numeric WHEN e.views IS NULL THEN 0::numeric WHEN e.views=0 THEN 0::numeric ELSE s.units_sold::numeric/e.views::numeric*100 END AS cvr_pct,CASE WHEN p.cost>0 THEN ROUND((p.base_price-p.cost)/p.cost*100,2) ELSE NULL END AS margin_pct FROM products p JOIN cat_tree cat ON cat.id=p.category_id LEFT JOIN sales s ON s.product_id=p.id LEFT JOIN engagement e ON e.product_id=p.id WHERE p.published=true AND p.category_id=ANY(@category_ids::int4[]))
SELECT sc.id,sc.sku,sc.name,sc.revenue,sc.cvr_pct,sc.margin_pct,RANK() OVER w_rev AS revenue_rank,RANK() OVER w_cvr AS cvr_rank,ROUND(sc.revenue/NULLIF(SUM(sc.revenue) OVER(),0)*100,2) AS revenue_share_pct,LAG(sc.revenue,1,0::numeric) OVER w_rev AS prev_revenue,LAG(sc.cvr_pct) OVER w_cvr AS prev_cvr_pct FROM scored sc WINDOW w_rev AS(ORDER BY sc.revenue DESC),w_cvr AS(ORDER BY sc.cvr_pct DESC);

フォーマット後 (-s 2):

-- name: GetProductScoreBoard :many
WITH RECURSIVE cat_tree (
  id,
  name,
  depth,
  path
) AS (
  SELECT
    id,
    name,
    0 AS depth,
    name AS path
  FROM
    categories
  WHERE
    parent_id IS NULL
  UNION ALL
  SELECT
    c.id,
    c.name,
    t.depth + 1,
    t.path || ' > ' || c.name
  FROM
    categories c
    INNER JOIN cat_tree t ON c.parent_id = t.id) SEARCH BREADTH FIRST BY id SET seq_col,
sales AS (
  SELECT
    oi.product_id,
    SUM(oi.quantity) AS units_sold,
    SUM(oi.quantity * oi.unit_price) AS revenue,
    COUNT(*) FILTER (WHERE oi.returned) AS return_count
  FROM
    order_items oi
    INNER JOIN orders o ON o.id = oi.order_id
  WHERE
    o.status IN('paid'::order_status, 'shipped'::order_status)
    AND o.created_at >= @from::timestamptz
  GROUP BY
    oi.product_id
),
engagement AS (
  SELECT
    product_id,
    COUNT(*) FILTER (WHERE type = 'view') AS views,
    COUNT(DISTINCT user_id) AS unique_users
  FROM
    events
  WHERE
    product_id IS NOT NULL
    AND created_at >= @from::timestamptz
  GROUP BY
    product_id
),
scored AS (
  SELECT
    p.id,
    p.sku,
    p.name,
    COALESCE(s.revenue, 0) AS revenue,
    COALESCE(s.units_sold, 0) AS units_sold,
    COALESCE(e.views, 0) AS views,
    CASE WHEN s.units_sold IS NULL THEN
      0::numeric
    WHEN e.views IS NULL THEN
      0::numeric
    WHEN e.views = 0 THEN
      0::numeric
    ELSE
      s.units_sold::numeric / e.views::numeric * 100
    END AS cvr_pct,
    CASE WHEN p.cost > 0 THEN
      ROUND((p.base_price - p.cost) / p.cost * 100, 2)
    ELSE
      NULL
    END AS margin_pct
  FROM
    products p
    INNER JOIN cat_tree cat ON cat.id = p.category_id
    LEFT JOIN sales s ON s.product_id = p.id
    LEFT JOIN engagement e ON e.product_id = p.id
  WHERE
    p.published = TRUE
    AND p.category_id = ANY(@category_ids::int4[]))
SELECT
  sc.id,
  sc.sku,
  sc.name,
  sc.revenue,
  sc.cvr_pct,
  sc.margin_pct,
  RANK() OVER w_rev AS revenue_rank,
  RANK() OVER w_cvr AS cvr_rank,
  ROUND(sc.revenue / NULLIF(SUM(sc.revenue) OVER(), 0) * 100, 2) AS revenue_share_pct,
  LAG(sc.revenue, 1, 0::numeric) OVER w_rev AS prev_revenue,
  LAG(sc.cvr_pct) OVER w_cvr AS prev_cvr_pct
FROM
  scored sc WINDOW w_rev AS(ORDER BY sc.revenue DESC),
  w_cvr AS(ORDER BY sc.cvr_pct DESC);

@from@category_ids のような名前付きパラメータはそのまま保持されます。

DuckDB フォーマット例

--dialect duckdb を指定すると DuckDB 固有の構文 (QUALIFY, PIVOT, EXCLUDE, ASOF JOIN, FROM-first など) を正しくフォーマットできます。

フォーマット前(1 行に詰め込まれた状態):

-- name: GetSalesRanking :many
SELECT id,product,amount,region,DENSE_RANK() OVER (PARTITION BY region ORDER BY amount DESC) AS ranking,SUM(amount) OVER (PARTITION BY region) AS region_total,LAG(amount,1,0) OVER (ORDER BY sale_date) AS prev_amount FROM sales WHERE region='east' AND sale_date>='2024-01-01' ORDER BY ranking;

-- name: GetTopProductsByRegion :many
WITH regional_sales AS (SELECT product,region,SUM(amount) AS total_amount,COUNT(*) AS sale_count FROM sales GROUP BY product,region) SELECT product,region,total_amount,sale_count FROM regional_sales QUALIFY ROW_NUMBER() OVER (PARTITION BY region ORDER BY total_amount DESC)<=3;

-- name: PivotSalesByRegion :many
PIVOT sales ON region USING SUM(amount) GROUP BY product;

-- name: GetSalesExcludeId :many
SELECT * EXCLUDE (id) FROM sales WHERE amount>100;

-- name: AsofJoinEvents :many
SELECT s.product,s.amount,e.event_type FROM sales s ASOF JOIN events e ON s.product=e.product AND s.sale_date>=e.event_date;

-- name: FromFirstSyntax :many
FROM sales SELECT product,SUM(amount) AS total WHERE region='east' GROUP BY product HAVING total>1000 ORDER BY total DESC LIMIT 10;

フォーマット後 (ruri fmt --dialect duckdb -s 2):

-- name: GetSalesRanking :many
SELECT
  id,
  product,
  amount,
  region,
  DENSE_RANK() OVER (PARTITION BY region ORDER BY amount DESC) AS ranking,
  SUM(amount) OVER (PARTITION BY region) AS region_total,
  LAG(amount, 1, 0) OVER (ORDER BY sale_date) AS prev_amount
FROM
  sales
WHERE
  region = 'east'
  AND sale_date >= '2024-01-01'
ORDER BY
  ranking;

-- name: GetTopProductsByRegion :many
WITH
  regional_sales AS (
  SELECT
    product,
    region,
    SUM(amount) AS total_amount,
    COUNT(*) AS sale_count
  FROM
    sales
  GROUP BY
    product,
    region
)
SELECT
  product,
  region,
  total_amount,
  sale_count
FROM
  regional_sales
QUALIFY
  ROW_NUMBER() OVER (PARTITION BY region ORDER BY total_amount DESC) <= 3;

-- name: PivotSalesByRegion :many
PIVOT sales ON region USING SUM(amount)
GROUP BY
  product;

-- name: GetSalesExcludeId :many
SELECT
  * EXCLUDE (id)
FROM
  sales
WHERE
  amount > 100;

-- name: AsofJoinEvents :many
SELECT
  s.product,
  s.amount,
  e.event_type
FROM
  sales s
  ASOF JOIN events e ON s.product = e.product AND s.sale_date >= e.event_date;

-- name: FromFirstSyntax :many
FROM
  sales
SELECT
  product,
  SUM(amount) AS total
WHERE
  region = 'east'
GROUP BY
  product
HAVING
  total > 1000
ORDER BY
  total DESC
LIMIT
  10;

QUALIFY、PIVOT、EXCLUDE、ASOF JOIN、FROM-first 構文など DuckDB 固有の構文がそのまま正しくフォーマットされます。


ruri lint — SQL Linter

SQL クエリファイルのアンチパターンを静的解析で検出します。コンパイラが弾くのは「構文エラーやスキーマとの不整合」ですが、Linter が検出するのは「文法的には正しいが、バグを生みやすい・保守性が低い・パフォーマンスが劣化するコード」です。

# クエリファイルを lint する
ruri lint

# 設定ファイルを指定する
ruri lint -f path/to/ruri.jsonc

4 カテゴリ、29 ルール

error レベルの違反があれば exit 1 を返すため、CI パイプラインに組み込めます。

Linter はカタログ (スキーマ情報) にアクセスするため、not-in-nullable-subquery (サブクエリのカラムが nullable かどうか) や between-timestamp (カラムがタイムスタンプ型かどうか) のようにスキーマ依存の検出も可能です。

Correctness (論理バグ) — デフォルト: error

論理バグやデータ事故の温床となるパターンを検出します。

ルール 説明 検出例
null-equality = / <> による NULL 比較は常に Unknown を返す WHERE col = NULL -> IS NULL を使用
not-in-nullable-subquery NOT IN のサブクエリに nullable カラムが含まれると全体が偽になる NOT IN (SELECT nullable_col ...) -> NOT EXISTS を使用
insert-without-columns カラムリスト省略はスキーマ変更で壊れる INSERT INTO t VALUES (...) -> INSERT INTO t (col1, col2) VALUES (...)
limit-without-order-by ORDER BY なしの LIMIT は結果が非決定的 SELECT * FROM t LIMIT 10 -> ORDER BY を追加
between-timestamp BETWEEN は両端を含むため境界値バグを生みやすい WHERE ts BETWEEN '...' AND '...' -> >= AND < を使用
duplicate-table-alias 同一クエリ内のテーブルエイリアス重複 FROM t1 AS a JOIN t2 AS a
duplicate-column-alias 同一 SELECT 内のカラムエイリアス重複 SELECT a AS x, b AS x
set-column-count-mismatch 集合演算の左右でカラム数が一致しない SELECT a, b UNION SELECT c
constant-expression WHERE 句に常に真/偽の比較式がある WHERE col = col (常に true、NULL 時は Unknown)
implicit-join-type JOIN 種別が明示されていない JOIN t2 ON ... -> INNER JOIN t2 ON ...

Perf (パフォーマンス) — デフォルト: warn

インデックスの無効化やコストの高い操作を検出します。

ルール 説明 検出例
non-sargable-function WHERE 句でカラムに関数を適用するとインデックスが使えない WHERE lower(col) = 'x' -> 関数インデックスか右辺に移動
leading-wildcard-like 前方ワイルドカードはインデックスを使用できない WHERE col LIKE '%foo' -> 全文検索の検討
union-instead-of-union-all UNION は暗黙の重複排除でソートコストが発生する UNION -> 重複排除不要なら UNION ALL
distinct-with-group-by GROUP BY があれば DISTINCT は冗長 SELECT DISTINCT col ... GROUP BY col -> DISTINCT を削除

Style (お作法) — デフォルト: off

PostgreSQL のベストプラクティスに沿ったコーディングスタイルを推奨します。

ルール 説明 検出例
prefer-count-star 行数カウントには COUNT(*) が最適 COUNT(1) / COUNT(col) -> COUNT(*)
implicit-cross-join カンマ区切りの暗黙的 CROSS JOIN は意図が不明確 FROM t1, t2 -> 明示的な CROSS JOIN か適切な JOIN
redundant-boolean-equality ブール値との等値比較は冗長 WHERE col = true -> WHERE col
numeric-order-by 位置番号による ORDER BY はカラム追加で壊れる ORDER BY 1, 2 -> カラム名を使用
unaliased-expression 式にエイリアスがないと結果カラム名が不安定 SELECT upper(name) -> SELECT upper(name) AS upper_name
prefer-left-join RIGHT JOIN はテーブル順を入れ替えて LEFT JOIN にする方が読みやすい a RIGHT JOIN b -> b LEFT JOIN a
join-in-where WHERE 句の結合条件は意図が不明確 WHERE t1.id = t2.id -> JOIN ... ON t1.id = t2.id

Complexity (冗長) — デフォルト: warn

無駄・冗長なコードを検出し、可読性と保守性を向上させます。

ルール 説明 検出例
unused-cte 定義されたが参照されない CTE WITH unused AS (...) SELECT * FROM other
useless-order-by-in-cte LIMIT なし CTE 内の ORDER BY は順序を保証しない WITH c AS (SELECT ... ORDER BY x) SELECT * FROM c
redundant-parentheses 意味のない二重括弧 WHERE ((col = 1)) -> WHERE (col = 1)
useless-alias カラム名と同名のエイリアスは冗長 SELECT id AS id -> SELECT id
redundant-else-null ELSE NULL は暗黙のデフォルトと同じ CASE ... ELSE NULL END -> CASE ... END
nested-case CASE 式のネストは可読性を下げる CASE WHEN ... THEN CASE WHEN ... END END -> サブクエリや関数に分離
unused-table-alias 定義されたが未参照のテーブルエイリアス FROM users u WHERE users.id = 1 -> u を使うか削除
unused-join JOIN したテーブルが SELECT/WHERE で未使用 不要な JOIN を削除してパフォーマンス改善

設定による制御

ruri.jsonc でカテゴリ一括設定とルール個別のオーバーライドが可能です。

{
  "sql": [{
    "lint": {
      "categories": {
        "correctness": "error",
        "style": "warn"
      },
      "rules": {
        "prefer-count-star": "warn",
        "null-equality": "off"
      }
    }
  }]
}

優先順位: ルール個別設定 > カテゴリ設定 > デフォルト。

出力例

Rust の clippy のようなリッチなエラー表示を採用しています。ソースコード表示 + 該当箇所への下線 + 修正提案 を一画面で確認できます。人間にも LLM にもわかりやすいエラーメッセージを目指しています。

ruri::correctness::null-equality

  × NULL に対する = 演算子は常に NULL (Unknown) を返します
   ╭─[queries/bad.sql:2:38]
 1 │ -- name: GetDeletedUsers :many
 2 │ SELECT * FROM users WHERE deleted_at = NULL;
   ·                                      ───┬──
   ·                                         ╰── NULL に対する = 演算子は常に NULL (Unknown) を返します
 3 │
   ╰────
  help: IS NULL を使用してください

ruri::correctness::limit-without-order-by

  × ORDER BY なしで LIMIT を使用しています
    ╭─[queries/bad.sql:11:21]
 10 │ -- name: GetTopUsers :many
 11 │ SELECT * FROM users LIMIT 10;
    ·                     ────┬───
    ·                         ╰── ORDER BY なしで LIMIT を使用しています
 12 │
    ╰────
  help: 結果の順序が非決定的になるため ORDER BY を追加してください

ruri::correctness::not-in-nullable-subquery

  × NOT IN のサブクエリに nullable カラムが含まれています
    ╭─[queries/bad.sql:14:35]
 13 │ -- name: GetOrphanCategories :many
 14 │ SELECT * FROM categories WHERE id NOT IN (SELECT parent_id FROM categories);
    ·                                   ────────────────────┬────────────────────
    ·                                                       ╰── NOT IN のサブクエリに nullable カラムが含まれています
 15 │
    ╰────
  help: サブクエリに NULL が含まれると全体が偽になります。NOT EXISTS を使用してください

ruri::correctness::between-timestamp

  × タイムスタンプ型に対して BETWEEN を使用しています
    ╭─[queries/bad.sql:17:39]
 16 │ -- name: GetJanuaryEvents :many
 17 │ SELECT * FROM events WHERE created_at BETWEEN '2024-01-01' AND '2024-01-31';
    ·                                       ──────────────────┬──────────────────
    ·                                                         ╰── タイムスタンプ型に対して BETWEEN を使用しています
    ╰────
  help: 境界値の端数処理バグを防ぐため >= と < を使用してください

下線は問題のある式全体をカバーするため、どの部分が問題なのかが一目でわかります。


ruri migrate plan/apply — 宣言的スキーママイグレーション (PostgreSQL 専用)

スキーマは SQL ファイルで宣言的に管理します。CREATE TABLEALTER TABLE を手書きする必要はありません。現在のデータベース状態と目標スキーマの差分を Ruri が自動的に計算し、必要なマイグレーション SQL を生成します。

# 差分を計算してマイグレーション SQL を生成する
ruri migrate plan --from-dsn "postgres://..." --to-dir schema/

# 生成した SQL を本番データベースに適用する
ruri migrate apply --from-dsn "postgres://..." --to-dir schema/ \
  --allow-hazards ACQUIRES_ACCESS_EXCLUSIVE_LOCK

スキーマの追加・変更・削除をすべて SQL ファイルの編集だけで管理できます。マイグレーションの順序管理や連番ファイルの管理は不要です。

保守的すぎるくらいがちょうどいい

RDB のマイグレーションは慎重であればあるほどよい、というのが ruri の立場です。本番データベースに対して「なんとなく実行できてしまう」ツールは危険です。ruri migrate はデフォルトで最も安全な動作を選び、危険な操作は一切許可しません。何かを実行するには、必ずその危険性を理解して明示的に許可する必要があります。

すべての危険な操作はデフォルトでブロックされる

ruri migrate はマイグレーション SQL を生成する前に、各操作に含まれる危険性を ハザード として検出します。ハザードが 1 つでも含まれていれば、--allow-hazards で許可を明示しない限りプランの生成自体が失敗します。

# 危険な操作が含まれているとエラーになる
ruri migrate plan --from-dsn "postgres://..." --to-dir schema/
# -> ERROR: plan contains hazards: DELETES_DATA, ACQUIRES_ACCESS_EXCLUSIVE_LOCK
#   Use --allow-hazards to proceed.

apply では --allow-hazards が引数として必須です。オプションではありません。何も考えずに apply を叩いてもエラーになるように設計されています。

# apply でも --allow-hazards なしでは実行できない
ruri migrate apply --from-dsn "postgres://..." --to-dir schema/
# -> ERROR: --allow-hazards is required for apply.

# 危険性を理解した上で明示的に許可する
ruri migrate apply --from-dsn "postgres://..." --to-dir schema/ \
  --allow-hazards DELETES_DATA,ACQUIRES_ACCESS_EXCLUSIVE_LOCK

検出されるハザードの種類:

ハザード 内容
DELETES_DATA テーブル / カラム削除によるデータ消失
ACQUIRES_ACCESS_EXCLUSIVE_LOCK 全アクセスをブロックするロック取得
ACQUIRES_SHARE_LOCK 書き込みをブロックするロック取得
ACQUIRES_SHARE_ROW_EXCLUSIVE_LOCK NOT VALID なし制約追加時のロック
INDEX_BUILD 長時間かかりうるインデックスビルド
INDEX_DROPPED インデックス削除によるクエリ性能劣化
AUTHZ_UPDATE RLS / ポリシーの認可設定変更

ロックは自動的に最小化される

ハザードを許可した場合でも、ruri migrate はロックを最小限に抑える操作を自動で選択します。手動で書き分ける必要はありません。

  • インデックス: 作成・削除は常に CONCURRENTLY で実行し、書き込みをブロックしない
  • 外部キー / CHECK 制約: NOT VALID 付きで追加し、既存行の検証を後回しにする。ロック保持時間を最小限に抑える
  • ステートメント順序: トポロジカルソートで依存関係を解決し、安全な実行順序を保証する

リネームはデータ消失の最大の罠

多くのツールはリネームを「削除 + 追加」として扱います。テーブルを users から accounts に名前変更しようとした結果、DROP TABLE users + CREATE TABLE accounts が生成されてデータが消える。これは現実に起きる事故です。

ruri migrate は --rename-table / --rename-column で意図を明示することを要求します。指定がなければリネームとみなさず、削除と追加として扱います。

ruri migrate plan --from-dsn "postgres://..." --to-dir schema/ \
  --rename-table  users:accounts \
  --rename-column accounts.name:full_name
ALTER TABLE users RENAME TO accounts;

ALTER TABLE accounts RENAME COLUMN name TO full_name;

タイムアウトで長時間ロックを強制終了する

本番環境で発生した長時間ロックを放置することは許されません。ruri migrate はステートメントごとにタイムアウトを設定でき、想定外に時間がかかる操作を強制終了します。

ruri migrate plan --from-dsn "postgres://..." --to-dir schema/ \
  --statement-timeout 5s \
  --statement-timeout-modifier "CREATE INDEX:30m"

デフォルトは 5 秒、CREATE INDEX だけ 30 分に延ばす、といった粒度で制御できます。タイムアウトなしでの本番適用は推奨しません。

スキーマハッシュで「適用後の状態」を保証する

マイグレーション SQL を適用したら DB はどの状態になるのか。ruri migrate はプラン生成時に適用後スキーマの SHA-256 ハッシュを計算し、SQL の先頭コメントに埋め込みます。ハッシュが一致しない環境への適用をブロックする CI パイプラインを構築できます。

-- schema_hash: sha256:a3f1c8...
ALTER TABLE products
  ADD COLUMN published bool NOT NULL DEFAULT FALSE;

PostgreSQL オブジェクトの広いカバレッジ

テーブルとインデックスだけでなく、PostgreSQL で実際に使われるオブジェクトを広くカバーしています。

オブジェクト 操作
COMPOSITE 型 属性の追加 / 削除 / 型変更
DOMAIN 型 制約の変更 / 基底型変更
関数 / プロシージャ 追加 / 削除 / 変更
トリガー 追加 / 削除
ビュー / マテリアライズドビュー 追加 / 削除 / 変更
エクステンション バージョン変更
RLS / ポリシー 有効化 / 無効化 / ポリシー操作
パーティションテーブル 親 / 子テーブルの認識と生成

ruri migrate lint — マイグレーション SQL の静的解析 (PostgreSQL 専用)

手書きのマイグレーション SQL ファイルに含まれるロック取得、破壊的変更、ベストプラクティス違反を静的解析で検出します。CI/CD パイプラインに組み込んで、マイグレーション適用前にリスクを発見することを目的としています。

ruri migrate lintruri migrate plan/apply とは独立した機能です。 ruri migrate plan/apply は目標スキーマとの差分から安全なマイグレーション SQL を自動生成するため、lint は不要です。一方、golang-migrate や goose などでマイグレーション SQL を手書きしているプロジェクトでは、Ruri のマイグレーション機能を使わなくても ruri migrate lint だけを導入して手書き SQL の品質を検証できます。

# マイグレーション SQL ファイルを lint する
ruri migrate lint migrations/*.sql

# PostgreSQL 16 を対象にする
ruri migrate lint --pg-version 16 migrations/*.sql

# 特定のルールを除外する
ruri migrate lint --exclude-rule ban-drop-column migrations/*.sql

# カテゴリ単位で除外する
ruri migrate lint --exclude-category best-practice migrations/*.sql
オプション 説明
--pg-version VERSION 対象 PostgreSQL バージョン (デフォルト: 17)
--assume-in-transaction マイグレーションがトランザクション内で実行される前提で解析する
--exclude-rule RULE 指定したルールを除外する (複数回指定可)
--exclude-category CATEGORY 指定したカテゴリを除外する (locking / breaking / best-practice、複数回指定可)

error レベルの違反があれば exit 1 を返すため、CI パイプラインに組み込めます。

3 カテゴリ、30 ルール

Locking (ロック) — デフォルト: error

マイグレーション実行中に長時間ロックを取得するパターンを検出します。本番環境でのダウンタイムを防ぎます。

ルール 説明
require-concurrent-index-creation CREATE INDEXCONCURRENTLY がない
require-concurrent-index-deletion DROP INDEXCONCURRENTLY がない
ban-concurrent-index-creation-in-transaction トランザクション内の CREATE INDEX CONCURRENTLY (実行不可)
disallowed-unique-constraint UNIQUE 制約の直接追加 (UNIQUE INDEX を推奨)
adding-primary-key-constraint ALTER TABLE ... ADD CONSTRAINT ... PRIMARY KEY (ACCESS EXCLUSIVE ロック)
adding-foreign-key-constraint 外部キー制約の追加
constraint-missing-not-valid FK / CHECK 制約の追加に NOT VALID がない
adding-not-nullable-field NOT NULL カラムの追加
changing-column-type ALTER COLUMN ... TYPE (テーブル全体の書き換え)
adding-field-with-default PG 11 未満での ADD COLUMN ... DEFAULT (テーブル全体の書き換え)

Breaking (破壊的変更) — デフォルト: warn

既存データの消失やクライアントの破壊を引き起こすパターンを検出します。

ルール 説明
ban-drop-column DROP COLUMN によるデータ損失
ban-drop-table DROP TABLE によるデータ損失
ban-drop-database DROP DATABASE によるデータベース全体の削除
ban-drop-not-null ALTER COLUMN ... DROP NOT NULL
renaming-table テーブル名の変更による既存クライアントの破壊
renaming-column カラム名の変更による既存クライアントの破壊
ban-truncate-cascade TRUNCATE ... CASCADE によるデータ損失
adding-required-field NOT NULL でデフォルト値なしのカラム追加 (既存行が制約違反)

Best Practice (ベストプラクティス) — デフォルト: warn

PostgreSQL のベストプラクティスに沿った推奨事項を検出します。

ルール 説明
prefer-bigint-over-int int4 より bigint の使用を推奨 (枯渇リスク)
prefer-bigint-over-smallint smallint より bigint の使用を推奨
prefer-identity SERIAL より GENERATED AS IDENTITY を推奨
prefer-text-field 長さ未指定の varchar より text を推奨
prefer-timestamptz timestamp より timestamptz を推奨
ban-char-field char / character 型のスペースパディングを警告
ban-create-domain-with-constraint CREATE DOMAIN ... CONSTRAINT を警告
ban-alter-domain-with-add-constraint ALTER DOMAIN ... ADD CONSTRAINT を警告
transaction-nesting ネストされた BEGIN を検出
ban-uncommitted-transaction BEGIN に対応する COMMIT / ROLLBACK がない
prefer-robust-stmts IF EXISTS / IF NOT EXISTS 等の防御的 SQL を推奨
require-timeout-settings ロック取得操作の前に SET lock_timeout / SET statement_timeout がない

インラインでのルール抑制

特定のステートメントに対してルールを抑制できます。

-- ruri-migrate-ignore: ban-drop-column
ALTER TABLE users DROP COLUMN legacy_field;

使い方の組み合わせ

推奨: フルプラン

Ruri のすべての機能を組み合わせて使うのが最も効果的です。SQL のフォーマット・リンター・型安全なコード生成・宣言的マイグレーションまでを一貫して管理できます。

ruri fmt queries.sql                                             # SQL をフォーマット
ruri lint                                                        # SQL のアンチパターンを検出
ruri generate                                                    # 型安全なコードを生成
ruri migrate plan --from-dsn "postgres://..." --to-dir schema/   # 差分を確認
ruri migrate apply --from-dsn "postgres://..." --to-dir schema/ \
  --allow-hazards INDEX_BUILD                                    # DB に適用

必要な機能だけを選んで使う

Ruri の各機能は独立しているため、すべてを一度に導入する必要はありません。プロジェクトの状況に応じて必要な機能だけを選んで使えます。

コード生成は不要、フォーマットとリンターだけ使う:

ruri fmt --check          # CI でフォーマットチェック
ruri lint                 # CI でアンチパターン検出

コード生成 + フォーマット + リンターを使う (マイグレーションは別ツール):

ruri fmt queries.sql      # SQL をフォーマット
ruri lint                 # SQL のアンチパターンを検出
ruri compile              # スキーマとクエリの整合性を検証
ruri generate             # 型安全なコードを生成

マイグレーションの移行が難しい場合

既に golang-migrate や goose などでマイグレーションを運用しているプロジェクトでは、マイグレーションツールの移行は簡単ではありません。そのようなプロジェクトのために ruri migrate lint を提供しています。既存のマイグレーションツールはそのまま使い続けながら、手書きマイグレーション SQL の品質だけを Ruri で検証できます。

ruri migrate lint migrations/*.sql   # 手書きマイグレーション SQL を検証
ruri fmt queries.sql                 # クエリ SQL をフォーマット
ruri lint                            # クエリのアンチパターンを検出
ruri generate                        # 型安全なコードを生成

クイックスタート

# 設定ファイルのテンプレートを生成する
ruri init

# SQL からコードを生成する
ruri generate

# SQL のアンチパターンを検出する
ruri lint

# コード生成せずに検証のみ行う (CI 向け)
ruri compile

# 型推論結果を確認する (デバッグ向け)
ruri inspect -s schema.sql -q queries.sql

# SQL ファイルをフォーマットする
ruri fmt -s 2 queries.sql

設定ファイル例

Rust (tokio-postgres):

{
  "version": "1",
  "sql": [
    {
      "schema": "schema.sql",
      "queries": "queries.sql",
      "engine": "postgresql",
      "gen": {
        "rust": {
          "out": "src/db",
          "package": "db",
          // Querier trait を生成する
          "emit_interface": true,
          // ENUM に TryFrom<String> 実装を生成する
          "emit_enum_valid_method": true,
          // out ディレクトリに mod.rs を自動生成する
          "emit_mod_rs": true,
          // クエリから参照されない構造体を生成しない
          "omit_unused_structs": true,
          // PostgreSQL 型から Rust 型へのカスタムマッピング
          "overrides": [
            { "db_type": "numeric", "rust_type": "rust_decimal::Decimal" }
          ]
        }
      },
      "fmt": {
        // インデント幅 (デフォルト: 4)
        "indent_width": 2,
        // SQL キーワードのケース: "upper" | "lower" | "unchanged" | "capitalize"
        "keyword_case": "upper",
        // 型名のケース: "lower" | "upper" | "unchanged" | "capitalize"
        "type_case": "lower",
        // コンマ位置: "end" | "start"
        "comma_position": "end"
      },
      "lint": {
        // カテゴリ単位でレベルを設定する: "error" | "warn" | "off"
        "categories": {
          // 論理バグ・事故の温床 (= NULL, NOT IN + nullable など)
          "correctness": "error",
          // パフォーマンス劣化 (SARGable 違反, LIKE '%...' など)
          "perf": "warn",
          // 無駄・冗長なコード (未使用 CTE, 過剰な括弧など)
          "complexity": "warn",
          // PostgreSQL のお作法 (COUNT(1) -> COUNT(*) など)
          "style": "off"
        }
      }
    }
  ]
}

Go (pgx/v5):

{
  "version": "1",
  "sql": [
    {
      "schema": "schema.sql",
      "queries": "queries.sql",
      "engine": "postgresql",
      "gen": {
        "go": {
          "out": "db",
          "package": "db",
          // Querier interface を生成する
          "emit_interface": true,
          // struct フィールドに json タグを付与する
          "emit_json_tags": true,
          // クエリから参照されない構造体を生成しない
          "omit_unused_structs": true
        }
      },
      "fmt": {
        "indent_width": 2,
        "keyword_case": "upper",
        "type_case": "lower",
        "comma_position": "end"
      },
      "lint": {
        "categories": {
          "correctness": "error",
          "perf": "warn",
          "complexity": "warn",
          "style": "off"
        }
      }
    }
  ]
}

TypeScript (pg / node-postgres):

{
  "version": "1",
  "sql": [
    {
      "schema": "schema.sql",
      "queries": "queries.sql",
      "engine": "postgresql",
      "gen": {
        "typescript": {
          "out": "src/db",
          "package": "db"
        }
      },
      "fmt": {
        "indent_width": 2,
        "keyword_case": "upper",
        "type_case": "lower",
        "comma_position": "end"
      },
      "lint": {
        "categories": {
          "correctness": "error",
          "perf": "warn",
          "complexity": "warn",
          "style": "off"
        }
      }
    }
  ]
}

DuckDB -> Go (duckdb-go):

{
  "version": "1",
  "sql": [
    {
      "schema": "schema.sql",
      "queries": "queries.sql",
      "engine": "duckdb",
      "gen": {
        "go": {
          "out": "db",
          "package": "db"
        }
      }
    }
  ]
}

DuckDB-Wasm -> TypeScript (@duckdb/duckdb-wasm):

{
  "version": "1",
  "sql": [
    {
      "schema": "schema.sql",
      "queries": "queries.sql",
      "engine": "duckdb-wasm",
      "gen": {
        "typescript": {
          "out": "src/db",
          "package": "db"
        }
      }
    }
  ]
}

DuckDB-Wasm TypeScript コード生成例

schema.sql
CREATE TABLE users (
    id INTEGER NOT NULL PRIMARY KEY,
    name VARCHAR NOT NULL,
    email VARCHAR,
    age INTEGER,
    is_active BOOLEAN NOT NULL DEFAULT true,
    balance DOUBLE,
    created_at TIMESTAMP NOT NULL DEFAULT now()
);

CREATE TABLE posts (
    id BIGINT NOT NULL PRIMARY KEY,
    user_id INTEGER NOT NULL,
    title VARCHAR NOT NULL,
    body TEXT,
    published BOOLEAN NOT NULL DEFAULT false,
    created_at TIMESTAMP NOT NULL DEFAULT now()
);
query.sql
-- name: GetUser :one
SELECT id, name, email, age, is_active, balance, created_at FROM users WHERE id = $1;

-- name: ListUsers :many
SELECT * FROM users;

-- name: CreateUser :exec
INSERT INTO users (id, name, email, age) VALUES ($1, $2, $3, $4);

-- name: GetUserPosts :many
SELECT id, user_id, title, body, published, created_at FROM posts WHERE user_id = $1;

-- name: CreatePost :execrows
INSERT INTO posts (id, user_id, title, body) VALUES ($1, $2, $3, $4);
生成される TypeScript コード (models.ts)
// Code generated by ruri. DO NOT EDIT.

export interface Posts {
    id: bigint;
    userId: number;
    title: string;
    body: string | null;
    published: boolean;
    createdAt: Date;
}

export interface Users {
    id: number;
    name: string;
    email: string | null;
    age: number | null;
    isActive: boolean;
    balance: number | null;
    createdAt: Date;
}
生成される TypeScript コード (query.sql.ts)
// Code generated by ruri. DO NOT EDIT.

import type { AsyncDuckDBConnection } from "@duckdb/duckdb-wasm";

export const getUserQuery = `-- name: GetUser :one
SELECT id, name, email, age, is_active, balance, created_at FROM users WHERE id = ?`;

export interface GetUserArgs {
    id: number;
}

export async function getUser(conn: AsyncDuckDBConnection, args: GetUserArgs): Promise<Users | null> {
    const stmt = await conn.prepare(getUserQuery);
    const table = await stmt.query(args.id);
    await stmt.close();
    const rows = table.toArray();
    if (rows.length === 0) return null;
    const row = rows[0];
    return {
        id: row.id,
        name: row.name,
        email: row.email,
        age: row.age,
        isActive: row.is_active,
        balance: row.balance,
        createdAt: row.created_at,
    };
}

export const listUsersQuery = `-- name: ListUsers :many
SELECT * FROM users`;

export async function listUsers(conn: AsyncDuckDBConnection): Promise<Users[]> {
    const table = await conn.query(listUsersQuery);
    return table.toArray().map((row: any) => ({
        id: row.id,
        name: row.name,
        email: row.email,
        age: row.age,
        isActive: row.is_active,
        balance: row.balance,
        createdAt: row.created_at,
    }));
}

export const createUserQuery = `-- name: CreateUser :exec
INSERT INTO users (id, name, email, age) VALUES (?, ?, ?, ?)`;

export interface CreateUserArgs {
    id: number;
    name: string;
    email: string | null;
    age: number | null;
}

export async function createUser(conn: AsyncDuckDBConnection, args: CreateUserArgs): Promise<void> {
    const stmt = await conn.prepare(createUserQuery);
    await stmt.query(args.id, args.name, args.email, args.age);
    await stmt.close();
}

export const getUserPostsQuery = `-- name: GetUserPosts :many
SELECT id, user_id, title, body, published, created_at FROM posts WHERE user_id = ?`;

export interface GetUserPostsArgs {
    userId: number;
}

export async function getUserPosts(conn: AsyncDuckDBConnection, args: GetUserPostsArgs): Promise<Posts[]> {
    const stmt = await conn.prepare(getUserPostsQuery);
    const table = await stmt.query(args.userId);
    await stmt.close();
    return table.toArray().map((row: any) => ({
        id: row.id,
        userId: row.user_id,
        title: row.title,
        body: row.body,
        published: row.published,
        createdAt: row.created_at,
    }));
}

export const createPostQuery = `-- name: CreatePost :execrows
INSERT INTO posts (id, user_id, title, body) VALUES (?, ?, ?, ?)`;

export interface CreatePostArgs {
    id: bigint;
    userId: number;
    title: string;
    body: string | null;
}

export async function createPost(conn: AsyncDuckDBConnection, args: CreatePostArgs): Promise<number> {
    const stmt = await conn.prepare(createPostQuery);
    const table = await stmt.query(args.id, args.userId, args.title, args.body);
    await stmt.close();
    return table.numRows;
}

DuckDB の INTEGERnumberBIGINTbigint、nullable カラム (VARCHAR without NOT NULL) は string | null として型安全に生成されます。$1 パラメータは DuckDB-Wasm の ? プレースホルダに自動変換されます。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment