Note
このドキュメントは LLM によって生成され、人間による校正を経て公開されています。
Caution
このツールは現在開発中で、オープンソースとして公開される予定はありません。 このツールは一部機能を制限して無料で利用できるプランを提供する予定です。
Ruri は PostgreSQL を中心とした SQL ツールチェーンです。SQL クエリファイルから型安全なコードを生成する ruri generate、SQL Formatter の ruri fmt、SQL Linter の ruri lint、宣言的スキーママイグレーションの ruri migrate を提供します。DuckDB への対応も並行して進めています。
各機能は独立して使えます。 コード生成が不要なプロジェクトでも ruri fmt や ruri lint だけを導入できます。マイグレーションに別のツール (golang-migrate, goose 等) を使っているプロジェクトでも、手書きマイグレーション SQL の検証に ruri migrate lint だけを使えます。必要な機能だけを選んで組み合わせてください。
Ruri は sqlc と pg-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 |
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: pg_query.rs を使用
- DuckDB: duckdb-rs を使用 (予定)
さらに実際の 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.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> |
一括挿入 |
主キーやユニークキーで検索する場合、行が存在しないことは正常系です。: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> { ... }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 を正確に推論できるかを示します。
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
);-- 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 -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 のデフォルト値なし
// 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>
}// 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_at が timestamptz 型のカラムに対して、誤って ::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 はスキーマとクエリを解析し、各クエリのパラメータ型・カラム型・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 が正確に推論されています。
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| キー | 型 | デフォルト | 説明 |
|---|---|---|---|
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 のような名前付きパラメータはそのまま保持されます。
--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 固有の構文がそのまま正しくフォーマットされます。
SQL クエリファイルのアンチパターンを静的解析で検出します。コンパイラが弾くのは「構文エラーやスキーマとの不整合」ですが、Linter が検出するのは「文法的には正しいが、バグを生みやすい・保守性が低い・パフォーマンスが劣化するコード」です。
# クエリファイルを lint する
ruri lint
# 設定ファイルを指定する
ruri lint -f path/to/ruri.jsoncerror レベルの違反があれば exit 1 を返すため、CI パイプラインに組み込めます。
Linter はカタログ (スキーマ情報) にアクセスするため、not-in-nullable-subquery (サブクエリのカラムが nullable かどうか) や between-timestamp (カラムがタイムスタンプ型かどうか) のようにスキーマ依存の検出も可能です。
論理バグやデータ事故の温床となるパターンを検出します。
| ルール | 説明 | 検出例 |
|---|---|---|
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 ... |
インデックスの無効化やコストの高い操作を検出します。
| ルール | 説明 | 検出例 |
|---|---|---|
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 を削除 |
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 |
無駄・冗長なコードを検出し、可読性と保守性を向上させます。
| ルール | 説明 | 検出例 |
|---|---|---|
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: 境界値の端数処理バグを防ぐため >= と < を使用してください
下線は問題のある式全体をカバーするため、どの部分が問題なのかが一目でわかります。
スキーマは SQL ファイルで宣言的に管理します。CREATE TABLE や ALTER 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_nameALTER 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 で実際に使われるオブジェクトを広くカバーしています。
| オブジェクト | 操作 |
|---|---|
| COMPOSITE 型 | 属性の追加 / 削除 / 型変更 |
| DOMAIN 型 | 制約の変更 / 基底型変更 |
| 関数 / プロシージャ | 追加 / 削除 / 変更 |
| トリガー | 追加 / 削除 |
| ビュー / マテリアライズドビュー | 追加 / 削除 / 変更 |
| エクステンション | バージョン変更 |
| RLS / ポリシー | 有効化 / 無効化 / ポリシー操作 |
| パーティションテーブル | 親 / 子テーブルの認識と生成 |
手書きのマイグレーション SQL ファイルに含まれるロック取得、破壊的変更、ベストプラクティス違反を静的解析で検出します。CI/CD パイプラインに組み込んで、マイグレーション適用前にリスクを発見することを目的としています。
ruri migrate lint は ruri 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 パイプラインに組み込めます。
マイグレーション実行中に長時間ロックを取得するパターンを検出します。本番環境でのダウンタイムを防ぎます。
| ルール | 説明 |
|---|---|
require-concurrent-index-creation |
CREATE INDEX に CONCURRENTLY がない |
require-concurrent-index-deletion |
DROP INDEX に CONCURRENTLY がない |
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 (テーブル全体の書き換え) |
既存データの消失やクライアントの破壊を引き起こすパターンを検出します。
| ルール | 説明 |
|---|---|
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 でデフォルト値なしのカラム追加 (既存行が制約違反) |
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.sqlRust (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"
}
}
}
]
}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()
);-- 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);// 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;
}// 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 の INTEGER は number、BIGINT は bigint、nullable カラム (VARCHAR without NOT NULL) は string | null として型安全に生成されます。$1 パラメータは DuckDB-Wasm の ? プレースホルダに自動変換されます。
{ "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 } }] }