SQL テンプレート抽象化
リポジトリ: drizzle-team/drizzle-orm 分析日: 2026-03-04
概要
Drizzle ORM の SQL テンプレートリテラルシステムは、TypeScript のタグ付きテンプレート(sql`...`)を使って SQL フラグメントを型安全に合成する仕組みである。SQL 文字列の直接結合を避け、チャンクの再帰的な木構造として SQL を表現することで、パラメータ化・エスケープ・方言差異の吸収を一元化している。式ヘルパー(eq,and,or` 等)はすべてこのテンプレートシステムの上に構築されており、DSL 的な使いやすさとロー SQL の柔軟性を両立させている点が注目に値する。
背景にある原則
SQL はデータ構造であり文字列ではない: SQL を
stringとして扱わず、SQLクラス(チャンクの配列)として表現する。これにより、合成・条件付き挿入・パラメータ抽出を安全に行える。タグ付きテンプレートは構文上の入口に過ぎず、内部ではStringChunk,Param,Name,SQL(ネスト)等の異種チャンクの配列として保持される(drizzle-orm/src/sql/sql.ts:118)。統一インターフェースによる合成可能性:
SQLWrapperインターフェース(getSQL(): SQLメソッドを持つ)をテーブル・カラム・ビュー・サブクエリ・式すべてに実装させることで、あらゆる SQL 構成要素をテンプレート内に自由に埋め込める。これが「合成の通貨」として機能している(drizzle-orm/src/sql/sql.ts:65-68)。パラメータバインドの自動化と明示の棲み分け:
bindIfParam関数がプリミティブ値を自動的にParamでラップし、既に SQL 構造を持つ値はそのまま通す。これにより、ユーザーはeq(column, 'value')のようにプリミティブを直接渡せる一方、SQL 式を渡す場合は二重バインドを防げる(drizzle-orm/src/sql/expressions/conditions.ts:17-30)。遅延評価とコンテキスト依存のレンダリング: SQL オブジェクトは構築時にはチャンク配列を保持するだけで、文字列化は
toQuery()呼び出し時に行われる。BuildQueryConfigにエスケープ関数やパラメータ形式(PostgreSQL の$1と MySQL の?)を注入することで、同じ SQL 木を異なる方言で出力できる(drizzle-orm/src/sql/sql.ts:32-41)。
実例と分析
タグ付きテンプレートの内部構造
sql タグ付きテンプレート関数は、テンプレート文字列の静的部分を StringChunk に、補間部分をそのまま SQLChunk として交互に配置する。これは JavaScript のタグ付きテンプレートの仕様(strings 配列は常に params より 1 つ多い)を活用している。
// drizzle-orm/src/sql/sql.ts:485-495
export function sql(strings: TemplateStringsArray, ...params: SQLChunk[]): SQL {
const queryChunks: SQLChunk[] = [];
if (params.length > 0 || (strings.length > 0 && strings[0] !== "")) {
queryChunks.push(new StringChunk(strings[0]!));
}
for (const [paramIndex, param] of params.entries()) {
queryChunks.push(param, new StringChunk(strings[paramIndex + 1]!));
}
return new SQL(queryChunks);
}再帰的なチャンク解決
buildQueryFromSourceParams メソッドは SQL チャンクを再帰的に解決する。SQL チャンクが別の SQL を含む場合、内部チャンクを再帰的に展開する。配列チャンクは自動的に括弧で囲まれカンマ区切りになる。undefined は空文字列として無視される。
// drizzle-orm/src/sql/sql.ts:172-174
if (chunk === undefined) {
return { sql: "", params: [] };
}
// drizzle-orm/src/sql/sql.ts:188-193 — SQL のネスト解決
if (is(chunk, SQL)) {
return this.buildQueryFromSourceParams(chunk.queryChunks, {
...config,
inlineParams: inlineParams || chunk.shouldInlineParams,
});
}この undefined スキップ機構が、条件付き SQL 挿入の基盤となっている。
条件付き SQL 挿入パターン
三項演算子と undefined スキップ、または SQL.if() メソッドを組み合わせることで、条件付きの SQL フラグメント挿入を実現している。
// drizzle-orm/src/sql/sql.ts:369-371 — SQL.if() メソッド
if(condition: any | undefined): this | undefined {
return condition ? this : undefined;
}// drizzle-orm/src/pg-core/dialect.ts:147 — 三項演算子パターン
const whereSql = where ? sql` where ${where}` : undefined;
// drizzle-orm/src/pg-core/query-builders/count.ts:22 — .if() パターン
return sql<number>`(select count(*) from ${source}${sql.raw(" where ").if(filters)}${filters})`;sql namespace のユーティリティ群
sql は関数であると同時に namespace でもあり、ユーティリティ関数群を持つ。これにより sql.join(), sql.raw(), sql.empty(), sql.identifier(), sql.param(), sql.placeholder() を単一のインポートで利用できる。
// drizzle-orm/src/sql/sql.ts:497-565
export namespace sql {
export function empty(): SQL { return new SQL([]); }
export function raw(str: string): SQL { return new SQL([new StringChunk(str)]); }
export function join(chunks: SQLChunk[], separator?: SQLChunk): SQL { ... }
export function identifier(value: string): Name { return new Name(value); }
export function param<TData, TDriver>(value: TData, encoder?: ...): Param { ... }
export function placeholder<TName extends string>(name: TName): Placeholder { ... }
}式ヘルパーによる DSL 構築
条件式(eq, gt, and, or 等)はすべて sql テンプレートと bindIfParam を組み合わせて実装されている。これにより、新しい式ヘルパーの追加が極めて簡潔になる。
// drizzle-orm/src/sql/expressions/conditions.ts:62-64
export const eq: BinaryOperator = (left: SQLWrapper, right: unknown): SQL => {
return sql`${left} = ${bindIfParam(right, left)}`;
};and / or は undefined を自動フィルタし、sql.join で結合する。条件が 0 個なら undefined を返し、1 個ならそのまま返すという最適化も行われている。
// drizzle-orm/src/sql/expressions/conditions.ts:107-125
export function and(...unfilteredConditions: (SQLWrapper | undefined)[]): SQL | undefined {
const conditions = unfilteredConditions.filter(
(c): c is Exclude<typeof c, undefined> => c !== undefined,
);
if (conditions.length === 0) return undefined;
if (conditions.length === 1) return new SQL(conditions);
return new SQL([
new StringChunk("("),
sql.join(conditions, new StringChunk(" and ")),
new StringChunk(")"),
]);
}方言固有の式ヘルパー
各方言(PostgreSQL, MySQL, SQLite)は共通の式ヘルパーを再エクスポートしつつ、方言固有のヘルパーを追加する。全ての方言で concat と substring が同じパターンで実装されているが、SQLite のみ rowId() を追加している。
// drizzle-orm/src/sqlite-core/expressions.ts:27-29
export function rowId(): SQL<number> {
return sql<number>`rowid`;
}エンティティ種別判定(entityKind)
チャンクの型判別に instanceof ではなく entityKind シンボルベースの is() 関数を使っている。これはプロトタイプチェーンを辿って判定するため、異なるバージョンの drizzle パッケージが混在する場合でも正しく動作する。
// drizzle-orm/src/entity.ts:12-42
export function is<T extends DrizzleEntityClass<any>>(value: any, type: T): value is InstanceType<T> {
if (!value || typeof value !== "object") return false;
if (value instanceof type) return true;
// ...プロトタイプチェーンを辿る
let cls = Object.getPrototypeOf(value).constructor;
if (cls) {
while (cls) {
if (entityKind in cls && cls[entityKind] === type[entityKind]) return true;
cls = Object.getPrototypeOf(cls);
}
}
return false;
}コード例
// drizzle-orm/src/sql/sql.ts:118
// SQL クラスのコンストラクタ — チャンク配列を受け取る
constructor(readonly queryChunks: SQLChunk[]) {
for (const chunk of queryChunks) {
if (is(chunk, Table)) {
const schemaName = chunk[Table.Symbol.Schema];
this.usedTables.push(
schemaName === undefined
? chunk[Table.Symbol.Name]
: schemaName + '.' + chunk[Table.Symbol.Name],
);
}
}
}// drizzle-orm/src/pg-core/dialect.ts:440-441
// 複数の SQL フラグメントを undefined 許容で合成する SELECT 文の最終組み立て
const finalQuery =
sql`${withSql}select${distinctSql} ${selection} from ${tableSql}${joinsSql}${whereSql}${groupBySql}${havingSql}${orderBySql}${limitSql}${offsetSql}${lockingClauseSql}`;// drizzle-orm/src/pg-core/dialect.ts:420-438
// sql.empty() + append で命令的に SQL を組み立てるパターン
const lockingClauseSql = sql.empty();
if (lockingClause) {
const clauseSql = sql` for ${sql.raw(lockingClause.strength)}`;
if (lockingClause.config.of) {
clauseSql.append(
sql` of ${
sql.join(
Array.isArray(lockingClause.config.of) ? lockingClause.config.of : [lockingClause.config.of],
sql`, `,
)
}`,
);
}
if (lockingClause.config.noWait) {
clauseSql.append(sql` nowait`);
} else if (lockingClause.config.skipLocked) {
clauseSql.append(sql` skip locked`);
}
lockingClauseSql.append(clauseSql);
}// drizzle-orm/src/sql/functions/aggregate.ts:19-20
// mapWith による結果型のデコード指定
export function count(expression?: SQLWrapper): SQL<number> {
return sql`count(${expression || sql.raw("*")})`.mapWith(Number);
}// drizzle-orm/src/sql/expressions/conditions.ts:17-30
// bindIfParam — プリミティブ値の自動パラメータバインド
export function bindIfParam(value: unknown, column: SQLWrapper): SQLChunk {
if (
isDriverValueEncoder(column)
&& !isSQLWrapper(value)
&& !is(value, Param)
&& !is(value, Placeholder)
&& !is(value, Column)
&& !is(value, Table)
&& !is(value, View)
) {
return new Param(value, column);
}
return value as SQLChunk;
}パターンカタログ
Interpreter パターン (分類: 振る舞い)
- 解決する問題: SQL 文法をオブジェクト構造として表現し、異なるコンテキスト(方言)で解釈する
- 適用条件: DSL の構文木を構築し、複数のバックエンドで出力する必要がある場合
- コード例:
drizzle-orm/src/sql/sql.ts:148-299—buildQueryFromSourceParamsがチャンク種別ごとに分岐して解釈する - 注意点: チャンク種別の追加には
buildQueryFromSourceParamsの分岐追加が必要になるため、Open-Closed 原則には完全には従わない
Composite パターン (分類: 構造)
- 解決する問題: 単一の SQL 式と複合 SQL 式を同一の型で扱う
- 適用条件: ツリー構造の再帰的合成が必要な場合
- コード例:
drizzle-orm/src/sql/sql.ts:463-476—SQLChunk型はSQL自身を含む再帰的ユニオン型 - 注意点: 循環参照を防ぐため、
Column.prototype.getSQLは別ファイルで定義されている(sql.ts:710-712)
Strategy パターン (分類: 振る舞い)
- 解決する問題: エスケープ・パラメータ形式を方言ごとに切り替える
- 適用条件: 同じデータ構造を異なるフォーマットで出力する場合
- コード例:
drizzle-orm/src/sql/sql.ts:32-41—BuildQueryConfigのescapeName,escapeParam,escapeString - 注意点: Strategy は構築時ではなく出力時に注入される(遅延評価)
Good Patterns
- undefined スキップによる宣言的な条件付き SQL 合成: テンプレート内で
undefinedが自動スキップされることを利用し、const whereSql = where ? sql\where ${where}` : undefinedのように条件付きフラグメントを変数として用意し、最終テンプレートに${whereSql}` で埋め込む。命令的な文字列結合と比べて、最終形の構造が一目で見える。
// drizzle-orm/src/pg-core/dialect.ts:140-149
buildDeleteQuery({ table, where, returning, withList }: PgDeleteConfig): SQL {
const withSql = this.buildWithCTE(withList);
const returningSql = returning
? sql` returning ${this.buildSelection(returning, { isSingleTable: true })}`
: undefined;
const whereSql = where ? sql` where ${where}` : undefined;
return sql`${withSql}delete from ${table}${whereSql}${returningSql}`;
}- and/or の undefined 自動フィルタリング:
and()/or()がundefined引数を自動的に除外するため、条件のオプショナル合成が安全に行える。条件が 0 個のときundefinedを返すことで、呼び出し側のさらなる条件分岐にも対応する。
// ユーザーコードでの利用例
const conditions = and(
eq(users.role, "admin"),
searchTerm ? like(users.name, `%${searchTerm}%`) : undefined,
minAge ? gte(users.age, minAge) : undefined,
);
// conditions は undefined | SQL- sql namespace パターン: 関数と namespace の二重定義により、
sql\...`とsql.join()` を同一のインポートで使える。ユーザーの認知負荷を下げつつ、名前空間の汚染を防いでいる。
import { sql } from "drizzle-orm";
// タグ付きテンプレートとして
sql`SELECT * FROM ${users}`;
// namespace ユーティリティとして
sql.join([sql`a`, sql`b`], sql`, `);
sql.raw("SELECT 1");
sql.identifier("table-name");- bindIfParam による透過的パラメータ化: 式ヘルパーが
bindIfParamを通して値を受け取ることで、プリミティブ値も SQL 式も同じ API で渡せる。カラムのエンコーダ情報を使って適切な型変換も自動適用される。
Anti-Patterns / 注意点
- sql.raw() の安全でない使用:
sql.raw()はエスケープなしに文字列を SQL に埋め込むため、ユーザー入力を直接渡すと SQL インジェクションの危険がある。コードベース内でもsql.identifier()のドキュメントに「WARNING: This function does not offer any protection against SQL injections」と明記されている。
// Bad: ユーザー入力を直接 raw に渡す
const query = sql.raw(`SELECT * FROM ${userInput}`);
// Better: sql.identifier() でエスケープし、値は補間で渡す
const query = sql`SELECT * FROM ${sql.identifier(tableName)} WHERE id = ${userId}`;- sql.empty() + append の多用:
sql.empty()で空の SQL を作ってappendで組み立てるパターンは命令的で、最終形が見づらくなる。条件付きフラグメントは変数 + undefined パターンの方が宣言的である。ただし、ループ内で動的にフラグメントを追加する場合(locking clause の組み立て等)では append が適切。
// 命令的(append チェーン)
const query = sql.empty();
query.append(sql`SELECT * FROM users`);
if (where) query.append(sql` WHERE ${where}`);
// 宣言的(undefined スキップ)
const whereSql = where ? sql` WHERE ${where}` : undefined;
const query = sql`SELECT * FROM users${whereSql}`;- 型引数なしの sql テンプレート:
sql\...`はデフォルトでSQL<unknown>を返す。SELECT の結果型を活かすにはsql<number>`count(*)`のように型引数を付けるか、.mapWith()` でデコーダを指定する必要がある。
// Bad: 型情報なし
const result = sql`count(*)`; // SQL<unknown>
// Better: 型引数またはmapWithで型を指定
const result = sql<number>`count(*)`.mapWith(Number);導出ルール
[MUST]SQL テンプレートシステムを構築する場合、補間値をプリミティブ文字列ではなくパラメータ化されたチャンクとして保持し、文字列化を最終出力時まで遅延させる- 根拠: Drizzle ORM は
SQLクラスにチャンク配列を持たせ、toQuery()時に初めてパラメータ番号の割り当てとエスケープを行うことで、方言差異の吸収とパラメータ化を一元化している(sql.ts:137-146)
- 根拠: Drizzle ORM は
[MUST]条件付き SQL フラグメントの合成には、undefinedを「存在しない」を表す値として一貫して扱い、合成エンジン側で自動スキップする設計にする- 根拠: Drizzle ORM は
buildQueryFromSourceParamsでundefinedチャンクを空文字列にマッピングし(sql.ts:172-174)、and()/or()でもundefined引数をフィルタすることで(conditions.ts:108-109)、呼び出し側の条件分岐を最小化している
- 根拠: Drizzle ORM は
[MUST]エスケープなしに文字列を SQL に埋め込む関数(raw()等)はユーザー入力に対して使用しない。動的な識別子には専用のエスケープ付き関数(identifier()等)を使う- 根拠:
sql.raw()はStringChunkとしてそのまま出力され、パラメータ化もエスケープも行われないため、SQL インジェクションの直接的な原因となる
- 根拠:
[SHOULD]合成可能な SQL フラグメントを作るためのインターフェース(Drizzle のSQLWrapper)を定義し、テーブル・カラム・式・サブクエリ等すべての SQL 構成要素に実装させる- 根拠:
SQLWrapperのgetSQL()メソッドにより、異なる種類の SQL 構成要素を統一的にテンプレート内に埋め込めるようになっている(sql.ts:65-68)。これが式の自由な合成を支えている
- 根拠:
[SHOULD]バイナリ演算式(eq,gt等)では、右辺の値が既に SQL 構造かプリミティブかを判別し、プリミティブの場合のみ自動パラメータ化する(bindIfParamパターン)- 根拠:
bindIfParamはisDriverValueEncoderとisSQLWrapperの判定を組み合わせることで、二重バインドを防ぎつつプリミティブ値の型変換も適用している(conditions.ts:17-30)
- 根拠:
[SHOULD]可変個数の条件を合成する関数(and,or等)では、条件が 0 個のときundefined、1 個のとき単体を返し、2 個以上のときのみ括弧とセパレータで結合する- 根拠: この最適化により、
and(onlyOneCondition)が不要な括弧なしの SQL を生成し、and()がundefinedを返すことで上位の条件合成にも正しく伝播する(conditions.ts:112-125)
- 根拠: この最適化により、
[AVOID]SQL テンプレートシステムにおいてinstanceofによる型判別を使う。パッケージのバージョン不一致やバンドラの重複によりinstanceofが失敗する環境がある- 根拠: Drizzle ORM は
entityKindシンボル + プロトタイプチェーン辿りのis()関数を使い、instanceofが失敗する環境でも正しく型判別できるようにしている(entity.ts:12-42)
- 根拠: Drizzle ORM は
適用チェックリスト
- [ ] SQL テンプレートシステムで補間値を文字列結合ではなくパラメータ化チャンクとして保持しているか
- [ ] 条件付き SQL フラグメントの生成で
undefinedスキップパターン(宣言的合成)を活用しているか - [ ]
sql.raw()相当のエスケープなし埋め込みがユーザー入力に対して使われていないか - [ ] SQL の構成要素(テーブル、カラム、式、サブクエリ)に共通インターフェースを実装させ、テンプレート内で統一的に扱えるようにしているか
- [ ] バイナリ演算の式ヘルパーで
bindIfParam相当のプリミティブ自動パラメータ化を行っているか - [ ]
and()/or()相当の可変条件合成で空引数・単一引数の最適化を行っているか - [ ] 型判別に
instanceofではなくシンボルベースの判定を使っているか(マルチパッケージ環境を想定する場合) - [ ] 文字列化(レンダリング)をビルド時ではなく出力時に遅延させ、方言ごとのエスケープ戦略を注入可能にしているか