安全な動的クエリビルダー——AIに生SQLを書かせずに、柔軟な検索を実現する

「住所が東京の顧客を検索して」とAIエージェントに投げたとき、裏側で何が起きているべきでしょうか。SvelteKit + DrizzleORM + Cloudflare D1 + Claude Tool Useで構築したCRMのチャット検索機能で、ツールに住所の絞り込みが用意されていないのに検索が動いているように見えるという問題が見つかったことをきっかけに、汎用フィルターオブジェクト方式のクエリビルダーを実装しました。この記事ではその設計をご紹介します。

「動いてる」の裏で何が起きていたか

CRMのチャットに「住所が東京の顧客一覧だして」と入力すると、ちゃんと東京の顧客だけが返ってきます。一見正しく動いているように見えましたが、住所にWHERE句をかけているツールはありません。

実際に起きていたのは、AIが顧客一覧取得を「絞り込み無し」で呼び出し、返ってきた最大50件(デフォルトlimit)の顧客データをJSONごと自分のコンテキストに読み込んで、住所列のテキストを目視ならぬ「文脈内フィルタリング」していただけ、という状態でした。DB側のLIKE検索すら発生していません。顧客が50件を超えた瞬間、範囲外の東京の顧客は静かに結果から漏れます。件数が数千〜数万件以上の規模になる本番運用では、この方式での運用は難しいです。

根本原因は、AIがフィルタできるのはツールが用意したパラメータの範囲内だけという当たり前の制約にあります。企業名やステータス(有効・無効)のような「よく聞かれる」属性には専用パラメータがあっても、住所はありません。そして新しい属性の問い合わせが来るたびにパラメータを1個ずつ足していくやり方は、遅かれ早かれ破綻します。

選んだ設計と、却下した設計

「どのカラムを・どう絞り込むか」をAI自身に決めさせる方法は複数考えられます。それぞれの安全性とスケーラビリティを比較しました。

方式 内容 採用可否
専用パラメータを都度追加 「住所が欲しい」と言われたら住所パラメータを足す。次は業種、次は担当者名……と際限なく増える。実装は最も単純だが、スキーマの肥大化が止まらない。 却下
全カラムを連結してLIKE検索 name・address等を1カラムに連結し丸ごとLIKE検索。実装は簡単だが、フィールド境界が消えて誤マッチが起きやすく、性能面でも複数カラムOR検索と大差がない。 却下
AIにWHERE句(生SQL)を生成させる DBスキーマを渡し、AIに直接SQLを書かせる。表現力は最大だが、SQLインジェクション・任意テーブルへのアクセス・DrizzleというORM層をバイパスすることによる将来のDB移行制約など、リスクが大きすぎる。 却下
構造化フィルターオブジェクト + 許可リスト AIは{field, op, value}という「意図」だけを渡す。実行側が許可リストに従って安全なDrizzleクエリに翻訳する。表現力と安全性を両立できる。 採用

AIには「意図」だけを話させ、「実行」はサーバー側の許可リストが握る、というのがここでの結論です。

汎用フィルターオブジェクトを成立させる3要素

この設計は「安全な動的クエリビルダー」と呼べる一般的なパターンで、AI特有のものではありません。Hasura/PostgraphileのGraphQL where入力型や、Retool・Airtableのようなフィルター付きテーブルUIも、内部的には同じfield / operator / valueの三つ組で表現されています。LLM tool useはこの「型」に自然言語をそのまま流し込める点で、この設計と相性が良いといえます。

処理の流れは次の通りです。

  1. ユーザーが「住所が東京の顧客」のように自然文で指示する
  2. AIがTool Useで{field: "address", op: "contains", value: "東京"}を組み立てて渡す
  3. サーバー側が許可リストで検証する(fieldが許可カラムか、opがその型で許可されているか、valueを型に応じて変換できるか)
  4. 検証を通った条件だけがlike(customers.address, "%東京%")のような実際のクエリとして実行される

これを成立させている要素は3つあります。

要素1:カラムの許可リスト(allowlist)

AIが渡すfieldは文字列でしかありません。これをそのままカラム名として使う(例えばsql`WHERE ${field} = ...`)のは、その文字列が実在するカラム名かどうかに関わらず実行されてしまう危険な設計になります。かわりに、アプリ側が事前に定義したマップに自身のプロパティとして存在するかだけを見て、値は常に実カラムオブジェクト(DrizzleのColumn型)を経由させます。bracketアクセスだけだとconstructor__proto__のようなプロトタイプ継承のプロパティまで拾ってしまうため、Object.hasOwnで明示的に絞り込み、存在しないキーは実行前に例外で弾きます。

要素2:型ごとの演算子制限

statusのようなenumカラムにgt(より大きい)を許すと、意味を持たない条件が組み立てられてしまいます。カラムをtext / enum / number / dateの4種類に分類し、型ごとに使える演算子を固定しました。

フィールド型 許可する演算子
text / enum eq, not, contains
number / date eq, not, gt, gte, lt, lte

要素3:値の安全な型変換

AnthropicのTool Use JSON Schemaでフィールドごとに型を出し分けることは不可能ではないものの、フィールドが動的な今回の構造では現実的ではないため、valueは常に文字列として受け取り、フィールド型に応じてサーバー側で数値・日付に変換します。変換に失敗したら(例:日付として解釈できない文字列)例外を投げ、DBに変な条件が渡らないようにしています。

実装

上記3要素をまとめた共通ユーティリティをsrc/lib/server/agent-tools/filter.tsに置き、search_customers / search_deals / search_activitiesの3ツールから使い回しています。

// src/lib/server/agent-tools/filter.ts
import { eq, gt, gte, like, lt, lte, ne, type SQL } from 'drizzle-orm';
import type { SQLiteColumn } from 'drizzle-orm/sqlite-core';

export type FilterOp = 'eq' | 'not' | 'contains' | 'gt' | 'gte' | 'lt' | 'lte';
export type FilterFieldType = 'text' | 'enum' | 'number' | 'date';

export type FilterableColumn = { column: SQLiteColumn; type: FilterFieldType };

// フィールドの型ごとに許可する演算子(enumに gt/lt のような意味不明な条件を許さないため)
const OPS_BY_TYPE: Record<FilterFieldType, FilterOp[]> = {
    text: ['eq', 'not', 'contains'],
    enum: ['eq', 'not', 'contains'],
    number: ['eq', 'not', 'gt', 'gte', 'lt', 'lte'],
    date: ['eq', 'not', 'gt', 'gte', 'lt', 'lte']
};

// field は fields(呼び出し側の許可リスト)に「自身のプロパティ」として存在するキーのみ受け付ける
// (bracketアクセスだけだと constructor/__proto__ 等のプロトタイプ継承プロパティも
//  truthy に解決されてガードをすり抜けてしまうため、hasOwn で明示的に絞る)
export function buildFilterConditions(
    filters: FilterCondition[] | undefined,
    fields: Record<string, FilterableColumn>
): SQL[] {
    if (!filters?.length) return [];

    return filters.map((f) => {
        if (!Object.hasOwn(fields, f.field)) throw new Error(`未対応の絞り込み対象です: ${f.field}`);

        const def = fields[f.field];
        const allowedOps = OPS_BY_TYPE[def.type];
        if (!allowedOps.includes(f.op)) {
            throw new Error(`${f.field} には ${f.op} は使用できません`);
        }

        const value = coerceValue(def.type, f.value); // 数値/日付変換。失敗時は例外
        switch (f.op) {
            case 'eq': return eq(def.column, value);
            case 'not': return ne(def.column, value);
            case 'contains': return like(def.column, `%${value}%`);
            // gt / gte / lt / lte も同様に続く
        }
    });
}

ポイントはcolumnが最初から最後までDrizzleの型付きカラムオブジェクトのままで、一度も生の文字列としてSQLに混ざらないことにあります。fieldという文字列は、どのカラムオブジェクトを取り出すかを決めるためのキー以上の役割を持ちません。

テーブルごとの許可リストとツールへの組み込み

呼び出し側(search.ts)で、テーブルごとに許可カラムを定義します。

// src/lib/server/agent-tools/search.ts
const CUSTOMER_FILTER_FIELDS: Record<string, FilterableColumn> = {
    name: { column: customers.name, type: 'text' },
    email: { column: customers.email, type: 'text' },
    address: { column: customers.address, type: 'text' },
    status: { column: customers.status, type: 'enum' },
    created_at: { column: customers.createdAt, type: 'date' }
    // ...
};

export async function handleSearchCustomers(db: Db, input: unknown) {
    const p = searchCustomersSchema.parse(input);
    const filterConditions = buildFilterConditions(p.filters, CUSTOMER_FILTER_FIELDS);

    const rows = await db.select().from(customers).where(
        and(
            p.name ? like(customers.name, `%${p.name}%`) : undefined,
            p.status ? eq(customers.status, p.status) : undefined,
            // ...関連条件のサブクエリ...
            ...filterConditions // ← 汎用フィルターも同じ and() に合流させる
        )
    );
    return rows;
}

既存のname / statusのような専用パラメータは残したまま、filtersを「逃げ道」として追加しました。よく使う条件は専用パラメータのままシンプルに、それ以外のロングテールな属性はfiltersでカバーする、というハイブリッド構成になっています。

ツールの説明文はコードから自動生成する

Anthropic Tool UseのJSON Schemaは「このフィールドにはこの演算子だけ許可」という条件付き制約をうまく表現できません。そこで許可リストから"address(eq/not/contains), status(eq/not/contains), amount(eq/not/gt/gte/lt/lte)..."という説明文を自動生成し、ツールのdescriptionに埋め込みました。AIが誤った演算子を選んだ場合は、buildFilterConditionsが投げる日本語エラーメッセージ(「statusにはgtは使用できません」)がそのままツール実行エラーとしてAIに返り、次のターンでの訂正材料になります。

DBなしで許可リストの境界を検証する

buildFilterConditionsはDrizzleのクエリビルダー関数を呼ぶだけでI/Oを一切行わないため、実DBを立てずに純粋なユニットテストで検証できます。確認すべきは「許可リスト外のfieldを弾けるか」「型に合わない演算子を弾けるか」「不正な値を弾けるか」の3点、つまり境界のテストがそのままセキュリティテストになります。

// src/lib/server/agent-tools/filter.test.ts
it('rejects fields not in the allowlist (no raw column/SQL injection)', () => {
    expect(() =>
        buildFilterConditions([{ field: 'id', op: 'eq', value: 'x' }], FIELDS)
    ).toThrow(/未対応の絞り込み対象/);
});

it('rejects operators not allowed for the field type', () => {
    // status は enum カラム: gt に順序の意味はない
    expect(() =>
        buildFilterConditions([{ field: 'status', op: 'gt', value: 'active' }], FIELDS)
    ).toThrow(/には gt は使用できません/);
});

このパターンが解決しないこと

この設計が解決するのは、AIが安全に・広い表現力で絞り込み条件を組み立てられるかであって、そのクエリが速いかではありません。containsは結局LIKE '%...%'にコンパイルされ、SQLiteでは前方一致でない検索にインデックスは効かずフルスキャンになります。顧客数が数千〜万件規模になった時点で、この仕組みだけでは性能要件を満たせません。表現力の層と性能の層を分けて考えるのが、このパターンの肝になります。

まとめ

AIエージェントに生SQLやSQL文字列の組み立てを直接させず、渡させるのは常に構造化された「意図」(field / operator / value)に限定します。受け取ったfieldは、サーバー側が事前定義した許可リストのキーとしてのみ扱い、存在しなければ即エラーとし、カラム名として文字列を直接クエリに混ぜません。演算子はカラムの型ごとに許可セットを制限し、enumに大小比較を許すような意味的に壊れた条件を型で防ぎます。値の型変換に失敗したら実行前に例外を投げ、DBに壊れた条件を渡さないようにします。よく使う条件は専用パラメータのまま残し、汎用フィルターは「ロングテールな条件のための逃げ道」として共存させます——全部を汎用化する必要はありません。表現力(何を検索できるか)と性能(それが速いか)は別の問題として切り分け、後者は別途インデックス戦略で解きます。