運用と注意点

SQL Injection 対策、可読性、テスト、書き方の流儀、計測、典型アンチパターン。

SQL Injection 対策

絶対 NG

// ユーザ入力を文字列連結
const sql = `SELECT * FROM users WHERE email = '${email}'`

OK: プレースホルダ

// pg
client.query("SELECT * FROM users WHERE email = $1", [email])

// postgres.js(テンプレートリテラル)
sql`SELECT * FROM users WHERE email = ${email}`

// SQLite (better-sqlite3)
db.prepare("SELECT * FROM users WHERE email = ?").get(email)

// Prisma など ORM 経由は基本安全
prisma.user.findUnique({ where: { email } })

動的な識別子(テーブル名 / 列名)

プレースホルダは値専用。識別子を動的にしたい場合は厳密にホワイトリストで:

const allowed = ["created_at", "name", "email"]
if (!allowed.includes(orderBy)) throw new Error("invalid")
sql.unsafe(`SELECT * FROM users ORDER BY ${orderBy}`)

クエリの書き方(流儀)

SELECT
  p.id,
  p.title,
  u.email AS author
FROM posts p
JOIN users u ON u.id = p.user_id
WHERE p.status = 'published'
  AND p.created_at >= NOW() - INTERVAL '7 days'
ORDER BY p.created_at DESC
LIMIT 20;

命名のルール

SQL のテスト

pgTap の例

BEGIN;
SELECT plan(2);

INSERT INTO users (email) VALUES ('a@ex.com');
SELECT is(count(*)::int, 1, 'one user inserted') FROM users;

INSERT INTO users (email) VALUES ('a@ex.com');  -- 重複
-- ↑ ROLLBACK されるべき → throws_ok

SELECT * FROM finish();
ROLLBACK;

計測の習慣

典型アンチパターン

1. SELECT *

必要な列だけ。列追加で挙動が変わる、転送量増、Index Only Scan の機会喪失。

2. ループの中でクエリ(N+1)

JOIN / IN にまとめる。

3. WHERE で関数呼び出し

インデックスが効かなくなる。関数式インデックスにする。

4. NOT IN + NULL

NULL が混じると常に空。NOT EXISTS に書き換え。

5. OFFSET 100000

後ろのページほど遅い。カーソル式に。

6. 巨大 IN

IN (1000 個) は遅い。= ANY(VALUES (...)) や JOIN に。

7. 再帰なしのツリー走査

アプリで何度もクエリ → 再帰 CTE で 1 回。

8. 集計しないで count(*) を毎回

頻出ならカウンタ列 / マテビュー / キャッシュ。

SQL のスタイルガイド

SQL のフォーマッタ

典型レシピ集

「Top N per group」

SELECT * FROM (
  SELECT *, ROW_NUMBER() OVER (PARTITION BY category ORDER BY views DESC) AS rn
  FROM posts
) t WHERE rn <= 3;

「Distinct on (Postgres)」

SELECT DISTINCT ON (user_id) *
FROM posts
ORDER BY user_id, created_at DESC;

「カーソル式ページング」

SELECT * FROM posts
WHERE (created_at, id) < ($1, $2)   -- 前回の最終値
ORDER BY created_at DESC, id DESC
LIMIT 20;

「期間をまたぐ集計」

SELECT
  date_trunc('day', generated.day) AS day,
  COALESCE(stats.count, 0) AS posts
FROM generate_series(
  CURRENT_DATE - INTERVAL '30 days',
  CURRENT_DATE,
  '1 day'
) AS generated(day)
LEFT JOIN (
  SELECT date_trunc('day', created_at) AS day, count(*) FROM posts GROUP BY 1
) stats ON stats.day = generated.day
ORDER BY day;

SQL を学び続けるリソース

本セクションの総まとめ

  1. SELECT / WHERE / ORDER / LIMIT を確実に
  2. JOIN を図で理解する
  3. GROUP BY と集計関数
  4. Window 関数で世界が広がる
  5. CTE で段階分解
  6. JSON / 配列 / 全文検索などの応用
  7. EXPLAIN を読めるように
  8. SQL Injection / NULL の罠を回避
最後に

SQL は50 年以上現役。学んだ知識は陳腐化しない。 ORM を使っていても、根底の SQL を理解していると性能のチューニング複雑なレポートが桁違いに速く書ける。 投資価値の高い言語の 1 つ。