👽

drizzle-llm ~自然言語で指示できる、ビルド時SQL生成~

に公開

飲み会で「もうSQLはLLMに書いてもらう方が良さそうですね〜ガハハ」という会話があったので、drizzle-orm向けの仕組みを作ってみました。

https://github.com/iwamatsu0430/drizzle-llm
https://www.npmjs.com/package/drizzle-llm

できること

自然言語でクエリロジックを書くと、LLMがSQLを生成してくれます。
ビルド時に生成する仕組みなので、一度生成してしまえば実行ごとにお金がかかることはありません。
また、クエリ自体はSQLファイルで保存されるため必要に応じて修正できますし、型安全性をある程度(drizzleが)保ってくれます。
(Golangのsqlcをイメージしています)

使い方

$ npm install drizzle-llm

LLMの設定ファイルを追加

// drizzle-llm.config.ts
export default {
  provider: {
    type: 'openai',
    apiKey: process.env.OPENAI_API_KEY,
  },
  paths: {
    schema: './src/schema',
    queries: ['./src/**/*.ts'],
  }
};

以下のように、自然言語でクエリを書き

// src/user.ts
import { llm } from "drizzle-llm";
import type { Sqlite } from "@/types";
import type { User } from "./types";

export async function findName(db: Sqlite, id: string): Promise<string | undefined> {
  return await db.get<string>(llm`Find the user name by id ${id}`);
}

export async function list(db: Sqlite): Promise<User[]> {
  return await db.all<User>(llm`ユーザーを全件取得する`);
}

export async function getAverageOfAge(db: Sqlite): Promise<number> {
  return await db.get<number>(llm`ユーザーの平均年齢を取得する`).then((value: number) => value ?? -1);
}

ビルドを実行

$ npx drizzle-llm build

SQLファイル(.sql)と中間処理ファイル(.query.ts)が生成されます。

-- 8c381e8b6af5a90ebcf8c72a27bd3a49
-- Find the user name by id ${0}
SELECT name FROM user WHERE id = $1

-- 5eed765f7bd9b64c48e08823ad321f38
-- ユーザーを全件取得する
SELECT id, name, age FROM user

-- 1cc99f0e4ef8e0aa88d7ed06d7f43bd1
-- ユーザーの平均年齢を取得する
SELECT AVG(age) FROM user

実行時には生成されたSQLが使用されます。
ちょっと複雑な指示でも大丈夫です

複雑なクエリ1
export async function getInventoryAlerts(db: Sqlite): Promise<Array<{
  productId: string;
  productName: string;
  currentStock: number;
  salesLastMonth: number;
  estimatedDaysLeft: number;
}>> {
  return await db.all<{
    productId: string;
    productName: string;
    currentStock: number;
    salesLastMonth: number;
    estimatedDaysLeft: number;
  }>(llm`Generate inventory alerts for products that might run out soon based on current stock and sales velocity from last 30 days`);
}
SELECT 
  p.id AS "productId",
  p.name AS "productName", 
  p.stock AS "currentStock",
  COALESCE(SUM(s.quantity), 0) AS "salesLastMonth",
  CASE 
    WHEN COALESCE(SUM(s.quantity), 0) > 0 
    THEN ROUND((p.stock * 30.0) / SUM(s.quantity))
    ELSE NULL 
  END AS "estimatedDaysLeft"
FROM 
  product p
LEFT JOIN 
  sales s ON p.id = s.productId AND s.saleDate >= datetime('now', '-30 days') AND s.status != 'cancelled'
WHERE 
  p.is_active = 'true'
GROUP BY 
  p.id, p.name, p.stock
HAVING 
  p.stock < 20 OR (COALESCE(SUM(s.quantity), 0) > 0 AND (p.stock * 30.0) / SUM(s.quantity) < 15)
ORDER BY 
  "estimatedDaysLeft" ASC NULLS LAST
複雑なクエリ2
export async function getMonthlySalesGrowth(db: Sqlite, year: number): Promise<Array<{
  month: string;
  revenue: number;
  previousMonthRevenue: number;
  growthRate: number;
  cumulativeRevenue: number;
}>> {
  return await db.all<{
    month: string;
    revenue: number;
    previousMonthRevenue: number;
    growthRate: number;
    cumulativeRevenue: number;
  }>(llm`${year}年の月別売上を取得し、前月比の成長率と年累計売上も一緒に表示`);
}
WITH monthly_revenue AS (
  SELECT 
    strftime('%Y-%m', sale_date) as month,
    SUM(total_amount) as revenue
  FROM sales
  WHERE strftime('%Y', sale_date) = $1
  GROUP BY strftime('%Y-%m', sale_date)
),
with_previous AS (
  SELECT 
    month,
    revenue,
    LAG(revenue) OVER (ORDER BY month) as previous_month_revenue
  FROM monthly_revenue
),
with_cumulative AS (
  SELECT 
    month,
    revenue,
    previous_month_revenue,
    CASE 
      WHEN previous_month_revenue IS NULL OR previous_month_revenue = 0 THEN NULL
      ELSE (revenue - previous_month_revenue) / previous_month_revenue * 100
    END as growth_rate,
    SUM(revenue) OVER (ORDER BY month) as cumulative_revenue
  FROM with_previous
)
SELECT 
  month,
  revenue,
  previous_month_revenue,
  growth_rate,
  cumulative_revenue
FROM with_cumulative
ORDER BY month

やっていること

ビルド時

  • ビルド時にAST解析でタグ付きテンプレートリテラル( llm )を走査
  • LLMによるクエリ生成
    • スキーマ情報(テーブル名、カラム名)を必要に応じて提供
    • プレースホルダや返り値の型[1]、メソッド(all/get)、ユーザーの自然言語クエリをコンテキストとして追加
  • SQLファイル(.sql)と紐づけ用の中間処理ファイル(.query.ts)を生成

実行時

  • テンプレートリテラル( llm )が実行される際、生成済みのSQLファイルからハッシュ値をもとにクエリを検索[2]
  • パラメータをバインドし、Drizzle ORMを通じてデータベースに実行
  • 型安全な結果を返却

仕組みそのものだけであれば、実はdrizzle-ormに乗っかる必要はなかったりします。

TODO

  • 入力値によるハッシュ値を検証し、変更が必要ないクエリはスルーする
    • 現在もハッシュは生成しているものの、キャッシュがうまく機能していない
  • SQLファイルとしての整合性担保
    • スキーマが存在するなら、そちらとの突き合わせ
  • 自然言語→SQLへのコードジャンプ

さいごに

Claude Code (Maxプラン)が約1日でゴリゴリと作り上げてくれました。自分では面倒くさくてやらないことも頑張ってくれるのは助かりますね。作業過程は

  • 思いつく
  • Claude(Web)と会話し、アイデアを検証
  • Deep Researchで同種のソリューションがないか調査[3]
  • 実装をClaude Codeに引き継ぐためドキュメント (CLAUDE.md)を作成
  • 以降はVibe Coding
    • 様子を見つつ指示出し

といった形で完全な自律とまではいきませんが、自分自身は別のことに時間を使えるのは良いです。
また、Text-To-SQLはGoogle も提唱していますが、自分の雑なプロンプトでもいい感じに動くのは面白かったです。
雑な思いつきはどんどん形にしていきましょう。

脚注
  1. よって、事前に型情報などを充実させる方が生成精度が上がります ↩︎

  2. 大規模化してくるとパフォーマンス問題がありそうですね ↩︎

  3. 少なくともビルド時の生成はないって言われたけど本当かな? ↩︎

Discussion