Skip to content

SQL チートシート

リレーショナルデータベースの管理とクエリのための標準言語。

01

SELECT とクエリの基礎

SELECT、WHERE、ORDER BY

SELECT は1つ以上のテーブルから行を取得します。パフォーマンスと明確さのため * ではなく列を明示的に指定してください(スキーマ変更がアプリを壊しません)。WHERE はグループ化の前に行をフィルタします。ORDER BY は結果をソートします(ASC がデフォルト、DESC が降順)。LIMIT/OFFSET はページネーションを実装します — 大規模データセットにはキーセットページネーション(WHERE id > last_id)を優先します。

sql
-- basic query: select specific columns
SELECT id, name, email
FROM users
WHERE age >= 18 AND status = 'active'
ORDER BY name ASC, created_at DESC
LIMIT 10 OFFSET 0;

-- select all columns (avoid in production)
SELECT * FROM products;

-- column aliases with AS
SELECT name AS product_name, price * 1.1 AS price_with_tax
FROM products;

DISTINCT とエイリアス

DISTINCT は結果セットから重複行を削除します。個々の列ではなく行全体に作用します — SELECT DISTINCT city, country は一意の city+country ペアを返します。テーブルエイリアス(u、o)はクエリを短縮し、テーブルを自己結合する場合に必要です。列エイリアスは可読性のために出力列をリネームします。

sql
-- unique values only
SELECT DISTINCT country FROM users;
SELECT DISTINCT city, country FROM users;  -- unique combos

-- table aliases (essential for joins)
SELECT u.name, o.total
FROM users AS u
JOIN orders AS o ON u.id = o.user_id;

-- column alias (AS is optional)
SELECT name product_name, COUNT(*) count
FROM products
GROUP BY name;

フィルタリング:BETWEEN、IN、IS NULL

BETWEEN は両端を含みます。IN はリストまたはサブクエリの任意の値にマッチします。NULL には IS NULL / IS NOT NULL が必要です(= NULL は使用不可)。NOT IN とサブクエリに注意してください — サブクエリが NULL を返す場合、NOT IN は行を全く返しません。NOT EXISTS を使用してください。NULL を正しく処理し、多くの場合より高速です。

sql
SELECT * FROM products
WHERE price BETWEEN 10 AND 100        -- inclusive range
  AND category IN ('tech', 'home')    -- match any value
  AND stock IS NOT NULL               -- exclude NULLs
  AND discount IS NULL;               -- only NULLs

-- NOT IN with NULL caveat: returns nothing if subquery has NULL!
SELECT * FROM users
WHERE id NOT IN (SELECT user_id FROM orders);  -- risky if NULLs

-- safer: NOT EXISTS
SELECT * FROM users u
WHERE NOT EXISTS (
  SELECT 1 FROM orders o WHERE o.user_id = u.id
);

LIKE とパターンマッチング

LIKE は %(ゼロ以上の文字)と _(正確に1文字)をワイルドカードとして使用します。LIKE は MySQL 以外のほとんどのデータベースで大文字小文字を区別します(MySQL はデフォルトで大文字小文字を区別しません)。PostgreSQL では大文字小文字を区別しないマッチングに ILIKE を使用します。複雑なパターンには正規表現を使用します(PostgreSQL では ~、MySQL では REGEXP)。先頭の % 付き LIKE はインデックスを使用できません — パフォーマンスのためにフルテキスト検索を検討してください。

sql
-- LIKE: basic pattern matching
SELECT * FROM users WHERE name LIKE 'A%';     -- starts with A
SELECT * FROM users WHERE name LIKE '%son';    -- ends with son
SELECT * FROM users WHERE name LIKE '%a%';     -- contains a
SELECT * FROM users WHERE name LIKE '_a%';     -- second char is a

-- ILIKE (PostgreSQL): case-insensitive
SELECT * FROM users WHERE name ILIKE 'a%';

-- SIMILAR TO (PostgreSQL): regex-like
SELECT * FROM users WHERE name SIMILAR TO '[AB]%';

-- full regex (PostgreSQL)
SELECT * FROM users WHERE name ~ '^A[a-z]+$';

CASE 式

CASE は SQL の if-then-else で、行ごとに評価されます。SELECT、WHERE、ORDER BY、HAVING に現れます。'ピボット'パターン(CASE の SUM)は行を列に変換します — レポートに便利です。CASE は WHEN がマッチせず ELSE がない場合 NULL を返します。予測可能な結果のために常に ELSE を含めてください。

sql
-- conditional logic in queries
SELECT
  name,
  price,
  CASE
    WHEN price < 10 THEN 'cheap'
    WHEN price < 50 THEN 'moderate'
    WHEN price < 100 THEN 'expensive'
    ELSE 'luxury'
  END AS price_category
FROM products;

-- CASE in aggregation (pivot table)
SELECT
  category,
  COUNT(*) AS total,
  SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) AS active_count,
  SUM(CASE WHEN status = 'inactive' THEN 1 ELSE 0 END) AS inactive_count
FROM products
GROUP BY category;
02

JOIN

INNER JOIN

INNER JOIN は両方のテーブルでマッチする行のみを返します。JOIN は INNER JOIN の短縮形です。マルチテーブルクエリでは、テーブルを段階的に結合します。ON は結合条件を指定し、USING(column) は両方のテーブルが同じ列名を持つ場合の短縮形です。内部結合は両側から非マッチング行を除外します。

sql
-- only matching rows from both tables
SELECT u.name, o.total, o.created_at
FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE o.total > 100
ORDER BY o.total DESC;

-- multiple joins
SELECT u.name, o.total, p.product_name
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id;

-- USING (when column names match)
SELECT * FROM users
JOIN profiles ON users.id = profiles.user_id;

LEFT JOIN(LEFT OUTER JOIN)

LEFT JOIN は左テーブルのすべての行を返し、非マッチング右行には NULL を入れます。これは「すべて含める」クエリに不可欠です。アンチジョインパターン(WHERE right.id IS NULL)は右にマッチがない左テーブルの行を見つけます — 「注文していないユーザー」に便利です。COUNT(right.id) は非 NULL 値をカウントするため、注文のないユーザーには 0 を返します。

sql
-- all users, with their orders (NULL if no orders)
SELECT u.name, o.total
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
ORDER BY u.name;

-- find users with NO orders (anti-join pattern)
SELECT u.name
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;

-- count orders per user (including zero)
SELECT u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;

RIGHT と FULL OUTER JOIN

RIGHT JOIN はすべての右テーブル行を返します。テーブルを入れ替えて LEFT JOIN を使用するのと同等です(より読みやすい)。FULL OUTER JOIN は両方のテーブルのすべての行を返し、マッチがない場合は NULL を入れます — データ照合に便利です。MySQL は FULL OUTER JOIN を直接サポートしません。LEFT JOIN UNION RIGHT JOIN でエミュレートします。

sql
-- RIGHT JOIN: all rows from right table
SELECT u.name, o.total
FROM users u
RIGHT JOIN orders o ON u.id = o.user_id;
-- returns all orders, even orphaned ones (user_id = NULL)

-- FULL OUTER JOIN: all rows from both tables
SELECT u.name, o.total
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id;
-- returns all users AND all orders, matching where possible

-- Note: RIGHT JOIN is rarely used (just swap tables and use LEFT)
-- FULL OUTER JOIN is useful for finding mismatches between tables
SELECT u.name, o.total
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id
WHERE u.id IS NULL OR o.user_id IS NULL;

CROSS JOIN と自己結合

CROSS JOIN はデカルト積を生成します — A のすべての行と B のすべての行をペアにします。組み合わせの生成(サイズ × 色)に使用します。自己結合(テーブルを自身に結合)は階層データ(従業員-管理者)、重複の発見、同じテーブル内の行の比較によく使用されます。自己結合では常にテーブルエイリアスを使用して2つの「コピー」を区別します。

sql
-- CROSS JOIN: Cartesian product (every combination)
SELECT s.size, c.color
FROM sizes s
CROSS JOIN colors c;
-- produces all size+color combinations

-- Self join: join a table to itself
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;

-- self join for hierarchical data
SELECT c.name AS child, p.name AS parent
FROM categories c
JOIN categories p ON c.parent_id = p.id;

-- self join to find duplicates
SELECT a.id, a.email, b.id AS dup_id
FROM users a
JOIN users b ON a.email = b.email AND a.id < b.id;

NATURAL JOIN と JOIN タイプのまとめ

NATURAL JOIN は同じ名前の列で自動的に結合します — 便利ですが、スキーマ変更が結合動作を黙って変更する可能性があるため危険です。本番では避けてください。LATERAL 結合はサブクエリが外部クエリの列を参照できるようにします — 「グループごとのトップ N」クエリに強力です。カンマ構文(FROM a, b)は CROSS JOIN と同等です。

sql
-- NATURAL JOIN: joins on all matching column names
-- (rarely recommended — implicit, fragile)
SELECT * FROM users NATURAL JOIN profiles;
-- joins on any column that exists in BOTH tables

-- Summary of join types:
-- INNER JOIN : matching rows only
-- LEFT JOIN  : all left + matching right
-- RIGHT JOIN : all right + matching left
-- FULL JOIN  : all from both sides
-- CROSS JOIN : Cartesian product
-- SELF JOIN  : table joined to itself

-- LATERAL JOIN (PostgreSQL): subquery can reference outer query
SELECT u.name, recent.*
FROM users u,
LATERAL (
  SELECT * FROM orders o
  WHERE o.user_id = u.id
  ORDER BY o.created_at DESC
  LIMIT 3
) recent;
03

GROUP BY と集計

GROUP BY と HAVING

GROUP BY は行をグループに折りたたみ、グループごとに1行にします。集計関数(COUNT、SUM、AVG、MIN、MAX)は各グループで動作します。WHERE はグループ化の前に個々の行をフィルタし、HAVING は集計後にグループをフィルタします。SELECT の非集計列は GROUP BY に現れなければなりません(標準 SQL)。MySQL は寛容ですが予測不能です — すべての非集計列を GROUP BY に含めてください。

sql
-- aggregate per group
SELECT category, COUNT(*) AS cnt, AVG(price) AS avg_price
FROM products
GROUP BY category
HAVING COUNT(*) > 5
ORDER BY cnt DESC;

-- HAVING filters groups (after aggregation)
-- WHERE filters rows (before aggregation)
SELECT dept, AVG(salary) AS avg_sal
FROM employees
WHERE status = 'active'       -- filter rows first
GROUP BY dept
HAVING AVG(salary) > 50000;   -- then filter groups

集計関数

COUNT(*) は NULL を含むすべての行をカウントし、COUNT(column) は非 NULL 値のみをカウントします。COUNT(DISTINCT col) は一意の値をカウントします。SUM/AVG は NULL を無視します。AVG = SUM/COUNT(非 NULL)なので、NULL は平均に影響します。STRING_AGG(PostgreSQL)/ GROUP_CONCAT(MySQL)はグループごとに文字列を連結します。BOOL_OR/BOOL_AND はいずれか/すべての値が true の場合 true を返します。

sql
SELECT
  COUNT(*) AS total_rows,           -- counts all rows
  COUNT(email) AS emails_filled,    -- counts non-NULL emails
  COUNT(DISTINCT country) AS countries,
  SUM(amount) AS total_revenue,
  AVG(amount) AS avg_order,
  MIN(amount) AS smallest_order,
  MAX(amount) AS largest_order,
  -- string aggregation (PostgreSQL)
  STRING_AGG(name, ', ') AS all_names,
  -- boolean aggregation
  BOOL_OR(is_active) AS any_active,
  BOOL_AND(is_active) AS all_active
FROM orders;

複数列の GROUP BY

複数列でグループ化するとグループの階層が作成されます。WITH ROLLUP は小計と総計行を追加します(グループ化列に NULL)。GROUPING SETS はどのグループ化組み合わせが必要かを正確に指定できます — ROLLUP より柔軟です。CUBE はすべての可能なグループ化組み合わせを生成します。これらはレポートと OLAP クエリに不可欠です。

sql
-- multi-level grouping
SELECT
  EXTRACT(YEAR FROM created_at) AS yr,
  EXTRACT(MONTH FROM created_at) AS mon,
  category,
  COUNT(*) AS cnt,
  SUM(total) AS revenue
FROM orders
GROUP BY yr, mon, category
ORDER BY yr DESC, mon DESC, cnt DESC;

-- GROUP BY with ROLLUP (subtotals + grand total)
SELECT category, COUNT(*) AS cnt
FROM products
GROUP BY category WITH ROLLUP;
-- last row has NULL category = grand total

-- GROUPING SETS (PostgreSQL): specify multiple groupings
SELECT category, status, COUNT(*)
FROM products
GROUP BY GROUPING SETS ((category, status), (category), ());

HAVING と WHERE

主な区別:WHERE は集計前に個々の行をフィルタし(SUM、COUNT などを使用不可)、HAVING は集計後にグループをフィルタします(集計関数を使用可)。WHERE で早期にデータを削減し(より良いパフォーマンス)、次に HAVING で集計結果をフィルタします。両方が同じクエリに現れます — 最初に WHERE、次に GROUP BY、次に HAVING。

sql
-- WHERE: filters rows BEFORE grouping
-- Cannot use aggregates
SELECT category, COUNT(*) AS cnt
FROM products
WHERE price > 10          -- OK: filter on raw column
GROUP BY category;

-- HAVING: filters groups AFTER grouping
-- Can use aggregates
SELECT category, COUNT(*) AS cnt
FROM products
GROUP BY category
HAVING COUNT(*) > 5       -- OK: filter on aggregate
   AND AVG(price) > 20;   -- OK: multiple aggregate filters

-- combining both
SELECT category, COUNT(*) AS cnt
FROM products
WHERE price > 10          -- filter rows
GROUP BY category         -- group
HAVING COUNT(*) > 5;      -- filter groups

日付/時刻の集計

日付切り捨ては時系列レポートに不可欠です。DATE(col) は日付のみを抽出し、EXTRACT/TIME_PART は特定のコンポーネント(年、月、時間)を取得します。TO_CHAR はグループ化と表示のために日付をフォーマットします。時系列分析には、タイムスタンプ型を保持する DATE_TRUNC('month', col) を検討してください。大きなテーブルのパフォーマンスのために日付列にインデックスを付けます。

sql
-- group by date parts
SELECT
  DATE(created_at) AS order_date,
  COUNT(*) AS orders,
  SUM(total) AS revenue
FROM orders
GROUP BY DATE(created_at)
ORDER BY order_date DESC;

-- group by hour
SELECT
  EXTRACT(HOUR FROM created_at) AS hr,
  COUNT(*) AS cnt
FROM orders
WHERE created_at >= CURRENT_DATE
GROUP BY hr
ORDER BY hr;

-- monthly revenue trend
SELECT
  TO_CHAR(created_at, 'YYYY-MM') AS month,
  SUM(total) AS revenue,
  COUNT(*) AS orders,
  AVG(total) AS avg_order
FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY month
ORDER BY month;
04

サブクエリと CTE

スカラーと列サブクエリ

スカラーサブクエリは単一値を返し、値が期待されるどこでも使用できます。列サブクエリは1列を返し、IN、ANY、ALL で使用されます。SELECT のサブクエリ(相関)は外部行ごとに1回実行されます — 大規模データセットで遅くなる可能性があります。より良いパフォーマンスのために GROUP BY 付き JOIN として書き直すことを検討してください。

sql
-- scalar subquery (returns single value)
SELECT name, age
FROM users
WHERE age > (SELECT AVG(age) FROM users);

-- column subquery (returns one column, multiple rows)
SELECT name
FROM users
WHERE id IN (SELECT user_id FROM orders WHERE total > 100);

-- subquery in SELECT
SELECT
  u.name,
  (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_count
FROM users u;

相関サブクエリと EXISTS

相関サブクエリは外部クエリを参照し、外部行ごとに1回実行されます — 潜在的に遅いです。EXISTS/NOT EXISTS はショートサーキットするため(最初のマッチで停止)効率的です。NOT EXISTS は「マッチする行のない行」を見つける推奨方法です — NULL を正しく処理し、多くの場合 NOT IN より高速です。データベースは相関サブクエリを結合に最適化する場合があります。

sql
-- correlated: subquery references outer query
SELECT u.name
FROM users u
WHERE EXISTS (
  SELECT 1 FROM orders o
  WHERE o.user_id = u.id
    AND o.total > 1000
);

-- NOT EXISTS: users without any orders
SELECT u.name
FROM users u
WHERE NOT EXISTS (
  SELECT 1 FROM orders o WHERE o.user_id = u.id
);

-- correlated subquery in SELECT (runs per row)
SELECT
  u.name,
  (SELECT MAX(o.total)
   FROM orders o
   WHERE o.user_id = u.id) AS max_order
FROM users u;

共通テーブル式(CTE)

CTE(WITH 節)は複雑なクエリを読みやすくする名前付き一時結果セットを作成します。サブクエリとは異なり、CTE は複数回参照でき、上から下に読めます。ほとんどのデータベースで、CTE はインライン化されます(最適化はクエリレベルで行われます)。PostgreSQL 12+ は MATERIALIZED/NOT MATERIALIZED ヒントをサポートします。CTE は再帰クエリにも必要です。

sql
-- CTE: named temporary result set
WITH active_users AS (
  SELECT id, name FROM users WHERE status = 'active'
),
user_orders AS (
  SELECT user_id, COUNT(*) AS cnt, SUM(total) AS revenue
  FROM orders
  GROUP BY user_id
)
SELECT au.name, uo.cnt, uo.revenue
FROM active_users au
LEFT JOIN user_orders uo ON au.id = uo.user_id
ORDER BY uo.revenue DESC NULLS LAST;

-- CTEs improve readability for complex queries
-- They are NOT materialized (just syntactic sugar) in most DBs
-- (PostgreSQL 12+ can materialize with MATERIALIZED keyword)

再帰 CTE

再帰 CTE は自身を参照し、ツリー/グラフトラバーサルとシーケンス生成を可能にします。構造:ベースケース UNION ALL 再帰ケース。再帰ケースは CTE を参照し、終了しなければなりません(無限ループを防ぐため WHERE を追加)。一般的な用途:組織図、カテゴリツリー、依存関係グラフ、日付シーケンス。各データベースで構文が少し異なります — DBMS ドキュメントを確認してください。

sql
-- hierarchical data: org chart
WITH RECURSIVE org_tree AS (
  -- base case: top-level managers
  SELECT id, name, manager_id, 1 AS level
  FROM employees
  WHERE manager_id IS NULL

  UNION ALL

  -- recursive case: direct reports
  SELECT e.id, e.name, e.manager_id, ot.level + 1
  FROM employees e
  JOIN org_tree ot ON e.manager_id = ot.id
)
SELECT level, name FROM org_tree ORDER BY level, name;

-- generate a series of dates
WITH RECURSIVE dates AS (
  SELECT DATE '2024-01-01' AS d
  UNION ALL
  SELECT d + 1 FROM dates WHERE d < '2024-01-31'
)
SELECT d FROM dates;

-- factorial
WITH RECURSIVE fact(n, result) AS (
  SELECT 1, 1
  UNION ALL
  SELECT n + 1, result * (n + 1) FROM fact WHERE n < 10
)
SELECT * FROM fact;

サブクエリ演算子:ANY、ALL

ANY と ALL は値をサブクエリ結果セットと比較します。> ANY は「少なくとも1つより大きい」を意味します。> ALL は「すべてより大きい」を意味します。= ANY は IN と同等です。<> ALL は NOT IN と同等ですが NULL をより安全に処理します。これらの演算子は IN/EXISTS より使用頻度は低いですが、特定のクエリをより自然に表現できます。

sql
-- ANY: greater than ANY of the values (= at least one)
SELECT * FROM products
WHERE price > ANY (
  SELECT price FROM products WHERE category = 'tech'
);
-- true if price exceeds at least one tech product's price

-- ALL: greater than ALL values (= every one)
SELECT * FROM products
WHERE price > ALL (
  SELECT price FROM products WHERE category = 'tech'
);
-- true if price exceeds every tech product's price

-- = ANY is equivalent to IN
SELECT * FROM users
WHERE id = ANY (SELECT user_id FROM orders);

-- <> ALL is equivalent to NOT IN (but NULL-safe)
SELECT * FROM users
WHERE id <> ALL (SELECT user_id FROM orders WHERE total < 0);
05

ウィンドウ関数

ROW_NUMBER、RANK、DENSE_RANK

ROW_NUMBER は一意の連番(1、2、3...)を割り当てます。RANK は同順位に同じランクを与えますが後続の番号をスキップします(1、1、3)。DENSE_RANK はスキップせずに同順位に同じランクを与えます(1、1、2)。PARTITION BY は行をグループに分割し、関数はパーティションごとにリセットします。「グループごとのトップ N」パターン(ROW_NUMBER + WHERE rn <= N)は分析で非常に一般的です。

sql
SELECT
  name,
  salary,
  dept,
  ROW_NUMBER() OVER (ORDER BY salary DESC) AS row_num,
  RANK() OVER (ORDER BY salary DESC) AS rank,
  DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rank
FROM employees;

-- top 3 earners per department
SELECT * FROM (
  SELECT
    name,
    dept,
    salary,
    ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
  FROM employees
) ranked
WHERE rn <= 3;

LAG と LEAD

LAG は前の行の値にアクセスし、LEAD は次の行の値にアクセスします。両方ともオプションのオフセット(デフォルト 1)とデフォルト値(デフォルト NULL)を受け入れます。時系列分析に不可欠です:日々の変化、移動比較、ギャップ検出。NULLIF はパーセンテージ計算でのゼロ除算を防ぎます。決定的な結果のために OVER 節で常に ORDER BY を指定してください。

sql
-- compare each row to previous/next
SELECT
  date,
  revenue,
  LAG(revenue) OVER (ORDER BY date) AS prev_day,
  LEAD(revenue) OVER (ORDER BY date) AS next_day,
  revenue - LAG(revenue) OVER (ORDER BY date) AS daily_change,
  ROUND(
    (revenue - LAG(revenue) OVER (ORDER BY date)) * 100.0
    / NULLIF(LAG(revenue) OVER (ORDER BY date), 0),
    2
  ) AS pct_change
FROM daily_sales
ORDER BY date;

-- LAG with offset and default
SELECT
  date,
  revenue,
  LAG(revenue, 7) OVER (ORDER BY date) AS revenue_7_days_ago
FROM daily_sales;

累計合計と移動平均

ウィンドウフレームは関数が動作する行を定義します。ROWS BETWEEN は物理行オフセットを使用し、RANGE は論理値範囲を使用します(日付ギャップに適しています)。UNBOUNDED PRECEDING は「最初から」を意味します。累計合計(累積 SUM)と移動平均が最も一般的な分析パターンです。フレームなしでは、集計ウィンドウ関数はデフォルトを使用します:RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。

sql
-- cumulative sum (running total)
SELECT
  date,
  revenue,
  SUM(revenue) OVER (ORDER BY date) AS running_total,
  SUM(revenue) OVER (ORDER BY date
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS last_7_days_sum,
  AVG(revenue) OVER (ORDER BY date
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS moving_avg_7d
FROM daily_sales
ORDER BY date;

-- window frame options:
-- ROWS BETWEEN n PRECEDING AND n FOLLOWING  -- physical rows
-- RANGE BETWEEN INTERVAL '7' DAY PRECEDING AND CURRENT ROW -- logical range
-- ROWS UNBOUNDED PRECEDING = from start to current
-- ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING = all rows

NTILE と PERCENT_RANK

NTILE(n) は順序付けされた行を n 個のほぼ等しいグループに分割します(四分位数、十分位数、百分位数)。PERCENT_RANK は相対ランク(0 から 1)を与えます。CUME_DIST は累積分布を与えます。FIRST_VALUE/LAST_VALUE はフレーム内の最初/最後の行から値を返します — LAST_VALUE には明示的なフレーム(UNBOUNDED FOLLOWING)が必要なことに注意してください。デフォルトフレームは現在の行で終了するためです。

sql
-- divide into quartiles
SELECT
  name,
  salary,
  NTILE(4) OVER (ORDER BY salary DESC) AS quartile,
  PERCENT_RANK() OVER (ORDER BY salary) AS pct_rank,
  CUME_DIST() OVER (ORDER BY salary) AS cumulative_dist
FROM employees;

-- first/last value in a partition
SELECT
  dept,
  name,
  salary,
  FIRST_VALUE(name) OVER (PARTITION BY dept ORDER BY salary DESC) AS top_earner,
  LAST_VALUE(name) OVER (
    PARTITION BY dept ORDER BY salary DESC
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
  ) AS lowest_earner
FROM employees;

ウィンドウ集計関数

ウィンドウ集計(OVER 付きの SUM、AVG、COUNT など)は行を折りたたむことなく集計値を計算します — 各行に集計が付加されます。これが GROUP BY との主な違いです:要約を見ながらすべての詳細行を保持します。個別値とグループ平均の比較、パーセンテージの計算、詳細レポートへのコンテキスト列の追加に最適です。

sql
-- aggregates over windows (no row collapse!)
SELECT
  name,
  dept,
  salary,
  -- compare to department average
  AVG(salary) OVER (PARTITION BY dept) AS dept_avg,
  salary - AVG(salary) OVER (PARTITION BY dept) AS diff_from_avg,
  -- percentage of department total
  salary * 100.0 / SUM(salary) OVER (PARTITION BY dept) AS pct_of_dept,
  -- count per department
  COUNT(*) OVER (PARTITION BY dept) AS dept_size
FROM employees
ORDER BY dept, salary DESC;

-- key advantage: aggregates without GROUP BY
-- every row is preserved, with the aggregate value attached
06

DDL:テーブルとスキーマ

CREATE TABLE とデータ型

CREATE TABLE はスキーマを定義します。SERIAL(PostgreSQL)/ AUTO_INCREMENT(MySQL)が ID を自動生成します。VARCHAR(n) には制限があり、TEXT は無制限です。DECIMAL(p,s) は正確(お金に使用!)、FLOAT は近似です。CHECK 制約はビジネスルールを強制します。DEFAULT は未指定時の値を提供します。JSONB(PostgreSQL)はインデックス付き JSON クエリを可能にします。タイムゾーンをまたぐタイムスタンプには常に TIMESTAMP WITH TIME ZONE を使用してください。

sql
CREATE TABLE users (
  id          SERIAL PRIMARY KEY,          -- auto-increment (PostgreSQL)
  -- MySQL: id INT AUTO_INCREMENT PRIMARY KEY
  username    VARCHAR(50) UNIQUE NOT NULL,
  email       VARCHAR(255) UNIQUE NOT NULL,
  password    VARCHAR(255) NOT NULL,
  age         INT CHECK (age >= 0 AND age <= 150),
  salary      DECIMAL(10, 2) DEFAULT 0.00, -- precision, scale
  bio         TEXT,                        -- unlimited length
  avatar      BYTEA,                       -- binary (PostgreSQL)
  metadata    JSONB,                       -- JSON (PostgreSQL)
  status      VARCHAR(20) DEFAULT 'active',
  created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- common types:
-- INT, BIGINT, SMALLINT, DECIMAL(p,s), NUMERIC
-- VARCHAR(n), CHAR(n), TEXT
-- DATE, TIME, TIMESTAMP, INTERVAL
-- BOOLEAN, UUID, JSON/JSONB, BYTEA/BLOB

制約:PRIMARY、FOREIGN、UNIQUE、CHECK

制約はデータベースレベルでデータ整合性を強制します。PRIMARY KEY は行を一意に識別し(NOT NULL + UNIQUE を意味)、FOREIGN KEY は参照整合性を維持します — ON DELETE CASCADE は親が削除されると子を削除します。UNIQUE は重複を防ぎます。CHECK はカスタムルールを強制します。データベースで制約を定義することで(アプリコードだけでなく)、データへのアクセス方法に関わらず整合性を保証します。

sql
CREATE TABLE orders (
  id          SERIAL PRIMARY KEY,
  user_id     INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  total       DECIMAL(10,2) NOT NULL CHECK (total >= 0),
  status      VARCHAR(20) NOT NULL DEFAULT 'pending',
  created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

  -- table-level constraints
  CONSTRAINT valid_status CHECK (status IN ('pending','paid','shipped','cancelled')),
  CONSTRAINT unique_user_order UNIQUE (user_id, created_at)
);

-- foreign key actions:
-- ON DELETE CASCADE  : delete child rows when parent deleted
-- ON DELETE SET NULL : set FK to NULL (column must be nullable)
-- ON DELETE RESTRICT : prevent parent deletion (default)
-- ON UPDATE CASCADE  : update FK when parent PK changes

-- add constraint to existing table
ALTER TABLE orders ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id);

ALTER TABLE

ALTER TABLE は既存のテーブル構造を変更します。デフォルト付きの列追加は通常高速です(PostgreSQL 11+ はテーブルを書き換えません)。列の削除はテーブルをロックする可能性があります。列型の変更はフルテーブル書き換えが必要で、データが変換されない場合失敗する可能性があります。スキーママイグレーションは常にコピーで最初にテストしてください。バージョン管理されたスキーマ変更にマイグレーションツール(Flyway、Alembic、Rails migrations)を使用します。

sql
-- add column
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
ALTER TABLE users ADD COLUMN verified BOOLEAN DEFAULT false;

-- drop column
ALTER TABLE users DROP COLUMN avatar;

-- rename column/table
ALTER TABLE users RENAME COLUMN username TO login;
ALTER TABLE users RENAME TO accounts;

-- change column type
ALTER TABLE users ALTER COLUMN age TYPE BIGINT;
-- MySQL: ALTER TABLE users MODIFY COLUMN age BIGINT;

-- add/drop constraints
ALTER TABLE users ADD CONSTRAINT email_unique UNIQUE (email);
ALTER TABLE users DROP CONSTRAINT email_unique;

-- set default
ALTER TABLE users ALTER COLUMN status SET DEFAULT 'active';

DROP、TRUNCATE とインデックス

DROP TABLE はテーブル全体を削除し、TRUNCATE は構造を保持して空にします(DELETE よりはるかに高速、ID をリセット)。インデックスはクエリを高速化しますが書き込みを遅くします — 戦略的にインデックスを作成します。複合インデックスは左から右に動作します:idx(a,b,c) は WHERE a=?、WHERE a=? AND b=? を助けますが、WHERE b=? は助けません。GIN インデックスはフルテキスト検索を可能にします。部分インデックスはマッチする行のみをインデックス化してスペースを節約します。

sql
-- DROP: permanently remove table (structure + data)
DROP TABLE IF EXISTS old_logs CASCADE;
-- CASCADE drops dependent objects (views, FKs)

-- TRUNCATE: remove all data, keep structure (faster than DELETE)
TRUNCATE TABLE logs;
TRUNCATE TABLE logs RESTART IDENTITY;  -- reset SERIAL counter
TRUNCATE TABLE orders, order_items CASCADE;  -- multiple tables

-- CREATE INDEX for query performance
CREATE INDEX idx_users_email ON users(email);
CREATE UNIQUE INDEX idx_users_username ON users(username);
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at);
CREATE INDEX idx_products_name ON products USING gin(to_tsvector('english', name));

-- partial index (PostgreSQL)
CREATE INDEX idx_active_users ON users(last_login)
WHERE status = 'active';

-- DROP INDEX
DROP INDEX IF EXISTS idx_users_email;

ビューとマテリアライズドビュー

ビューは仮想テーブルとして動作する保存済みクエリです — 毎回基盤クエリを実行します。複雑なクエリの簡素化、セキュリティの強制(列レベルアクセス)、安定した API の提供にビューを使用します。マテリアライズドビューは実際の結果を格納します — クエリは高速ですがリフレッシュが必要です。リアルタイムデータが不要な高コスト集計にマテリアライズドビューを使用します。CONCURRENTLY はロックなしでリフレッシュします(PostgreSQL)。

sql
-- VIEW: stored query (virtual table, runs on access)
CREATE VIEW active_users AS
SELECT id, name, email FROM users WHERE status = 'active';

SELECT * FROM active_users WHERE name LIKE 'A%';

-- updatable view (simple views can be INSERTed/UPDATEd)
CREATE VIEW user_summary AS
SELECT id, name, email, age FROM users;

-- MATERIALIZED VIEW: stored result (must refresh)
CREATE MATERIALIZED VIEW monthly_stats AS
SELECT
  DATE_TRUNC('month', created_at) AS month,
  COUNT(*) AS orders,
  SUM(total) AS revenue
FROM orders
GROUP BY month;

REFRESH MATERIALIZED VIEW monthly_stats;
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_stats; -- no lock

-- drop
DROP VIEW IF EXISTS active_users;
DROP MATERIALIZED VIEW IF EXISTS monthly_stats;
07

DML:Insert、Update、Delete

INSERT

INSERT は行を追加します。1文で複数 VALUES は個別挿入より効率的です。INSERT...SELECT はテーブル間でデータをコピーします。RETURNING(PostgreSQL/Oracle)は1ラウンドトリップで自動生成値(SERIAL id など)を取得します — アプリケーションコードに不可欠。すべてデフォルトで行を挿入するには DEFAULT VALUES を使用します。コードをスキーマ変更に対して堅牢にするため、常に列名を指定してください。

sql
-- single row
INSERT INTO users (name, email, age)
VALUES ('Alice', '[email protected]', 30);

-- multiple rows
INSERT INTO users (name, email) VALUES
  ('Bob', '[email protected]'),
  ('Carol', '[email protected]'),
  ('Dave', '[email protected]');

-- INSERT ... SELECT (copy data between tables)
INSERT INTO archive_users (name, email, deleted_at)
SELECT name, email, NOW()
FROM users
WHERE status = 'deleted';

-- INSERT with RETURNING (PostgreSQL)
INSERT INTO users (name, email)
VALUES ('Eve', '[email protected]')
RETURNING id, created_at;  -- returns the generated id

-- DEFAULT values
INSERT INTO users DEFAULT VALUES;

UPDATE

UPDATE は既存の行を変更します。すべての行を更新する意図がない限り、常に WHERE 節を含めてください。FROM 節(PostgreSQL)は更新で結合を許可します。RETURNING はどの行が変更されたかを表示します。問題が発生した場合に ROLLBACK できるよう、マルチステップ更新にはトランザクションを使用します。一般的な間違いは WHERE の忘れです — 影響を受ける行を確認するために最初に同じ WHERE で SELECT を実行することを検討してください。

sql
-- basic update
UPDATE users
SET age = 31, status = 'verified', updated_at = NOW()
WHERE id = 1;

-- update based on another table
UPDATE products p
SET price = p.price * 1.1
FROM categories c
WHERE p.category_id = c.id AND c.name = 'electronics';

-- update with subquery
UPDATE users
SET status = 'premium'
WHERE id IN (
  SELECT user_id FROM orders
  GROUP BY user_id HAVING SUM(total) > 1000
);

-- UPDATE with RETURNING (PostgreSQL)
UPDATE users SET status = 'inactive'
WHERE last_login < '2023-01-01'
RETURNING id, name;

-- WARNING: UPDATE without WHERE affects ALL rows!

DELETE と TRUNCATE

DELETE は1行ずつ行を削除します(ログあり、ロールバック可能、遅い)。TRUNCATE はすべての行を一度に削除します(最小限のログ、はるかに高速、自動インクリメントをリセット、一部の DB ではロールバック不可)。監査証跡には、ハード削除ではなくソフト削除(deleted_at タイムスタンプ)を使用します。DELETE には常に WHERE を使用します。外部キー制約を考慮してください — ON DELETE CASCADE が子行を自動的に処理します。

sql
-- delete specific rows
DELETE FROM users WHERE status = 'inactive';

-- delete with subquery
DELETE FROM orders
WHERE user_id IN (
  SELECT id FROM users WHERE status = 'deleted'
);

-- delete with RETURNING (PostgreSQL)
DELETE FROM users
WHERE last_login < '2020-01-01'
RETURNING id, name;

-- delete all rows (slow, logged, can be rolled back)
DELETE FROM logs;

-- TRUNCATE (fast, minimal logging, resets identity)
TRUNCATE TABLE logs;
TRUNCATE TABLE logs RESTART IDENTITY CASCADE;

-- soft delete pattern (preferred for audit)
UPDATE users SET deleted_at = NOW() WHERE id = 1;
SELECT * FROM users WHERE deleted_at IS NULL; -- active users

UPSERT(INSERT ... ON CONFLICT)

UPSERT(更新または挿入)は重複キーの競合を原子的に処理します。PostgreSQL は ON CONFLICT (column) DO UPDATE/DO NOTHING を使用します。MySQL は ON DUPLICATE KEY UPDATE を使用します。EXCLUDED(PostgreSQL)/ VALUES()(MySQL)は提案された挿入値を参照します。これは冪等な操作と競合状態の回避に不可欠です。UPSERT なしでは、競合状態になりやすい SELECT-then-INSERT/UPDATE が必要になります。

sql
-- PostgreSQL: ON CONFLICT (upsert)
INSERT INTO users (id, name, email)
VALUES (1, 'Alice', '[email protected]')
ON CONFLICT (id)
DO UPDATE SET email = EXCLUDED.email, updated_at = NOW()
RETURNING *;

-- DO NOTHING on conflict
INSERT INTO users (id, name, email)
VALUES (1, 'Alice', '[email protected]')
ON CONFLICT (id) DO NOTHING;

-- MySQL: ON DUPLICATE KEY UPDATE
INSERT INTO users (id, name, email)
VALUES (1, 'Alice', '[email protected]')
ON DUPLICATE KEY UPDATE email = VALUES(email);

-- SQLite: ON CONFLICT
INSERT INTO users (id, name)
VALUES (1, 'Alice')
ON CONFLICT(id) DO UPDATE SET name = excluded.name;

MERGE 文

MERGE(別名 UPSERT on steroids)は行がマッチするかどうかに基づいて INSERT、UPDATE、DELETE を1つの原子的文で組み合わせます。ソース間でデータを同期する最も効率的な方法です。WHEN MATCHED は既存行の UPDATE/DELETE をトリガーし、WHEN NOT MATCHED は新規行の INSERT をトリガーします。SQL Server、Oracle、PostgreSQL 15+、DB2 で利用可能。MySQL は MERGE をサポートしません — INSERT...ON DUPLICATE KEY を使用します。

sql
-- MERGE: conditional insert/update/delete in one statement
-- (SQL Server, Oracle, PostgreSQL 15+)
MERGE INTO products AS target
USING (VALUES
  (1, 'Widget', 9.99),
  (2, 'Gadget', 19.99),
  (3, 'Gizmo', 29.99)
) AS source (id, name, price)
ON target.id = source.id
WHEN MATCHED THEN
  UPDATE SET name = source.name, price = source.price
WHEN NOT MATCHED THEN
  INSERT (id, name, price) VALUES (source.id, source.name, source.price)
WHEN MATCHED AND source.price < 0 THEN
  DELETE;

-- useful for:
-- - syncing data from external sources
-- - bulk upsert with conditional logic
-- - ETL operations
08

トランザクションと ACID

BEGIN、COMMIT、ROLLBACK

トランザクションは操作を原子的単位にグループ化します — すべて成功(COMMIT)またはすべて失敗(ROLLBACK)。これが ACID の「A」です。BEGIN/START TRANSACTION がトランザクションを開始します。SAVEPOINT はトランザクション内の名前付きロールバックポイントを作成します — トランザクション全体を中止せずにロールバックできます。常にコミットまたはロールバックしてください — トランザクションを開いたままにするとロックを保持し、デッドロックを引き起こす可能性があります。

sql
-- basic transaction
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- both updates succeed or both fail (atomicity)

-- rollback on error
BEGIN;
  INSERT INTO orders (user_id, total) VALUES (1, 50.00);
  -- oops, something went wrong
  ROLLBACK;
-- the insert is undone

-- transaction with savepoints
BEGIN;
  INSERT INTO logs (msg) VALUES ('step 1');
  SAVEPOINT my_savepoint;
  INSERT INTO logs (msg) VALUES ('step 2');
  ROLLBACK TO my_savepoint;  -- undo step 2, keep step 1
  INSERT INTO logs (msg) VALUES ('step 3');
COMMIT;  -- commits step 1 and step 3

分離レベル

分離レベルは整合性と並行性のバランスを取ります。READ COMMITTED(PostgreSQL/Oracle のデフォルト)はダーティリードを防ぎますが非再現リードを許可します。REPEATABLE READ は非再現リードを防ぎますがファントムリードを許可します。SERIALIZABLE はすべての異常を防ぎますが並行性を削減します。より高い分離 = より多くのロック = より少ない並行性。正確性要件を満たす最低レベルを選択してください。

sql
-- set isolation level for transaction
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
  -- can see committed data from other transactions
  SELECT balance FROM accounts WHERE id = 1;
COMMIT;

-- isolation levels (from weakest to strongest):
-- READ UNCOMMITTED: can read uncommitted (dirty) data
-- READ COMMITTED: only committed data (PostgreSQL default)
-- REPEATABLE READ: same query returns same results within txn
-- SERIALIZABLE: transactions appear to run sequentially

-- PostgreSQL: set per transaction
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- MySQL: set per session
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- check current level
SHOW TRANSACTION ISOLATION LEVEL;

ロックと SELECT FOR UPDATE

SELECT FOR UPDATE はコミットするまで他のトランザクションが行を変更できないようにロックします。これは悲観的並行制御を実装します。SKIP LOCKED はジョブキューに不可欠です — 複数のワーカーが互いにブロックせずにジョブを取得できます。NOWAIT は待機せず即座に失敗します。ロックは並行性を削減し、デッドロックを引き起こす可能性があるため慎重に使用してください。ほとんどのユースケースで楽観的並行制御(バージョン列)を優先します。

sql
-- pessimistic locking: lock rows for update
BEGIN;
  SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
  -- row is locked; other transactions must wait
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;  -- lock released

-- NOWAIT: don't wait if locked, error immediately
SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT;

-- SKIP LOCKED: skip locked rows (useful for job queues)
SELECT * FROM jobs WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 10;

-- SHARE LOCK: allow reads but prevent updates
SELECT * FROM products WHERE id = 1 FOR SHARE;

デッドロックとエラー処理

デッドロックは2つのトランザクションが互いに必要なロックを保持している時に発生します。データベースはデッドロックを検出し、1つのトランザクション(犠牲者)を中止します。すべてのトランザクションで一貫した順序でロックを取得することでデッドロックを防ぎます。デッドロックやシリアライゼーション失敗で失敗したトランザクションを再試行する準備を常にしてください。ロック競合を削減するためトランザクションを短く保ちます。アプリケーションコードは SQLSTATE 40P01(デッドロック)をキャッチして再試行すべきです。

sql
-- deadlock example:
-- Transaction A:
BEGIN;
  UPDATE accounts SET balance = balance - 50 WHERE id = 1; -- locks row 1
  UPDATE accounts SET balance = balance + 50 WHERE id = 2; -- waits for row 2

-- Transaction B (concurrent):
BEGIN;
  UPDATE accounts SET balance = balance - 30 WHERE id = 2; -- locks row 2
  UPDATE accounts SET balance = balance + 30 WHERE id = 1; -- waits for row 1
-- DEADLOCK! Database detects and kills one transaction

-- prevention: always lock in consistent order
-- Transaction A and B both lock id=1 first, then id=2

-- PostgreSQL: error codes for handling
-- 40P01: deadlock_detected
-- 40001: serialization_failure
-- 40P02: transaction_integrity_constraint_violation

-- retry pattern (pseudocode):
-- for attempt in range(3):
--     try:
--         BEGIN; ... COMMIT; break
--     except deadlock:
--         ROLLBACK; continue

ACID プロパティ

ACID は信頼できるデータベーストランザクションの基盤です。原子性:トランザクション内のすべての操作が一緒に成功または失敗します。一貫性:トランザクションはデータベースをある有効な状態から別の有効な状態に移行します(制約が強制されます)。分離性:並行トランザクションは干渉しません(分離レベルで制御)。永続性:一度コミットされると、データはクラッシュを生き延びます(先行書き込みログで達成)。NoSQL データベースはスケーラビリティのために一部の ACID プロパティを犠牲にすることがよくあります。

sql
-- ACID guarantees for transactions:

-- A: Atomicity (all or nothing)
BEGIN;
  INSERT INTO orders (id, total) VALUES (1, 100);
  INSERT INTO order_items (order_id, product_id) VALUES (1, 5);
  -- if either fails, both are rolled back
COMMIT;

-- C: Consistency (valid state to valid state)
-- constraints are checked at commit
ALTER TABLE accounts ADD CONSTRAINT balance_non_negative
  CHECK (balance >= 0);
-- a transaction that would make balance negative fails

-- I: Isolation (concurrent transactions don't interfere)
-- controlled by isolation level (see previous section)

-- D: Durability (committed data survives crashes)
-- achieved via WAL (Write-Ahead Logging) + fsync
-- synchronous_commit = on (default) ensures durability
09

インデックス、ビューとストアドプロシージャ

インデックスタイプと戦略

B-tree インデックス(デフォルト)は等価(=)と範囲(<、>、BETWEEN)クエリを処理します。複合インデックスは左端プレフィックスルールに従います — (a,b,c) インデックスは WHERE a=?、WHERE a=? AND b=? を助けますが、WHERE b=? は助けません。部分インデックスはサブセットのみをインデックス化してスペースを節約します。式インデックスは関数のインデックス付きクエリを可能にします(LOWER、計算列)。インデックスが使用されていることを EXPLAIN ANALYZE で検証してください — 未使用インデックスはスペースを無駄にし書き込みを遅くします。

sql
-- B-tree index (default): equality and range queries
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_date ON orders(created_at);

-- composite index (order matters!)
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
-- helps: WHERE user_id = 1
-- helps: WHERE user_id = 1 AND status = 'paid'
-- does NOT help: WHERE status = 'paid' (leftmost prefix rule)

-- partial index: smaller, faster for common filters
CREATE INDEX idx_active_users ON users(email)
WHERE status = 'active';

-- expression index
CREATE INDEX idx_lower_email ON users(LOWER(email));
SELECT * FROM users WHERE LOWER(email) = '[email protected]';

-- unique index
CREATE UNIQUE INDEX idx_unique_email ON users(email);

-- EXPLAIN: see if index is used
EXPLAIN SELECT * FROM users WHERE email = '[email protected]';
EXPLAIN ANALYZE SELECT * FROM users WHERE email = '[email protected]';

ストアドプロシージャと関数

関数は値を返し SELECT で使用でき、プロシージャはアクションを実行し CALL で呼び出されます。ストアドプロシージャはビジネスロジックをデータベースにカプセル化します — ネットワークラウンドトリップを削減し、ロジックを一元化します。ただし、スケーリングを困難にする可能性があり(ロジックがアプリと DB に分割)、データベース固有です。データに近接することで恩恵を受けるデータ集約的操作に使用します。PostgreSQL は PL/pgSQL を使用し、MySQL は独自のプロシージャル SQL を使用します。

sql
-- PostgreSQL function
CREATE OR REPLACE FUNCTION get_user_orders(p_user_id INT)
RETURNS TABLE(order_id INT, total DECIMAL) AS $$
BEGIN
  RETURN QUERY
  SELECT id, total FROM orders WHERE user_id = p_user_id;
END;
$$ LANGUAGE plpgsql;

-- call function
SELECT * FROM get_user_orders(1);

-- PostgreSQL procedure (can manage transactions, PostgreSQL 11+)
CREATE PROCEDURE transfer_money(
  from_id INT, to_id INT, amount DECIMAL
) LANGUAGE plpgsql AS $$
BEGIN
  UPDATE accounts SET balance = balance - amount WHERE id = from_id;
  UPDATE accounts SET balance = balance + amount WHERE id = to_id;
  COMMIT;
END;
$$;

CALL transfer_money(1, 2, 100.00);

-- MySQL stored procedure
DELIMITER //
CREATE PROCEDURE GetActiveUsers()
BEGIN
  SELECT * FROM users WHERE status = 'active';
END //
DELIMITER ;
CALL GetActiveUsers();

トリガー

トリガーはデータ変更時に自動的に実行されます。BEFORE トリガーは入力データを変更できます(例:タイムスタンプ設定、検証)。AFTER トリガーは副作用を実行します(例:監査ログ、非正規化)。トリガーはアプリケーションコードから隠されているため、デバッグを困難にします — 控えめに使用してください。一般的なユースケース:監査証跡、計算列、複雑な制約の強制、非正規化データの同期。トリガーは常に明確に文書化してください。

sql
-- PostgreSQL trigger: audit log on update
CREATE OR REPLACE FUNCTION audit_user_change()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO user_audit (user_id, old_name, new_name, changed_at)
  VALUES (OLD.id, OLD.name, NEW.name, NOW());
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_user_audit
AFTER UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION audit_user_change();

-- trigger timing: BEFORE / AFTER / INSTEAD OF
-- trigger events: INSERT / UPDATE / DELETE / TRUNCATE
-- granularity: FOR EACH ROW / FOR EACH STATEMENT

-- MySQL trigger
CREATE TRIGGER before_user_insert
BEFORE INSERT ON users
FOR EACH ROW
SET NEW.created_at = NOW();

-- drop trigger
DROP TRIGGER IF EXISTS trg_user_audit ON users;

JSON 操作(PostgreSQL)

JSONB(PostgreSQL)は JSON をバイナリ形式で格納し、インデックス作成と効率的なクエリを可能にします。-> は JSON を返し、->> はテキストを返します。@> は包含をチェックします(JSON にこれが含まれているか?)。GIN インデックスにより JSON クエリが高速になります。柔軟/半構造化データ(イベントログ、API レスポンス、設定)に JSON 列を使用し、リレーショナルデータは通常の列に保持します。JSONB は JSON より優れています(より高速、インデックス可能、重複キーなし)。

sql
-- JSONB columns (PostgreSQL)
CREATE TABLE events (
  id SERIAL PRIMARY KEY,
  data JSONB NOT NULL
);

INSERT INTO events (data) VALUES
  ('{"type": "click", "user": {"id": 1, "name": "Alice"}, "tags": ["web", "mobile"]}');

-- extract fields (-> for JSON, ->> for text)
SELECT data->'type' AS type,           -- "click" (JSON)
       data->'user'->>'name' AS name,  -- Alice (text)
       data->'tags'->0 AS first_tag    -- "web"
FROM events;

-- filter by JSON field
SELECT * FROM events WHERE data->>'type' = 'click';
SELECT * FROM events WHERE data @> '{"type": "click"}';  -- containment

-- GIN index for JSON queries
CREATE INDEX idx_events_data ON events USING gin(data);
SELECT * FROM events WHERE data @> '{"user": {"id": 1}}';

-- modify JSON
UPDATE events SET data = jsonb_set(data, '{user,name}', '"Bob"');

フルテキスト検索

フルテキスト検索は自然言語クエリ(ステミング、ランキング、ストップワード)を可能にします。to_tsvector はテキストを検索可能なトークンに変換し、to_tsquery は検索クエリを作成し、@@ がマッチします。ts_rank は結果をスコアリングし、ts_headline はマッチをハイライトします。GIN インデックスによりこれが高速になります。大規模検索には専用エンジン(Elasticsearch、Solr)を検討してください。ただし PostgreSQL FTS は中規模データセットに優れており、インフラの複雑さを回避できます。

sql
-- PostgreSQL full-text search
CREATE TABLE articles (
  id SERIAL PRIMARY KEY,
  title VARCHAR(200),
  body TEXT
);

-- create a full-text search index
CREATE INDEX idx_articles_search ON articles
USING gin(to_tsvector('english', title || ' ' || body));

-- search with ranking
SELECT
  title,
  ts_rank(to_tsvector('english', body), query) AS rank,
  ts_headline('english', body, query) AS snippet
FROM articles, to_tsquery('english', 'database & performance') query
WHERE to_tsvector('english', title || ' ' || body) @@ query
ORDER BY rank DESC
LIMIT 10;

-- simplified with generated column
ALTER TABLE articles ADD COLUMN search_vector tsvector
  GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;
CREATE INDEX idx_articles_search_vec ON articles USING gin(search_vector);

SELECT * FROM articles WHERE search_vector @@ to_tsquery('database');
10

パフォーマンスとクエリ最適化

EXPLAIN とクエリプラン

EXPLAIN はクエリプランを表示します — データベースがクエリをどのように実行するか。EXPLAIN ANALYZE は実際に実行し、実際のタイミングを表示します。大きなテーブルでの Sequential Scan(インデックスを追加)、高コストの Sort(インデックスを追加)、行見積もりの不一致(統計を更新するため ANALYZE を実行)を探してください。コスト数値は相対的であり絶対的ではありません。クエリプランの理解は SQL パフォーマンスチューニングの第1のスキルです。

sql
-- EXPLAIN: show query plan without running
EXPLAIN SELECT * FROM users WHERE email = '[email protected]';

-- EXPLAIN ANALYZE: run the query and show actual timing
EXPLAIN ANALYZE SELECT * FROM users WHERE email = '[email protected]';

-- EXPLAIN ANALYZE with buffers (I/O stats)
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM users
JOIN orders ON users.id = orders.user_id;

-- key things to look for in the plan:
-- Seq Scan: full table scan (bad for large tables — add index)
-- Index Scan: using an index (good)
-- Bitmap Index Scan: index + heap lookup (good for many rows)
-- Hash Join: builds hash table (good for large joins)
-- Nested Loop: good for small result sets
-- Sort: explicit sort (consider index to avoid)
-- cost: estimated cost (first row, all rows)
-- rows: estimated vs actual rows (big mismatch = stale stats)

一般的なパフォーマンスの落とし穴

Sargability(Search Argument Able)はデータベースがインデックスを使用できることを意味します。列の関数(DATE(col)、UPPER(col))はインデックス使用を妨げます — 範囲クエリとして書き直すか式インデックスを使用してください。SELECT * は I/O を無駄にし、カバリングインデックスを妨げます。OFFSET ページネーションは O(n) です — キーセットページネーション(WHERE id > last_id)で O(1) にします。大きなバッチ操作は長いロックとレプリケーションラグを回避するためチャンク化すべきです。

sql
-- 1. Sargability: avoid functions on indexed columns
-- BAD: function prevents index usage
SELECT * FROM orders WHERE DATE(created_at) = '2024-01-01';
-- GOOD: range query uses index
SELECT * FROM orders
WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02';

-- 2. Avoid SELECT * (more I/O, prevents covering indexes)
-- BAD
SELECT * FROM users WHERE status = 'active';
-- GOOD
SELECT id, name, email FROM users WHERE status = 'active';

-- 3. Use LIMIT with ORDER BY for pagination
-- BAD: loads all rows
SELECT * FROM products ORDER BY id;
-- GOOD: keyset pagination
SELECT * FROM products WHERE id > 1000 ORDER BY id LIMIT 20;

-- 4. Batch large updates
-- BAD: one giant transaction
DELETE FROM logs WHERE date < '2023-01-01';
-- GOOD: batch in chunks
DELETE FROM logs WHERE date < '2023-01-01' AND id <= 10000;
DELETE FROM logs WHERE date < '2023-01-01' AND id <= 20000;

UNION、INTERSECT と EXCEPT

集合操作は結果セットを組み合わせます。UNION は重複を削除します(高コストのソート)。UNION ALL は保持します(より高速 — 重複がないことが分かっている場合や重複を望む場合に優先)。INTERSECT は両方にある行を返します。EXCEPT は最初にあるが2番目にない行を返します。すべて互換性のある列型が必要です。UNION ALL は複雑な OR 条件を置き換えでき、各ブランチに異なるインデックスを使用できるためしばしばより良いパフォーマンスを発揮します。

sql
-- UNION: combine results, remove duplicates
SELECT name FROM customers
UNION
SELECT name FROM suppliers;
-- UNION ALL: faster, keeps duplicates
SELECT name FROM customers
UNION ALL
SELECT name FROM suppliers;

-- INTERSECT: rows in BOTH results
SELECT product_id FROM sales_2023
INTERSECT
SELECT product_id FROM sales_2024;

-- EXCEPT (MINUS in Oracle): rows in first but not second
SELECT product_id FROM all_products
EXCEPT
SELECT product_id FROM discontinued_products;

-- rules:
-- - same number of columns
-- - compatible types
-- - column names come from first query
-- - UNION is often faster than OR conditions

データベース固有のヒント

VACUUM(PostgreSQL)は削除された行からスペースを回収します(MVCC は「デッドタプル」を残します)。ANALYZE はクエリプランナのためにテーブル統計を更新します — バルクロード後に実行します。OPTIMIZE TABLE(MySQL)はテーブルをデフラグします。外部キーを明示的にインデックス化してください(PostgreSQL は自動インデックス化しません)。pg_stat_user_indexes でインデックス使用状況を監視し、未使用のものを削除します。接続プーリング(PgBouncer、ProxySQL)は高トラフィックアプリに不可欠です — 接続を開くことは高コストです。

sql
-- PostgreSQL: VACUUM to reclaim space
VACUUM ANALYZE users;  -- update stats, reclaim dead rows
VACUUM FULL users;     -- rewrites table (locks, but reclaims all space)

-- PostgreSQL: ANALYZE to update statistics
ANALYZE users;  -- helps query planner make better decisions

-- MySQL: OPTIMIZE TABLE
OPTIMIZE TABLE users;

-- Common indexing rules across databases:
-- 1. Index foreign keys (not automatic in all DBs)
-- 2. Index columns used in WHERE, JOIN, ORDER BY, GROUP BY
-- 3. Composite indexes: high selectivity column first
-- 4. Don't over-index (slows writes, uses disk)
-- 5. Drop unused indexes (check with pg_stat_user_indexes)

-- Connection pooling (application level):
-- - Use PgBouncer (PostgreSQL) or ProxySQL (MySQL)
-- - Reuse connections instead of reconnecting per request
-- - Set appropriate pool size (not too high!)

データ型と NULL 処理

NULL はゼロや空ではなく、未知/欠落データを表します。NULL 比較は常に NULL(未知)を返し、WHERE で偽として扱われます。テストには IS NULL / IS NOT NULL を使用します。COALESCE はフォールバックを提供します。NULLIF は特定の値を NULL に変換します(ゼロ除算に便利)。集計は NULL をスキップします — COUNT(col) は非 NULL をカウントし、COUNT(*) はすべての行をカウントします。LEFT JOIN では、非マッチング行に 0 を得るために COUNT(right_table.col) を使用します。

sql
-- NULL is not zero or empty string — it's "unknown"
SELECT NULL = NULL;   -- NULL (not true!)
SELECT NULL IS NULL;  -- true
SELECT NULL <> 1;     -- NULL (unknown)

-- COALESCE: first non-NULL value
SELECT COALESCE(nickname, first_name, 'Anonymous') FROM users;

-- NULLIF: return NULL if two values are equal
SELECT NULLIF(score, 0) FROM tests;  -- NULL instead of 0
-- useful for avoiding division by zero:
SELECT total / NULLIF(count, 0) FROM stats;

-- aggregate functions ignore NULL
SELECT AVG(score) FROM tests;  -- avg of non-NULL scores only
SELECT COUNT(score) FROM tests;  -- count of non-NULL
SELECT COUNT(*) FROM tests;  -- count of all rows

-- use LEFT JOIN + COUNT carefully
SELECT u.name, COUNT(o.id) AS orders  -- COUNT(o.id) = 0 for no orders
FROM users u LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id;
11

再帰 CTE

基本的な再帰 CTE 構造

再帰 CTE は自身を参照して階層的または順次データを生成します。UNION ALL で結合された2つの部分があります:アンカークエリ(ベースケース/開始点)と再帰クエリ(CTE を参照し結果に追加)。再帰クエリが行を返さなくなるまで再帰が続きます。ツリートラバーサル(組織図、ファイルシステム)、シーケンス生成、グラフパスファインディングに再帰 CTE を使用します。無限ループを防ぐため WHERE 節に常に終了条件を含めてください。

sql
-- Recursive CTE: a CTE that references itself
-- Three parts: anchor, UNION ALL, recursive member
WITH RECURSIVE countdown(n) AS (
    -- Anchor: starting point
    SELECT 1 AS n
    UNION ALL
    -- Recursive member: references the CTE itself
    SELECT n + 1 FROM countdown WHERE n < 10
)
SELECT n FROM countdown;
-- Result: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10

-- PostgreSQL uses WITH RECURSIVE
-- SQL Server/MySQL 8+: WITH (RECURSIVE optional in MySQL)
-- SQLite: WITH RECURSIVE

階層データ(組織図)

再帰 CTE は組織図、カテゴリツリー、ファイルシステムのような階層データのトラバーサルに優れています。アンカーはルートノードを選択し、再帰メンバーは親子関係(manager_id = id)でテーブルを CTE に結合します。depth 列を追加して各行が何レベル深いかを追跡し、path 列(文字列連結)で完全な祖先チェーンを表示します。これにより複数の自己結合やアプリ側の再帰の必要性が置き換えられます。path の CAST は再帰中の型エラーを防ぎます。

sql
-- Employee hierarchy with manager relationships
CREATE TABLE employees (
    id INT PRIMARY KEY,
    name VARCHAR(100),
    manager_id INT REFERENCES employees(id)
);

-- Find all direct and indirect reports of employee 1 (CEO)
WITH RECURSIVE org_chain AS (
    -- Anchor: the starting employee
    SELECT id, name, manager_id, 0 AS depth, CAST(name AS VARCHAR(500)) AS path
    FROM employees WHERE id = 1
    UNION ALL
    -- Recursive: find employees whose manager is in the chain
    SELECT e.id, e.name, e.manager_id, oc.depth + 1,
           CAST(oc.path || ' > ' || e.name AS VARCHAR(500))
    FROM employees e
    JOIN org_chain oc ON e.manager_id = oc.id
)
SELECT id, name, depth, path FROM org_chain
ORDER BY depth, name;

-- depth shows hierarchy level, path shows the management chain

シーケンスと日付の生成

再帰 CTE はシーケンスと日付範囲を生成できます — 時系列レポートのギャップを埋めるのに便利です。範囲内のすべての日付を生成しデータに LEFT JOIN することで、レコードがない場合でもすべての日付が出力に現れるようにします。これはダッシュボードとチャートの一般的なパターンです。PostgreSQL にはよりシンプルな代替として generate_series() もあります。無限再帰を防ぐため常に終了条件(WHERE n < 100)を設定してください。一部のデータベースは再帰深度を制限します(MySQL では cte_max_recursion_depth でデフォルト 100)。

sql
-- Generate a series of numbers
WITH RECURSIVE numbers(n) AS (
    SELECT 1
    UNION ALL
    SELECT n + 1 FROM numbers WHERE n < 100
)
SELECT n FROM numbers;

-- Generate a date range (all days in January 2024)
WITH RECURSIVE dates(d) AS (
    SELECT DATE '2024-01-01'
    UNION ALL
    SELECT d + INTERVAL '1 day' FROM dates
    WHERE d < DATE '2024-01-31'
)
SELECT d FROM dates;

-- Generate a time series for gap-filling in reports
-- (ensures every date appears even if no data exists)
SELECT d.date, COALESCE(SUM(o.amount), 0) AS daily_total
FROM dates d
LEFT JOIN orders o ON o.order_date = d.date
GROUP BY d.date ORDER BY d.date;

グラフパスファインディング(BFS)

再帰 CTE はグラフ構造で幅優先探索(BFS)を実行できます。アンカーは開始ノードからエッジを見つけ、再帰メンバーはエッジを現在のパスの終点に結合してパスを延長します。循環グラフではサイクル防止が重要です — 宛先ノードが既にパスにないかチェックします(LIKE または文字列検索を使用)。ホップ制限は無限再帰に対する安全網です。このアプローチはルート検索、依存関係解決、ネットワーク分析に機能します。重み付き最短経路には、アプリコードでダイクストラ法を検討してください。

sql
-- Find all paths in a directed graph
CREATE TABLE edges (src VARCHAR(10), dst VARCHAR(10));

-- Find all reachable nodes from 'A' with the path taken
WITH RECURSIVE paths AS (
    -- Anchor: start from node A
    SELECT src, dst, CAST(src || '->' || dst AS VARCHAR(1000)) AS path,
           1 AS hops
    FROM edges WHERE src = 'A'
    UNION ALL
    -- Recursive: extend the path
    SELECT p.src, e.dst,
           CAST(p.path || '->' || e.dst AS VARCHAR(1000)),
           p.hops + 1
    FROM paths p
    JOIN edges e ON p.dst = e.src
    WHERE p.hops < 10  -- prevent infinite loops in cyclic graphs
      AND p.path NOT LIKE '%' || e.dst || '%'
)
SELECT DISTINCT path, hops FROM paths ORDER BY hops;

-- Cycle prevention: check the path doesn't already contain the node

階乗と再帰による集計

再帰 CTE は各反復で状態(n、fact)を運ぶことで階乗のような数学的計算を実行できます。アンカーはベースケース(0! = 1)を設定し、再帰メンバーは前の値から次を計算します。累計合計もこの方法で計算できますが、累積集計にはウィンドウ関数(SUM(amount) OVER (ORDER BY id))の方が効率的で慣用的です。計算用の再帰 CTE は主に教育的です — ウィンドウ関数やプロシージャルコードでロジックを表現できない場合に使用します。各再帰レベルが行を追加するため、結果セットは深さと共に成長します。

sql
-- Compute factorial using recursive CTE
WITH RECURSIVE factorial(n, fact) AS (
    -- Anchor: 0! = 1
    SELECT 0, 1
    UNION ALL
    -- Recursive: n! = n * (n-1)!
    SELECT n + 1, fact * (n + 1) FROM factorial WHERE n < 10
)
SELECT n, fact FROM factorial;

-- Running accumulation: cumulative sum
WITH RECURSIVE running_total AS (
    SELECT id, amount, amount AS cumulative
    FROM transactions WHERE id = 1
    UNION ALL
    SELECT t.id, t.amount, rt.cumulative + t.amount
    FROM transactions t
    JOIN running_total rt ON t.id = rt.id + 1
)
SELECT * FROM running_total ORDER BY id;

-- Note: window functions (SUM OVER) are usually better for this
12

PIVOT と UNPIVOT

PIVOT(行から列へ)

PIVOT は行を列に変換します — カテゴリを列ヘッダーにしたいクロス集計レポートに最適です。IN リストがどの値が列になるかを指定します。SQL Server と Oracle はネイティブ PIVOT 構文を持ちます。内部クエリがソースデータを提供し、PIVOT が各列グループに集計(SUM、AVG、COUNT)を適用します。これは条件付き集計と同等ですが、広いピボットではより読みやすいです。ピボットする値のセットが固定で既知の場合に PIVOT を使用します。

sql
-- Convert rows to columns (cross-tabulation)
-- Source: sales data with rows per quarter
CREATE TABLE sales (quarter VARCHAR(10), region VARCHAR(50), amount DECIMAL(10,2));

-- SQL Server PIVOT syntax:
SELECT region, [Q1], [Q2], [Q3], [Q4]
FROM (
    SELECT quarter, region, amount FROM sales
) AS src
PIVOT (
    SUM(amount) FOR quarter IN ([Q1], [Q2], [Q3], [Q4])
) AS pvt;

-- Result:
-- region  | Q1    | Q2    | Q3    | Q4
-- North   | 1000  | 1500  | 1200  | 1800
-- South   | 800   | 1100  | 900   | 1300

条件付き集計(汎用 PIVOT)

条件付き集計(SUM + CASE)はすべての SQL データベースで動作する汎用ピボットテクニックです。各 CASE 式が1つのカテゴリをフィルタし、SUM がマッチする値を集計します。これはしばしば PIVOT より高速で柔軟です。ELSE 0 は非マッチ行がゼロを寄与することを保証します。PostgreSQL の crosstab() 関数(tablefunc 拡張から)はより簡潔ですが、固定出力列が必要です。クロスデータベース互換性が必要な場合や PIVOT 構文が利用できない場合に条件付き集計を使用します。

sql
-- Works in ALL databases (MySQL, PostgreSQL, SQLite, etc.)
SELECT
    region,
    SUM(CASE WHEN quarter = 'Q1' THEN amount ELSE 0 END) AS Q1,
    SUM(CASE WHEN quarter = 'Q2' THEN amount ELSE 0 END) AS Q2,
    SUM(CASE WHEN quarter = 'Q3' THEN amount ELSE 0 END) AS Q3,
    SUM(CASE WHEN quarter = 'Q4' THEN amount ELSE 0 END) AS Q4,
    SUM(amount) AS total
FROM sales
GROUP BY region
ORDER BY region;

-- PostgreSQL-specific: crosstab() from tablefunc extension
-- SELECT * FROM crosstab('SELECT region, quarter, amount FROM sales ORDER BY 1,2')
-- AS ct(region VARCHAR, Q1 DECIMAL, Q2 DECIMAL, Q3 DECIMAL, Q4 DECIMAL);

動的 PIVOT(動的 SQL)

動的 SQL はピボット列が事前に分からない場合(例:月ごとにピボットする場合、月が異なる)にランタイムでクエリ文字列を構築します。プロセス:個別値をクエリ、列リストを構築、PIVOT 文を構築、sp_executesql(SQL Server)または PREPARE/EXECUTE(MySQL)で実行。SQL インジェクションを防ぐため常に QUOTENAME() または quote_ident() でサニタイズします。動的 SQL は強力ですが複雑さとセキュリティリスクを追加します — 控えめに使用し、可能な場合は固定ピボットを優先します。アプリ側ピボットはしばしばより安全な代替です。

sql
-- When pivot columns are unknown at write time, use dynamic SQL
-- SQL Server example:
DECLARE @cols AS NVARCHAR(MAX),
        @query AS NVARCHAR(MAX);

-- Build the column list dynamically
SELECT @cols = STUFF((
    SELECT DISTINCT ',' + QUOTENAME(quarter)
    FROM sales FOR XML PATH(''), TYPE
).value('.', 'NVARCHAR(MAX)'), 1, 1, '');

-- Build and execute the pivot query
SET @query = 'SELECT region, ' + @cols + '
FROM (SELECT quarter, region, amount FROM sales) x
PIVOT (SUM(amount) FOR quarter IN (' + @cols + ')) p';

EXEC sp_executesql @query;

-- WARNING: dynamic SQL is vulnerable to SQL injection
-- — always use QUOTENAME() to sanitize column names

UNPIVOT(列から行へ)

UNPIVOT は PIVOT を逆転させます — 列を行に変換します。これは非正規化データの正規化、広いインポートファイルの長いフォーマットへの変換、チャート用データの準備に便利です。SQL Server はネイティブ UNPIVOT 構文を持ちます。UNION ALL アプローチはどこでも動作します:各 SELECT が1つの列を抽出し固定値でラベル付けします。UNION ALL(UNION ではなく)は重複を保持しより高速です。UNPIVOT はソースデータがスプレッドシート形式(広い)で到着し正規化(長い)で保存する必要がある ETL パイプラインで一般的です。

sql
-- Convert columns back to rows
-- Source: wide table with Q1-Q4 columns
-- Target: narrow table with quarter/amount rows

-- SQL Server UNPIVOT:
SELECT region, quarter, amount
FROM quarterly_sales
UNPIVOT (
    amount FOR quarter IN (Q1, Q2, Q3, Q4)
) AS unpvt;

-- Universal UNION ALL approach (all databases):
SELECT region, 'Q1' AS quarter, Q1 AS amount FROM quarterly_sales
UNION ALL
SELECT region, 'Q2' AS quarter, Q2 AS amount FROM quarterly_sales
UNION ALL
SELECT region, 'Q3' AS quarter, Q3 AS amount FROM quarterly_sales
UNION ALL
SELECT region, 'Q4' AS quarter, Q4 AS amount FROM quarterly_sales;

-- Result: one row per region/quarter combination

実践的ピボットレポート例

この実世界のピボットレポートは月次内訳と前年同期比比較を1つのクエリで組み合わせます。条件付き集計(SUM + CASE)が月次列と年次合計の両方を作成します。yoy_change 列が差分をインラインで計算します。HAVING が売上のない製品を除外します。このパターンは BI ダッシュボードと財務レポートで一般的です。EXTRACT 関数はほとんどのデータベースで動作します(SQL Server では DATEPART、SQLite では strftime)。真に動的な列には、動的 SQLと組み合わせるか、アプリ層でピボットを処理します。

sql
-- Monthly sales pivot with year-over-year comparison
SELECT
    product_name,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 1  THEN amount ELSE 0 END) AS jan,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 2  THEN amount ELSE 0 END) AS feb,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 3  THEN amount ELSE 0 END) AS mar,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 4  THEN amount ELSE 0 END) AS apr,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 5  THEN amount ELSE 0 END) AS may,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 6  THEN amount ELSE 0 END) AS jun,
    SUM(CASE WHEN EXTRACT(YEAR  FROM order_date) = 2024 THEN amount ELSE 0 END) AS total_2024,
    SUM(CASE WHEN EXTRACT(YEAR  FROM order_date) = 2023 THEN amount ELSE 0 END) AS total_2023,
    SUM(CASE WHEN EXTRACT(YEAR  FROM order_date) = 2024 THEN amount ELSE 0 END) -
    SUM(CASE WHEN EXTRACT(YEAR  FROM order_date) = 2023 THEN amount ELSE 0 END) AS yoy_change
FROM orders o
JOIN products p ON o.product_id = p.id
WHERE order_date BETWEEN '2023-01-01' AND '2024-12-31'
GROUP BY product_name
HAVING SUM(amount) > 0
ORDER BY total_2024 DESC;
14

トリガー

トリガーの基礎(AFTER/BEFORE)

トリガーはデータ変更時に自動的に実行されるデータベースレベルのコードです。AFTER トリガーは変更をログまたは伝播します(NEW を変更不可)。BEFORE トリガーは書き込まれる前にデータを検証または変換します(NEW を変更可)。FOR EACH ROW は影響を受ける行ごとに1回発火し、FOR EACH STATEMENT は文ごとに1回発火します。監査ログ、複雑な制約の強制、派生列の自動更新にトリガーを使用します。ビジネスロジックにはトリガーを避けてください — 隠されており、デバッグが難しく、カスケード効果を引き起こす可能性があります。各データベースでトリガー構文が異なります。PostgreSQL は関数をトリガーボディとして使用します。

sql
-- A trigger fires automatically on INSERT/UPDATE/DELETE
-- MySQL syntax:
DELIMITER //
CREATE TRIGGER audit_log
AFTER INSERT ON employees
FOR EACH ROW
BEGIN
    INSERT INTO audit_table (table_name, action, row_id, changed_at)
    VALUES ('employees', 'INSERT', NEW.id, NOW());
END //
DELIMITER ;

-- BEFORE triggers can modify the NEW values:
CREATE TRIGGER validate_email
BEFORE INSERT ON users
FOR EACH ROW
BEGIN
    IF NEW.email NOT LIKE '%@%.%' THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Invalid email';
    END IF;
END //

-- PostgreSQL uses CREATE FUNCTION + CREATE TRIGGER:
-- CREATE TRIGGER audit AFTER INSERT ON employees
-- FOR EACH ROW EXECUTE FUNCTION audit_func();

監査ログトリガー

監査トリガーはコンプライアンスとデバッグのためにすべてのデータ変更をキャプチャします。監査テーブルはアクションタイプ、新旧の値、変更者(CURRENT_USER)、変更時刻(CURRENT_TIMESTAMP)を格納します。INSERT、UPDATE、DELETE に個別のトリガーが必要です。OLD は変更前の値を参照(UPDATE/DELETE で利用可能)、NEW は変更後の値を参照(INSERT/UPDATE で利用可能)。監査テーブルは無限に成長します — 日付でパーティション化するか古いデータをアーカイブします。このパターンはデータ変更追跡の SOX、HIPAA、GDPR 要件を満たします。

sql
-- Track all changes to a critical table
CREATE TABLE employee_audit (
    audit_id INT AUTO_INCREMENT PRIMARY KEY,
    action VARCHAR(10),       -- INSERT, UPDATE, DELETE
    employee_id INT,
    old_name VARCHAR(100),
    new_name VARCHAR(100),
    old_salary DECIMAL(10,2),
    new_salary DECIMAL(10,2),
    changed_by VARCHAR(50) DEFAULT CURRENT_USER,
    changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TRIGGER trg_audit_insert
AFTER INSERT ON employees
FOR EACH ROW
INSERT INTO employee_audit (action, employee_id, new_name, new_salary)
VALUES ('INSERT', NEW.id, NEW.name, NEW.salary);

CREATE TRIGGER trg_audit_update
AFTER UPDATE ON employees
FOR EACH ROW
INSERT INTO employee_audit (action, employee_id, old_name, new_name, old_salary, new_salary)
VALUES ('UPDATE', NEW.id, OLD.name, NEW.name, OLD.salary, NEW.salary);

CREATE TRIGGER trg_audit_delete
AFTER DELETE ON employees
FOR EACH ROW
INSERT INTO employee_audit (action, employee_id, old_name, old_salary)
VALUES ('DELETE', OLD.id, OLD.name, OLD.salary);

計算/派生列トリガー

トリガーはアプリケーションコードなしで派生列を自動計算し、一貫性を保証できます。BEFORE INSERT/UPDATE トリガーが他の列に基づいて NEW.final_price を設定します。ただし、モダンなデータベースは GENERATED(計算)列をネイティブにサポートします — これらは常に正しく、手動でオーバーライドできず、インデックス化できます。計算値にはトリガーより GENERATED 列を優先します。計算が外部データ、条件ロジック、または GENERATED 列が処理できないクロステーブル依存関係を含む場合のみトリガーを使用します。トリガーはすべての書き込み操作にオーバーヘッドを追加することを忘れないでください。

sql
-- Auto-update a derived column when source data changes
CREATE TABLE products (
    id INT PRIMARY KEY,
    price DECIMAL(10,2),
    discount_percent DECIMAL(5,2),
    final_price DECIMAL(10,2)  -- computed: price * (1 - discount/100)
);

-- BEFORE INSERT: compute final_price
CREATE TRIGGER calc_final_price_insert
BEFORE INSERT ON products
FOR EACH ROW
SET NEW.final_price = NEW.price * (1 - NEW.discount_percent / 100);

-- BEFORE UPDATE: recompute if price or discount changes
CREATE TRIGGER calc_final_price_update
BEFORE UPDATE ON products
FOR EACH ROW
SET NEW.final_price = NEW.price * (1 - NEW.discount_percent / 100);

-- Alternative: use GENERATED columns (MySQL 5.7+, PostgreSQL 12+)
-- final_price DECIMAL(10,2) GENERATED ALWAYS AS
--     (price * (1 - discount_percent / 100)) STORED

トリガーによる削除防止

トリガーは CHECK 制約が表現できないデータ保護ルールを強制できます。BEFORE DELETE トリガーは削除を完全にブロック(SIGNAL/RAISE を使用)するか、ソフト削除(レコードを削除ではなく削除済みとしてマーク)を実装できます。SIGNAL SQLSTATE '45000' が MySQL でユーザー定義エラーを発生させる方法です。PostgreSQL は RAISE EXCEPTION を使用します。これは参照データの保護、子を持つ親レコードの削除防止、または不変の監査証跡の実装に便利です。注意:操作を妨げるトリガーは開発者を驚かせる可能性があります — 明確に文書化し、アプリケーションレベルのチェックも検討してください。

sql
-- Prevent deletion of critical records
CREATE TRIGGER prevent_delete_admin
BEFORE DELETE ON users
FOR EACH ROW
BEGIN
    IF OLD.role = 'admin' THEN
        SIGNAL SQLSTATE '45000'
        SET MESSAGE_TEXT = 'Cannot delete admin users';
    END IF;
END //

-- Soft delete instead of hard delete
CREATE TRIGGER soft_delete
BEFORE DELETE ON articles
FOR EACH ROW
BEGIN
    -- Prevent actual deletion, mark as deleted instead
    INSERT INTO articles (id, title, body, deleted_at)
    VALUES (OLD.id, OLD.title, OLD.body, NOW())
    ON DUPLICATE KEY UPDATE deleted_at = NOW();
    -- Still need to prevent the DELETE — use SIGNAL or a flag
END //

-- PostgreSQL: raise exception in trigger function
-- IF OLD.role = 'admin' THEN
--     RAISE EXCEPTION 'Cannot delete admin users';
-- END IF;

トリガー管理とデバッグ

トリガーの管理はメンテナンスに不可欠です。SHOW TRIGGERS(MySQL)と information_schema ビューがすべてのトリガーをリストします。DROP TRIGGER IF EXISTS でトリガーを削除します。バルクデータロードのためにトリガーを一時的に無効化する便利です(すべての行で高コストな監査/ログがトリガーされるため)。PostgreSQL は ALTER TABLE ... DISABLE/ENABLE TRIGGER を使用し、SQL Server は DISABLE/ENABLE TRIGGER を使用します。メンテナンス後に常にトリガーを再有効化してください。トリガーのデバッグは困難です — 黙って実行されます。デバッグテーブルにログを追加するか、最初にトリガーロジックを分離してテストします。過剰なトリガーは隠れた複雑さとパフォーマンス問題を作成します。

sql
-- View existing triggers
-- MySQL:
SHOW TRIGGERS;
SELECT * FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = 'mydb';

-- PostgreSQL:
SELECT tgname, tgrelid::regclass, tgtype FROM pg_trigger;

-- SQL Server:
SELECT name, type_desc FROM sys.triggers WHERE parent_id = OBJECT_ID('employees');

-- Drop a trigger
DROP TRIGGER IF EXISTS audit_log;  -- MySQL
DROP TRIGGER IF EXISTS audit_log ON employees;  -- PostgreSQL

-- Disable/enable triggers (PostgreSQL)
ALTER TABLE employees DISABLE TRIGGER ALL;
ALTER TABLE employees ENABLE TRIGGER ALL;

-- Disable for bulk operations (SQL Server)
DISABLE TRIGGER trg_audit ON employees;
-- ... bulk operations ...
ENABLE TRIGGER trg_audit ON employees;
15

ユーザー定義関数(UDF)

スカラー関数(単一値を返す)

スカラー UDF は単一値を返し、SELECT、WHERE、計算列で使用できます。DETERMINISTIC は出力が入力のみに依存することを意味します(キャッシュを有効化)。READS SQL DATA は関数がテーブルから読み取ることを宣言します。UDF は再利用可能なロジック(割引、フォーマット、計算)をカプセル化し、クエリ間で一貫性を保ちます。ただし、SQL Server のスカラー UDF はパフォーマンス問題を引き起こす可能性があります(行ごとの実行) — 可能な場合はインラインテーブル値関数または計算列を使用します。MySQL 8.0+ は決定的関数をより良く最適化します。関数の目的とパラメータを常に文書化してください。

sql
-- MySQL: a function that returns one value
CREATE FUNCTION calculate_discount(
    price DECIMAL(10,2),
    customer_tier VARCHAR(20)
) RETURNS DECIMAL(10,2)
DETERMINISTIC
READS SQL DATA
BEGIN
    DECLARE discount_rate DECIMAL(5,2);
    SET discount_rate = CASE customer_tier
        WHEN 'gold'   THEN 0.20
        WHEN 'silver' THEN 0.10
        WHEN 'bronze' THEN 0.05
        ELSE 0.00
    END;
    RETURN price * (1 - discount_rate);
END //

-- Usage in queries:
SELECT name, price, calculate_discount(price, tier) AS final_price
FROM orders;

-- DETERMINISTIC: same inputs always give same output (cacheable)
-- READS SQL DATA: function reads but doesn't modify tables

テーブル値関数(行を返す)

テーブル値関数(TVF)はテーブルのようにクエリできる結果セット(行)を返します。インライン TVF(SQL Server)はビューと同様に高速です — クエリオプティマイザがインライン化します。マルチステートメント TVF は最初に結果をテンポラリテーブルにマテリアライズするため、遅くなる可能性があります。PostgreSQL の TABLE または SETOF を返す関数が同等です。TVF はパラメータ化ビューです — パラメータ付きのビューが必要な場合に使用します。複雑な JOIN とフィルタのカプセル化に最適です。パフォーマンスのためにマルチステートメント TVF よりインライン TVF を優先します。PostgreSQL では、WHERE 節付きのパラメータ化ビューの使用も検討してください。

sql
-- SQL Server: Inline table-valued function (fast, like a view)
CREATE FUNCTION fn_OrdersByCustomer(@cust_id INT)
RETURNS TABLE
AS
RETURN (
    SELECT o.id, o.order_date, o.total
    FROM orders o
    WHERE o.customer_id = @cust_id
);
-- Usage: SELECT * FROM fn_OrdersByCustomer(42);

-- PostgreSQL: function returning a table
CREATE OR REPLACE FUNCTION get_orders_by_customer(cust_id INT)
RETURNS TABLE(order_id INT, order_date DATE, total DECIMAL) AS $$
    SELECT id, order_date, total
    FROM orders
    WHERE customer_id = cust_id;
$$ LANGUAGE SQL;

-- Usage:
SELECT * FROM get_orders_by_customer(42);

-- Multi-statement TVF (SQL Server) — slower, materializes result
-- CREATE FUNCTION fn_ComplexReport(@date DATE)
-- RETURNS @result TABLE (...)
-- AS BEGIN ... INSERT INTO @result ... RETURN END

文字列操作関数

カスタム文字列関数は組み込み関数がカバーしないテキスト処理ロジックをカプセル化します。get_first_name 関数は LOCATE と SUBSTRING を使用して最初の単語を抽出します。make_slug 関数は LOWER、REPLACE、REGEXP_REPLACE をチェーンして URL フレンドリーなスラッグを作成します。同じ入力が常に同じ出力を生成するため DETERMINISTIC としてマークします。SQL の文字列関数はデータベース固有です — PostgreSQL には split_part()、MySQL には SUBSTRING_INDEX() があります。UDF の作成はアプリケーション全体で動作を標準化します。SQL での複雑な文字列操作はアプリケーションコードの方がしばしばクリーンであることに注意してください。

sql
-- Create a function to split full name into parts
CREATE FUNCTION get_first_name(full_name VARCHAR(200))
RETURNS VARCHAR(100)
DETERMINISTIC
BEGIN
    DECLARE space_pos INT;
    SET space_pos = LOCATE(' ', full_name);
    IF space_pos > 0 THEN
        RETURN SUBSTRING(full_name, 1, space_pos - 1);
    ELSE
        RETURN full_name;
    END IF;
END //

-- Function to generate slug from a title
CREATE FUNCTION make_slug(title VARCHAR(500))
RETURNS VARCHAR(500)
DETERMINISTIC
BEGIN
    DECLARE slug VARCHAR(500);
    SET slug = LOWER(title);
    SET slug = REPLACE(slug, ' ', '-');
    SET slug = REGEXP_REPLACE(slug, '[^a-z0-9-]', '');
    RETURN slug;
END //

-- Usage:
SELECT get_first_name('John Doe Smith') AS first_name;  -- John
SELECT make_slug('Hello World! 2024') AS slug;           -- hello-world-2024

集計関数(カスタム)

カスタム集計関数は SUM、AVG、COUNT を超えた新しい集計ロジックを定義できます。PostgreSQL の CREATE AGGREGATE は状態遷移関数(SFUNC、行ごとに呼び出し)と最終関数(FINALFUNC、最後に1回呼び出し)を必要とします。この例は幾何平均(積の n 乗根)を計算します。カスタム集計は統計、金融、またはドメイン固有の計算に強力です。状態は行間で蓄積され、最終関数が結果を計算します。MySQL と SQL Server はカスタム集計を直接サポートしません — ストアドプロシージャまたはアプリ側計算を使用します。

sql
-- PostgreSQL: custom aggregate function
-- Step 1: Define a state transition function
CREATE OR REPLACE FUNCTION geom_mean_state(state numeric[], val numeric)
RETURNS numeric[] AS $$
BEGIN
    IF val IS NULL THEN RETURN state; END IF;
    IF state IS NULL THEN
        RETURN ARRAY[val];
    ELSE
        RETURN state || val;
    END IF;
END;
$$ LANGUAGE plpgsql;

-- Step 2: Define the final function
CREATE OR REPLACE FUNCTION geom_mean_final(vals numeric[])
RETURNS numeric AS $$
BEGIN
    IF vals IS NULL OR array_length(vals, 1) IS NULL THEN
        RETURN NULL;
    END IF;
    RETURN exp(avg(ln(v)) FROM unnest(vals) AS v);
END;
$$ LANGUAGE plpgsql;

-- Step 3: Create the aggregate
CREATE AGGREGATE geometric_mean(numeric) (
    SFUNC = geom_mean_state,
    STYPE = numeric[],
    FINALFUNC = geom_mean_final,
    INITCOND = '{}'
);

-- Usage:
SELECT category, geometric_mean(price) FROM products GROUP BY category;

関数とストアドプロシージャ

関数とストアドプロシージャは異なる目的を果たします。関数は値を返し SELECT/WHERE に埋め込めます — 決定的であるべきです(ほとんどのデータベースで副作用なし)。ストアドプロシージャはデータを変更し、トランザクションを管理し、複数の結果セットを返せます — ただしクエリ内で使用できません(CALL/EXEC で呼び出し)。計算とデータ取得に関数を使用し、マルチステップ操作(転送、バッチ処理、ETL)にプロシージャを使用します。関数は合成可能で、プロシージャは命令的です。PostgreSQL では、関数はプロシージャができるほとんどすべてのことができ(データ変更を含む)、区別が曖昧です。

sql
-- FUNCTIONS: return values, usable in queries, no side effects
CREATE FUNCTION get_full_name(fname VARCHAR, lname VARCHAR)
RETURNS VARCHAR(200)
DETERMINISTIC
RETURN CONCAT(fname, ' ', lname);

-- Can be used in SELECT:
SELECT get_full_name(first, last) FROM users;  -- OK
SELECT * FROM users WHERE get_full_name(first, last) LIKE 'J%';  -- OK

-- STORED PROCEDURES: can modify data, use transactions, return result sets
CREATE PROCEDURE transfer_funds(
    IN from_acct INT, IN to_acct INT, IN amount DECIMAL(10,2)
)
BEGIN
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;
        RESIGNAL;
    END;
    START TRANSACTION;
    UPDATE accounts SET balance = balance - amount WHERE id = from_acct;
    UPDATE accounts SET balance = balance + amount WHERE id = to_acct;
    INSERT INTO transfers (from_id, to_id, amount) VALUES (from_acct, to_acct, amount);
    COMMIT;
END //

-- Call procedure (can't use in SELECT):
CALL transfer_funds(1, 2, 100.00);
16

データベース設計と正規化

第1正規形(1NF)

第1正規形は原子値を要求します — 各セルは1つのデータを持ち、リストや配列ではありません。カンマ区切り値の列は1NF に違反します。個々の項目をクエリ、インデックス、更新できないためです。修正:項目ごとに1行(複合主キー付き)または別の詳細テーブルに分割。1NF は行を一意に識別するための主キーも要求します。1NF 違反は「マウスを含むすべての注文を見つける」のようなクエリに文字列解析を要求し — 遅くてエラーが発生しやすい。常に 1NF 準拠から始めてください。

sql
-- 1NF: each column contains atomic (indivisible) values
--       no repeating groups, each row is unique

-- VIOLATION: comma-separated values in one column
CREATE TABLE bad_orders (
    id INT,
    customer_name VARCHAR(100),
    products VARCHAR(500)  -- "laptop, mouse, keyboard" — BAD!
);

-- 1NF COMPLIANT: separate row per product
CREATE TABLE orders (
    order_id INT,
    customer_name VARCHAR(100),
    product_name VARCHAR(100),  -- one product per row
    PRIMARY KEY (order_id, product_name)  -- composite key for uniqueness
);

-- Better: normalize further with separate tables
CREATE TABLE orders (order_id INT PRIMARY KEY, customer_name VARCHAR(100));
CREATE TABLE order_items (
    order_id INT REFERENCES orders(order_id),
    product_name VARCHAR(100),
    quantity INT,
    PRIMARY KEY (order_id, product_name)
);

第2と第3正規形(2NF、3NF)

2NF は部分依存を排除します — すべての非キー列は主キーの一部ではなく全体に依存しなければなりません。これは複合キーの場合にのみ重要です。3NF は推移的依存を排除します — 非キー列は他の非キー列ではなく主キーのみに依存しなければなりません。例えば、customer_name は order_id に依存する customer_id に依存します(推移的)。正規化はデータ冗長性を削減し(各事実を1回格納)、異常を削減します(すべての注文ではなく1箇所で顧客名を更新)。ほとんどの実用的なデータベースは 3NF または BCNF を目指します。

sql
-- 2NF: 1NF + no partial dependencies (non-key attrs depend on FULL key)
-- Problem: order_items has composite key (order_id, product_id)
-- but product_name depends only on product_id (partial dependency)

-- VIOLATION of 2NF:
CREATE TABLE bad_order_items (
    order_id INT,
    product_id INT,
    product_name VARCHAR(100),  -- depends on product_id only!
    quantity INT,
    PRIMARY KEY (order_id, product_id)
);

-- 2NF COMPLIANT: move product_name to products table
CREATE TABLE products (
    product_id INT PRIMARY KEY,
    product_name VARCHAR(100)
);
CREATE TABLE order_items (
    order_id INT,
    product_id INT REFERENCES products(product_id),
    quantity INT,
    PRIMARY KEY (order_id, product_id)
);

-- 3NF: 2NF + no transitive dependencies
-- (non-key attrs don't depend on other non-key attrs)
-- Problem: orders table has customer_name that depends on customer_id
-- Solution: separate customers table (see above)

非正規化(ルールを破る時)

非正規化は書き込みの複雑さとストレージのコストで読み取りパフォーマンスを向上させるため、意図的に正規形に違反します。正規化されたデータベースでは、完全な注文を取得するには4つの JOIN が必要です — 高トラフィックダッシュボードには高コストです。非正規化テーブルは高速な読み取りのためにデータを事前結合および事前計算します。トレードオフ:書き込みは複数箇所を更新する必要があります(不整合のリスク)、ストレージが増加します。読み取り多いシステム(分析、レポート、データウェアハウス)に非正規化を使用します。マテリアライズドビューは管理された非正規化を提供します — データベースがリフレッシュを処理します。OLTP システムは正規化されたままにすべきで、OLAP システムは通常非正規化されます(スター/スノーフレークスキーマ)。

sql
-- Denormalization: intentionally adding redundancy for performance
-- Normalized (3NF): requires JOINs to get full order info
SELECT o.order_id, c.name, p.product_name, oi.quantity, p.price
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON o.order_id = oi.order_id
JOIN products p ON oi.product_id = p.id;

-- Denormalized: store redundant data for read speed
CREATE TABLE order_summary (
    order_id INT PRIMARY KEY,
    customer_id INT,
    customer_name VARCHAR(100),    -- redundant (also in customers)
    customer_email VARCHAR(200),   -- redundant
    total_amount DECIMAL(10,2),    -- pre-calculated
    item_count INT,                -- pre-calculated
    order_date TIMESTAMP
);

-- Trade-off: faster reads, slower writes, risk of inconsistency
-- Use for: reporting tables, read-heavy dashboards, data warehouses

-- Materialized views are a managed form of denormalization:
CREATE MATERIALIZED VIEW sales_summary AS
SELECT date, region, SUM(amount) AS total FROM sales GROUP BY date, region;
REFRESH MATERIALIZED VIEW sales_summary;

主キー、外部キーと制約

制約はデータベースレベルでデータ整合性を強制します。PRIMARY KEY は行を一意に識別し、クラスタ化インデックスを作成します。UNIQUE は重複を防ぎます(ほとんどのデータベースで複数の NULL を許可)。CHECK はカスタムルールを強制します(salary > 0)。FOREIGN KEY は参照整合性を維持します — ON DELETE SET NULL/CASCADE/RESTRICT が親行が削除された時に何が起こるかを制御します。ON UPDATE CASCADE が PK 変更を FK に伝播します。制約は不良データに対する最後の防御線です — アプリケーションコードにバグがあっても、データベースは無効なデータを拒否します。常に制約を定義してください。それらはドキュメントと強制の組み合わせです。

sql
CREATE TABLE departments (
    dept_id INT PRIMARY KEY AUTO_INCREMENT,
    dept_name VARCHAR(100) NOT NULL UNIQUE,
    budget DECIMAL(12,2) CHECK (budget >= 0),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE employees (
    emp_id INT PRIMARY KEY AUTO_INCREMENT,
    emp_name VARCHAR(100) NOT NULL,
    email VARCHAR(200) UNIQUE,
    dept_id INT,
    salary DECIMAL(10,2) CHECK (salary > 0 AND salary < 1000000),
    hire_date DATE NOT NULL,
    manager_id INT REFERENCES employees(emp_id),  -- self-reference
    FOREIGN KEY (dept_id) REFERENCES departments(dept_id)
        ON DELETE SET NULL      -- don't delete dept if employees exist
        ON UPDATE CASCADE,      -- update FK if dept_id changes
    INDEX idx_dept (dept_id),
    INDEX idx_email (email)
);

-- Constraint types:
-- PRIMARY KEY: unique + not null (clustered index)
-- UNIQUE: no duplicates (allows NULLs)
-- NOT NULL: required field
-- CHECK: custom condition
-- FOREIGN KEY: referential integrity
-- DEFAULT: value when not specified

インデックス戦略

インデックスは読み取りを劇的に高速化しますが書き込みを遅くします(各インデックスは INSERT/UPDATE/DELETE で更新される必要あり)。B-tree インデックスは等価、範囲、ソートをサポートします。複合インデックスは左端プレフィックスルールに従います — a または a+b のクエリに (a, b) を使用できますが、b 単独には使用できません。カバリングインデックス(INCLUDE 節)は追加列を格納し、クエリがテーブルに触れないようにします — 非常に高速。部分インデックスは行のサブセットのみをインデックス化し、スペースを節約します。インデックス使用状況を監視(PostgreSQL の pg_stat_user_indexes)し、未使用のものを削除します。良いルール:外部キーと WHERE/JOIN 節の列をインデックス化します。過剰なインデックスは書き込みパフォーマンスを損ない、ストレージを無駄にします。

sql
-- B-tree index (default): good for =, <, >, BETWEEN, ORDER BY
CREATE INDEX idx_last_name ON employees(last_name);

-- Composite index: order matters! (leftmost prefix rule)
CREATE INDEX idx_dept_salary ON employees(dept_id, salary);
-- Usable for: WHERE dept_id = 5
-- Usable for: WHERE dept_id = 5 AND salary > 50000
-- NOT usable for: WHERE salary > 50000 (skips dept_id)

-- Covering index: includes all columns a query needs
CREATE INDEX idx_covering ON orders(customer_id, order_date)
    INCLUDE (total_amount, status);
-- Query can be satisfied from index alone (no table lookup)

-- Partial/partial index: index only matching rows
CREATE INDEX idx_active_users ON users(last_login)
    WHERE active = true;  -- PostgreSQL

-- Don't over-index: every index slows writes
-- Index columns used in: WHERE, JOIN, ORDER BY, GROUP BY
-- Drop unused indexes: SELECT * FROM pg_stat_user_indexes;
17

EXPLAIN とクエリ最適化

EXPLAIN 出力の読み方

EXPLAIN はデータベースがクエリをどのように実行するかを明らかにします — どのインデックスが使用され、テーブルがどのように結合され、何行検査されるか。EXPLAIN ANALYZE(PostgreSQL)または実行付き EXPLAIN(MySQL 8.0+)は実際にクエリを実行し、実際のタイミングを表示します。探すもの:Seq Scan / ALL(フルテーブルスキャン — 大きなテーブルで悪い)、Index Scan(良い)、行見積もり(高い = 高コスト)。MySQL で 'Using filesort' または 'Using temporary' は追加作業を示します。EXPLAIN が大きなテーブルでフルテーブルスキャンを示す場合、インデックスが必要です。最適化の前に常に EXPLAIN してください — 推推測しないで。

sql
-- EXPLAIN shows the query execution plan
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;

-- EXPLAIN ANALYZE actually runs the query (PostgreSQL)
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;

-- PostgreSQL output:
-- Index Scan using idx_customer on orders  (cost=0.29..8.31 rows=1 width=74)
--   Index Cond: (customer_id = 42)
--   Execution Time: 0.042 ms

-- Key columns to check:
-- - type: scan method (const > eq_ref > ref > range > index > ALL)
-- - rows: estimated rows examined (lower is better)
-- - key: which index is used (NULL = no index, bad!)
-- - Extra: "Using filesort" or "Using temporary" = warning signs

-- MySQL: EXPLAIN FORMAT=JSON for detailed output
-- SQL Server: SET SHOWPLAN_TEXT ON; or Actual Execution Plan in SSMS

一般的なパフォーマンス問題

いくつかの一般的なパターンがインデックス使用を妨げ、フルテーブルスキャンを引き起こします。インデックス付き列の関数(YEAR(date)、UPPER(name))はインデックス使用を妨げます — 範囲条件として書き直してください。LIKE の先頭ワイルドカード('%pattern')は B-tree インデックスを使用できません — 代わりにフルテキスト検索を使用します。SELECT * は帯域幅を無駄にし、カバリングインデックス最適化を妨げます。暗黙の型変換(文字列列と整数の比較)はインデックスを無効化する可能性があります。OR 条件は IN より効率が悪いことがあります。EXPLAIN でインデックスが実際に使用されていることを常に検証してください — 未使用インデックスは無駄なストレージと書き込みオーバーヘッドです。

sql
-- 1. Missing index → full table scan
-- Bad: SELECT * FROM orders WHERE customer_id = 42; (no index)
-- Fix: CREATE INDEX idx_customer ON orders(customer_id);

-- 2. Index not used due to function on column
-- Bad: WHERE YEAR(order_date) = 2024  (function prevents index use)
-- Good: WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01'

-- 3. SELECT * instead of specific columns
-- Bad: SELECT * FROM large_table WHERE id = 1;
-- Good: SELECT id, name, email FROM large_table WHERE id = 1;

-- 4. OR conditions preventing index use
-- Bad: WHERE dept = 'A' OR dept = 'B' OR dept = 'C'
-- Good: WHERE dept IN ('A', 'B', 'C')

-- 5. LIKE with leading wildcard
-- Bad: WHERE name LIKE '%son'  (can't use index)
-- OK:  WHERE name LIKE 'John%'  (can use index)

-- 6. Implicit type conversion
-- Bad: WHERE string_column = 123  (converts to string, skips index)
-- Good: WHERE string_column = '123'

JOIN 最適化

JOIN 最適化はマルチテーブルクエリに不可欠です。結合列(通常は外部キー)がインデックス化されていることを確認してください — インデックスなしの結合はネストループスキャン(O(n*m))を引き起こします。クエリオプティマイザは通常最適な結合順序を選びますが、早期にフィルタすることで(概念的には JOIN の前に WHERE)助けられます。マッチしない行が不要な場合は OUTER JOIN より INNER JOIN が高速です。相関サブクエリには IN より EXISTS がしばしば効率的です。最初のマッチでショートサーキットするためです。不要なテーブルを結合しないでください — 各結合は作業を乗算します。複雑なレポートには、マテリアライズドビューまたは事前集計サマリーテーブルを検討してください。

sql
-- Join order matters: smallest table first (optimizer usually handles this)
-- Ensure join columns are indexed (usually foreign keys)
CREATE INDEX idx_orders_customer ON orders(customer_id);
CREATE INDEX idx_order_items_order ON order_items(order_id);

-- Use INNER JOIN when you don't need unmatched rows (faster than OUTER)
SELECT c.name, COUNT(o.order_id) AS order_count
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id
GROUP BY c.name;

-- Avoid joining unnecessary tables — fetch details lazily if needed
-- Bad: join 5 tables when you only need 2 columns
-- Good: split into simpler queries or use a covering index

-- EXISTS vs IN for subqueries
-- EXISTS is often faster for large subquery results:
SELECT name FROM customers c
WHERE EXISTS (
    SELECT 1 FROM orders o WHERE o.customer_id = c.id
);
-- IN is better for small lists:
SELECT * FROM orders WHERE customer_id IN (1, 2, 3);

ページネーション最適化

OFFSET ベースのページネーション(LIMIT 10 OFFSET 10000)は O(n) です — データベースはすべてのスキップされた行をスキャンして破棄する必要があり、深いページを非常に遅くします。キーセット(カーソル)ページネーションは WHERE last_value < cursor で直接シークします — ページの深さに関わらず O(1)。これにはソート列のインデックスが必要です。同順位(同じタイムスタンプ)の場合、複合カーソル(created_at、id)を使用します。大きなテーブルで COUNT(*) を避けてください — テーブル全体をスキャンします。近似カウント(PostgreSQL の pg_class.reltuples)を使用するか、合計カウントを表示しないでください(無限スクロール)。キーセットページネーションは高性能 API の標準です。

sql
-- Bad: OFFSET pagination (slow for large offsets)
SELECT * FROM orders ORDER BY created_at DESC LIMIT 10 OFFSET 10000;
-- Must scan and discard 10000 rows — gets slower as you page deeper

-- Good: Keyset (cursor) pagination using WHERE
SELECT * FROM orders
WHERE created_at < '2024-01-15 10:30:00'  -- last seen value
ORDER BY created_at DESC
LIMIT 10;
-- Uses index efficiently — constant time regardless of page depth

-- For composite keyset pagination (handles ties):
SELECT * FROM orders
WHERE (created_at, id) < ('2024-01-15 10:30:00', 12345)
ORDER BY created_at DESC, id DESC
LIMIT 10;

-- Count total (expensive on large tables — avoid if possible)
-- Instead of COUNT(*), use an approximate count:
SELECT reltuples::bigint FROM pg_class WHERE relname = 'orders';

クエリ書き換えと最適化チェックリスト

クエリ最適化は反復プロセスです:EXPLAIN、ボトルネックの特定、書き換え、繰り返し。主要テクニック:IN サブクエリを JOIN に置き換え(しばしば高速)、UNION の代わりに UNION ALL を使用(重複排除ソートをスキップ)、INSERT をバッチ化(1000 の代わりに1クエリ)、プリペアドステートメントを使用(クエリプランをキャッシュ)。CTE は可読性を向上させますが、古い PostgreSQL バージョンではマテリアライズ化されます(最適化不可) — PostgreSQL 12+ はインライン化します。プランナが良い決定を下せるようテーブル統計を更新(ANALYZE)。黄金律:EXPLAIN ANALYZE で測定し、推測しない。あるデータベース/バージョンで高速なものが別のものでは遅い可能性があります。

sql
-- 1. Replace subqueries with JOINs when possible
-- Slow: SELECT * FROM orders WHERE customer_id IN
--       (SELECT id FROM customers WHERE active = true);
-- Fast: SELECT o.* FROM orders o
--       JOIN customers c ON o.customer_id = c.id WHERE c.active = true;

-- 2. Use UNION ALL instead of UNION (avoids dedup sort)
SELECT 'A' UNION ALL SELECT 'B';  -- fast
SELECT 'A' UNION SELECT 'B';       -- sorts to remove duplicates

-- 3. Batch operations instead of row-by-row
-- Slow: 1000 individual INSERTs
-- Fast: INSERT INTO t VALUES (1,'a'), (2,'b'), (3,'c'), ...;

-- 4. Use CTEs for readability, but know they may not optimize well
-- (PostgreSQL 12+ inlines CTEs; older versions materialize them)

-- 5. Avoid SELECT DISTINCT when you can use GROUP BY or EXISTS
-- 6. Use prepared statements for repeated queries (plan caching)
PREPARE get_user AS SELECT * FROM users WHERE id = $1;
EXECUTE get_user(42);

-- 7. Analyze tables for up-to-date statistics
ANALYZE orders;  -- PostgreSQL: updates planner statistics
18

NoSQL と SQL の比較

SQL と NoSQL:いつ何を使うか

SQL と NoSQL の選択はデータモデル、一貫性要件、スケールに依存します。SQL データベースはスキーマを強制し、ACID トランザクションをサポートし、JOIN 付きの複雑なクエリに優れています — 金融システムやデータ整合性が極めて重要なアプリに理想的です。NoSQL データベースはスケーラビリティと柔軟性のために一貫性をトレードします:進化するスキーマにはドキュメントストア(MongoDB)、キャッシュにはキーバリューストア(Redis)、大量の書き込みスループットにはカラムファミリー(Cassandra)、関係の多いデータにはグラフデータベース(Neo4j)。モダンな SQL データベースは JSON、フルテキスト検索、スケーリングをサポートするようになり、多くのケースで NoSQL の必要性を削減しています。

sql
-- SQL (Relational): structured, consistent, queryable
-- Best for: financial systems, e-commerce, CRM, any ACID-requiring app
-- Examples: PostgreSQL, MySQL, SQL Server, Oracle

-- NoSQL: flexible schema, horizontal scaling, specific data models
-- Document: MongoDB, CouchDB — JSON-like documents, flexible schema
-- Key-Value: Redis, DynamoDB — fast lookups, caching, sessions
-- Column-Family: Cassandra, HBase — wide-column, time-series, write-heavy
-- Graph: Neo4j, ArangoDB — relationships, social networks, recommendations

-- Decision factors:
-- 1. Data structure: fixed → SQL, evolving/varied → NoSQL
-- 2. Consistency: strict ACID → SQL, eventual consistency OK → NoSQL
-- 3. Scale: vertical (bigger server) → SQL, horizontal (more servers) → NoSQL
-- 4. Queries: complex JOINs → SQL, simple lookups → NoSQL
-- 5. Team expertise: SQL is universal, NoSQL varies

-- Many modern databases blur the line:
-- PostgreSQL: JSON columns, full-text search, pub/sub
-- MongoDB: transactions (multi-document ACID since 4.0)

ドキュメントストアパターン(MongoDB スタイル)

ドキュメントストアはテーブル間で正規化するのではなく、関連データを1つのドキュメントに埋め込みます。これにより読み取り多いアクセスパターンの JOIN が排除されますが、データが重複します(すべての注文に顧客情報)。埋め込みはデータが一緒にアクセスされ、サイズが境界がある場合に機能します。境界のない関係(数千の注文を持つ顧客)の場合、参照を使用します(customer_id を格納し、別々に取得)。PostgreSQL の JSONB 列はリレーショナルデータベース内でドキュメントストアの柔軟性を提供します — ACID トランジクション、インデックス作成(GIN)、JSON 上の SQL クエリを取得します。このハイブリッドアプローチはますます人気があり、別個の NoSQL データベースの必要性を削減します。

sql
-- In SQL, you'd normalize this into 3 tables:
-- customers, orders, order_items

-- In a document store (MongoDB), you might embed everything:
-- (pseudo-code, not SQL)
// db.orders.insertOne({
//   _id: 1,
//   customer: { name: "Alice", email: "[email protected]" },
//   items: [
//     { product: "Laptop", price: 999, qty: 1 },
//     { product: "Mouse", price: 25, qty: 2 }
//   ],
//   total: 1049,
//   status: "shipped",
//   created_at: ISODate("2024-01-15")
// })

-- SQL equivalent with JSON column (PostgreSQL):
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    data JSONB NOT NULL  -- stores the entire document
);
INSERT INTO orders (data) VALUES ('{
    "customer": {"name": "Alice", "email": "[email protected]"},
    "items": [{"product": "Laptop", "price": 999, "qty": 1}],
    "total": 999
}');

-- Query JSON data:
SELECT data->'customer'->>'name' AS name FROM orders
WHERE data->'total' > 500;

キーバリューストアパターン(Redis スタイル)

Redis のようなキーバリューストアはデータがメモリにあるため、超高速ルックアップ(サブミリ秒)に優れています。一般的なユースケース:高コストなクエリ結果のキャッシュ、セッションストレージ(TTL 期限付き)、リアルタイムカウンタ(アトミック INCR)、リーダーボード(ソート済みセット)。Redis データ構造(リスト、セット、ソート済みセット、ハッシュ)はシンプルなキーバリューを超えます。トレードオフ:データはインメモリ(RAM で制限)、永続化はオプションです。SQL の前にキャッシュレイヤーとして Redis を使用します — ライトスルーまたはキャッシュアサイドパターン。セッションデータには、Redis の自動期限切れ(TTL)が理想的です。SQL データベースはキャッシュテーブルでキャッシュをエミュレートできますが、ホットデータの Redis の速度には匹敵しません。

sql
-- Redis is an in-memory key-value store — not SQL
-- Common patterns (Redis commands, not SQL):

-- Caching: store expensive query results
// SET user:42:profile '{"name":"Alice","age":30}' EX 3600
// GET user:42:profile  -- returns cached data, expires in 1 hour

-- Session storage: fast, ephemeral
// SET session:abc123 '{"user_id":42}' EX 1800  -- 30 min TTL

-- Counters & leaderboards:
// INCR page:home:views           -- atomic counter
// ZADD leaderboard 1500 "alice"  -- sorted set for rankings
// ZREVRANGE leaderboard 0 9      -- top 10 players

-- SQL equivalent for caching (materialized/precomputed):
CREATE TABLE cache (
    cache_key VARCHAR(200) PRIMARY KEY,
    cache_value TEXT,
    expires_at TIMESTAMP,
    INDEX idx_expires (expires_at)
);
-- Periodically: DELETE FROM cache WHERE expires_at < NOW();

-- When to use Redis vs SQL cache:
-- Redis: sub-millisecond reads, data structures (sets, sorted sets)
-- SQL: when you need ACID or already have a database connection

ポリグロット永続性(データベースの混在)

ポリグロット永続性は1つのアプリケーション内で異なるデータニーズに異なるデータベースを使用します。PostgreSQL はトランザクションを処理し、Redis はキャッシュを処理し、Elasticsearch は検索を処理し、S3 はファイルを処理します。課題はストア間でデータを一貫性を保つことです — 解決策はイベント駆動アーキテクチャです:プライマリデータベース(信頼の源)に書き込み、Change Data Capture(CDC)またはメッセージキュー(Kafka、RabbitMQ)経由で他のストアに非同期で変更を伝播します。これにより結果的一貫性が得られます — セカンダリストアからの読み取りはわずかに遅れる可能性があります。利点:各ストアがワークロードに最適化されています。コスト:運用の複雑さ。単一の SQL データベースから始め、明確なパフォーマンス限界に達した場合のみ特殊なストアを追加します。

sql
-- Modern applications often use MULTIPLE database types:
-- Each data store handles what it does best

-- Typical architecture:
-- 1. PostgreSQL: core transactional data (users, orders, payments)
--    ACID guarantees, complex queries, foreign keys
CREATE TABLE users (id SERIAL PRIMARY KEY, email VARCHAR UNIQUE);
CREATE TABLE orders (id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id));

-- 2. Redis: session storage, caching, rate limiting
--    Fast in-memory access, auto-expiring keys

-- 3. Elasticsearch: full-text search, log analytics
--    Inverted index, faceted search, aggregations

-- 4. S3/Object storage: files, images, backups
--    Cheap, unlimited, HTTP-accessible

-- 5. TimescaleDB/InfluxDB: time-series metrics
--    Optimized for timestamped data, downsampling

-- Challenge: data consistency across stores
-- Solution: event-driven architecture (CDC, message queues)
--   1. Write to PostgreSQL (source of truth)
--   2. Publish event to Kafka
--   3. Consumers update Redis cache, Elasticsearch index, etc.
--   4. eventual consistency — reads may be slightly stale

ACID と BASE 一貫性モデル

ACID(原子性、一貫性、分離性、永続性)は厳密な一貫性を保証します — トランザクションは全か無かで、データは常に制約を満たします。これは部分更新がエラーを引き起こす金融システムに不可欠です。BASE(基本的に利用可能、ソフト状態、結果的一貫性)は即時の一貫性を可用性と分断耐性のためにトレードします — データは一時的に不整合かもしれませんが、時間と共に収束します。CAP 定理はネットワーク分断中に3つすべて(一貫性、可用性、分断耐性)を同時に持てないと述べます。SQL データベースは C+A(単一ノード)または C+P(分散)を優先します。多くの NoSQL データベースは A+P(Cassandra、DynamoDB)を優先します。正確性が重要な場合は ACID を、可用性とスケールがより重要な場合は BASE を選択します。

sql
-- ACID (SQL databases):
-- Atomicity: all operations in a transaction succeed or fail together
-- Consistency: data always satisfies constraints (FK, CHECK, etc.)
-- Isolation: concurrent transactions don't interfere
-- Durability: committed data survives crashes

-- SQL transaction example:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- If either fails, ROLLBACK undoes both
COMMIT;  -- both updates are permanent

-- BASE (NoSQL databases):
-- Basically Available: system responds (may be stale)
-- Soft state: state changes without input (eventual consistency)
-- Eventually consistent: data converges over time

-- NoSQL trade-off: higher availability & partition tolerance
-- at the cost of immediate consistency

-- CAP Theorem: in a network partition, choose:
-- - CP (consistency): refuse writes (some NoSQL: HBase, MongoDB)
-- - AP (availability): accept writes, reconcile later (Cassandra, DynamoDB)

-- SQL databases are typically CA (consistent + available, no partition tolerance
-- in single-node setups; distributed SQL like CockroachDB adds P)
19

CTE と再帰 CTE

基本 CTE

CTE(共通テーブル式)は一時的な名前付き結果セットです。複雑なクエリを分割して可読性を向上させます。複数の CTE をカンマでチェーンできます。CTE は単一文でのみ有効です。

sql
WITH high_earners AS (
  SELECT * FROM employees WHERE salary > 80000
), by_dept AS (
  SELECT department, COUNT(*) AS cnt FROM high_earners GROUP BY department
)
SELECT * FROM by_dept ORDER BY cnt DESC;

再帰 CTE

再帰 CTE は自身を参照します。アンカーがベースケースです。UNION ALL が再帰部分に接続します。階層データに使用されます:組織図、ファイルシステム、グラフトラバーサル。終了条件が必要です。

sql
WITH RECURSIVE org_chart AS (
  -- Anchor: top-level managers
  SELECT id, name, manager_id, 1 AS level
  FROM employees WHERE manager_id IS NULL
  UNION ALL
  -- Recursive: subordinates
  SELECT e.id, e.name, e.manager_id, oc.level + 1
  FROM employees e
  JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT * FROM org_chart;

CTE でフィボナッチ

再帰 CTE はシーケンスを生成できます。アンカーが最初の値を提供します。各反復で次を計算します。WHERE 節が無限再帰を防ぎます。数学的シーケンスに便利です。

sql
WITH RECURSIVE fib(n, a, b) AS (
  SELECT 1, 0, 1
  UNION ALL
  SELECT n + 1, b, a + b FROM fib WHERE n < 10
)
SELECT n, a FROM fib;
-- Result: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34

ツリートラバーサル

各再帰で名前を連結してパスを構築します。CAST により path 列が十分な幅を持つことを保証します。パンくず、ファイルパス、カテゴリ階層に便利です。ORDER BY path で階層的にソートします。

sql
WITH RECURSIVE category_tree AS (
  SELECT id, name, parent_id, CAST(name AS VARCHAR(1000)) AS path
  FROM categories WHERE parent_id IS NULL
  UNION ALL
  SELECT c.id, c.name, c.parent_id, ct.path || ' > ' || c.name
  FROM categories c JOIN category_tree ct ON c.parent_id = ct.id
)
SELECT id, path FROM category_tree ORDER BY path;

CTE とサブクエリ

CTE は可読性を向上させ、複数回参照できます。サブクエリはインラインで再利用できません。CTE は常にマテリアライズ化されるわけではありません。オプティマイザがインライン化する場合があります。明確さのために CTE を使用します。

sql
-- CTE: more readable, can be referenced multiple times
WITH active_users AS (SELECT * FROM users WHERE active = 1)
SELECT * FROM active_users WHERE age > 18
UNION ALL
SELECT * FROM active_users WHERE age <= 18;
-- Subquery: inline, cannot be reused
SELECT * FROM (SELECT * FROM users WHERE active = 1) au WHERE au.age > 18;
20

インデックス ディープダイブ

B-Tree インデックス

B-Tree はデフォルトのインデックスタイプです。複合インデックスは左端プレフィックスルールに従います:先頭列でフィルタする場合にクエリはインデックスを使用できます。選択性とクエリパターンで列を順序付けします。

sql
CREATE INDEX idx_email ON users(email);
CREATE INDEX idx_name_age ON users(last_name, first_name, age);
-- Composite index: useful for
-- WHERE last_name = 'Smith' AND first_name = 'John'
-- WHERE last_name = 'Smith' (leftmost prefix)

部分インデックス

部分インデックスは WHERE 節にマッチする行のみを含みます。フルインデックスより小さく高速です。常に条件でフィルタするクエリに理想的です。書き込みオーバーヘッドを削減します。

sql
CREATE INDEX idx_active_users ON users(last_login) WHERE active = 1;
-- Only indexes active users, saving space
-- Useful when queries always filter on active = 1

カバリングインデックス

カバリングインデックスはクエリに必要なすべての列を含み、インデックスオンリースキャンを可能にします。PostgreSQL は非キー列に INCLUDE を使用します。テーブルルックアップを回避することで SELECT クエリを劇的に高速化します。

sql
-- PostgreSQL: INCLUDE clause
CREATE INDEX idx_users_covering ON users(last_name) INCLUDE (first_name, email);
-- The query is "covered" if all columns are in the index:
SELECT first_name, email FROM users WHERE last_name = 'Smith';
-- No table lookup needed (index-only scan)

インデックスタイプ

異なるインデックスタイプは異なるニーズに対応します。一般用途に B-Tree。等価のみに Hash。フルテキストと JSON に GIN。幾何データに GiST。クエリパターンに基づいて選択します。

sql
-- B-Tree: default, good for equality and range
CREATE INDEX idx_btree ON users(email);
-- Hash: equality only (PostgreSQL)
CREATE INDEX idx_hash ON users(email) USING HASH;
-- GIN: full-text search, arrays, JSON
CREATE INDEX idx_gin ON docs USING GIN (tsv);
-- GiST: geometric, range types
CREATE INDEX idx_gist ON places USING GIST (location);

インデックスメンテナンス

書き込みを遅くする未使用インデックスを削除するため使用状況を監視します。REINDEX が断片化したインデックスを再構築します。ANALYZE がクエリプランナの統計を更新します。定期的なメンテナンスでパフォーマンスを最適に保ちます。

sql
-- Check index usage (PostgreSQL)
SELECT * FROM pg_stat_user_indexes;
-- Find unused indexes
SELECT relname, indexrelname FROM pg_stat_user_indexes WHERE idx_scan = 0;
-- Rebuild fragmented index
REINDEX INDEX idx_users_email;
-- Analyze for query planner
ANALYZE users;
21

トランザクション

ACID プロパティ

ACID:原子性(全か無か)、一貫性(有効な状態)、分離性(並行トランザクションが干渉しない)、永続性(コミットされたデータが永続)。BEGIN が開始、COMMIT が保存、ROLLBACK が取り消し。

sql
BEGIN TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- Or: ROLLBACK to undo

セーブポイント

セーブポイントはトランザクション内に部分的ロールバックポイントを作成します。ROLLBACK TO はトランザクションを終了せずにセーブポイントまで取り消します。再起動せずにマルチステップ操作のエラー処理に便利です。

sql
BEGIN;
INSERT INTO orders VALUES (1);
SAVEPOINT sp1;
INSERT INTO orders VALUES (2);
-- Oops, rollback to savepoint
ROLLBACK TO sp1;
-- Only order 1 is inserted
INSERT INTO orders VALUES (3);
COMMIT;

分離レベル

分離レベルは一貫性とパフォーマンスのバランスを取ります。READ COMMITTED(デフォルト)はダーティリードを防ぎます。REPEATABLE READ は非再現リードを防ぎます。SERIALIZABLE はファントムリードを防ぎますが最も遅いです。

sql
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- Levels (increasing isolation):
-- READ UNCOMMITTED: dirty reads
-- READ COMMITTED: no dirty reads (default)
-- REPEATABLE READ: no non-repeatable reads
-- SERIALIZABLE: full isolation

デッドロック

デッドロックはトランザクションが互いに必要なロックを保持している時に発生します。データベースはデッドロックを検出し、1つのトランザクションを中止します。一貫した順序でテーブルにアクセスすることで防ぎます。トランザクションを短く保ちます。

sql
-- Transaction 1
BEGIN;
UPDATE accounts SET balance = 0 WHERE id = 1;
UPDATE accounts SET balance = 0 WHERE id = 2;  -- Waits
-- Transaction 2
BEGIN;
UPDATE accounts SET balance = 0 WHERE id = 2;
UPDATE accounts SET balance = 0 WHERE id = 1;  -- Waits
-- Deadlock! Database aborts one transaction

楽観的ロック

楽観的ロックは競合が稀と仮定します。バージョン列が変更を追跡します。UPDATE が0行に影響する場合、データは別のトランザクションによって変更されました。再試行またはユーザーに通知します。長いロック保持を回避します。

sql
-- Add version column
ALTER TABLE products ADD COLUMN version INT DEFAULT 0;
-- Update with version check
UPDATE products SET price = 100, version = version + 1
WHERE id = 1 AND version = 5;
-- If 0 rows affected, someone else updated first
22

SQL の JSON

PostgreSQL JSONB

JSONB は JSON をバイナリ形式で格納し、インデックス作成と高速クエリを可能にします。->> はテキストとして抽出、-> は JSON として抽出します。JSONB は JSON よりクエリに優れています。JSONB 列に GIN インデックスを使用します。

sql
CREATE TABLE events (id SERIAL, data JSONB);
INSERT INTO events (data) VALUES ('{"user": "alice", "action": "login"}');
SELECT data->>'user' AS user_name FROM events;
SELECT * FROM events WHERE data->>'action' = 'login';

JSON クエリ

-> は JSON をナビゲート、->> はテキストを返します。@> は包含をチェックします。jsonb_set はネストした値を更新します。jsonb_object_keys はトップレベルキーを返します。これらの演算子で強力な JSON クエリが可能です。

sql
SELECT data->'address'->'city' AS city FROM users;
SELECT * FROM users WHERE data @> '{"role": "admin"}';
SELECT jsonb_object_keys(data) FROM users;
-- Update JSON
UPDATE users SET data = jsonb_set(data, '{last_login}', '"2024-01-01"');

JSON 集計

json_agg は行を JSON 配列に集計します。json_build_object は列から JSON オブジェクトを構築します。SQL から直接 API レスポンスを生成するのに便利です。リレーショナルデータとドキュメントデータを組み合わせます。

sql
SELECT department,
  json_agg(json_build_object('name', name, 'salary', salary)) AS employees
FROM employees
GROUP BY department;
-- Result: {"department": "Eng", "employees": [{"name": "Alice", "salary": 90000}, ...]}

MySQL JSON

MySQL は JSON に $.path 構文を使用します。JSON_EXTRACT が値を取得、JSON_SET が更新します。->> はテキスト結果付き JSON_EXTRACT の短縮形です。MySQL JSON は挿入時に検証されます。

sql
CREATE TABLE config (id INT, settings JSON);
INSERT INTO config VALUES (1, '{"theme": "dark", "lang": "en"}');
SELECT settings->>'$.theme' FROM config;
SELECT * FROM config WHERE JSON_EXTRACT(settings, '$.lang') = 'en';
-- Update
UPDATE config SET settings = JSON_SET(settings, '$.theme', 'light');

JSON インデックス

JSONB 上の GIN インデックスは任意のキーの高速クエリを可能にします。特定のパス上の式インデックスは対象クエリ用により小さく高速です。パフォーマンスのために頻繁にクエリされる JSON パスをインデックス化します。

sql
-- PostgreSQL GIN index on JSONB
CREATE INDEX idx_events_data ON events USING GIN (data);
-- Index specific path
CREATE INDEX idx_events_user ON events ((data->>'user'));
-- MySQL functional index
CREATE INDEX idx_theme ON config ((CAST(settings->>'$.theme' AS CHAR(50))));
23

パフォーマンスチューニング

EXPLAIN ANALYZE

EXPLAIN はクエリプランを表示し、ANALYZE はタイミング付きで実行します。Seq Scan はインデックス不足を示します。Index Scan が理想的です。高いコスト数値と遅い操作を探します。最適化の前に常に EXPLAIN してください。

sql
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = '[email protected]';
-- Shows: scan type, cost, rows, actual time
-- Seq Scan: full table scan (slow)
-- Index Scan: uses index (fast)
-- Bitmap Heap Scan: index + table lookup

クエリ最適化

I/O を削減するため必要な列のみ選択します。インデックス付き列の関数を避けます(非サーガブル)。サーガブル(Search Argument Able)クエリはインデックスを使用できます。関数の代わりに範囲条件を使用します。

sql
-- BAD: SELECT * fetches all columns
SELECT * FROM users;
-- GOOD: select only needed columns
SELECT id, name FROM users;
-- BAD: function on indexed column
WHERE YEAR(created_at) = 2024
-- GOOD: sargable
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'

JOIN 最適化

すべての結合列をインデックス化します。オプティマイザは統計に基づいて結合順序を選択します。INNER JOIN が通常最速です。式での結合を避けます。大規模データセットには、非正規化またはマテリアライズドビューを検討します。

sql
-- Use INNER JOIN for required relationships
SELECT u.name, o.total FROM users u
INNER JOIN orders o ON u.id = o.user_id;
-- Index join columns
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- For large joins, ensure both columns are indexed

ページネーション

OFFSET ページネーションは O(n) です — スキップされたすべての行をスキャンします。キーセット(カーソル)ページネーションは O(1) です — インデックスを使用します。安定したソートのためにタプル比較を使用します。深いページネーションにはるかに高速です。

sql
-- BAD: OFFSET scans all skipped rows
SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 10000;
-- GOOD: keyset pagination
SELECT * FROM users WHERE id > 10000 ORDER BY id LIMIT 10;
-- Stable pagination with cursor
SELECT * FROM users WHERE (created_at, id) > ('2024-01-01', 100) ORDER BY created_at, id LIMIT 10;

マテリアライズドビュー

マテリアライズドビューはクエリ結果を物理的に格納します。高コストな集計にはビューより高速です。REFRESH がデータを更新します(CONCURRENTLY オプションで並行)。高速クエリのためにインデックス化します。

sql
CREATE MATERIALIZED VIEW sales_summary AS
SELECT product_id, SUM(quantity) AS total, AVG(price) AS avg_price
FROM sales GROUP BY product_id;
-- Refresh periodically
REFRESH MATERIALIZED VIEW sales_summary;
-- Create index on materialized view
CREATE INDEX ON sales_summary (total);
24

高度な JOIN

自己結合

自己結合はテーブルを自身に対してクエリします。区別するためにエイリアスを使用します。階層データ(従業員-管理者)とペアの発見に一般的です。a.id < b.id のトリックで重複ペアを回避します。

sql
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
-- Find pairs in same department
SELECT a.name, b.name FROM employees a, employees b
WHERE a.department = b.department AND a.id < b.id;

CROSS JOIN

CROSS JOIN はデカルト積を生成します:A のすべての行と B のすべての行を組み合わせます。組み合わせの生成に便利です。注意:巨大な結果セットを生成する可能性があります。カンマ構文で暗黙的に使用されることがよくあります。

sql
-- Cartesian product: every combination
SELECT s.size, c.color FROM sizes s CROSS JOIN colors c;
-- Generate all size/color combinations
-- Useful for generating test data or matrices

FULL OUTER JOIN

FULL OUTER JOIN は両方のテーブルのすべての行を返します。NULL が非マッチ側を埋めます。両方向でマッチしないレコードを見つけるのに便利です。MySQL ではサポートされていません(LEFT と RIGHT JOIN の UNION でエミュレート)。

sql
SELECT u.name, o.order_id
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id;
-- Returns all users and all orders
-- NULLs where there is no match

アンチジョイン

アンチジョインは B にマッチしない A の行を見つけます。NOT EXISTS が通常最も明確でしばしば最速です。IS NULL 付き LEFT JOIN が代替です。欠落した関係を見つけるために使用します。

sql
-- Users who have never ordered
SELECT u.* FROM users u
WHERE NOT EXISTS (
    SELECT 1 FROM orders o WHERE o.user_id = u.id
);
-- Alternative: LEFT JOIN ... WHERE IS NULL
SELECT u.* FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;

セミジョイン

セミジョインは B の少なくとも1行にマッチする A の行を返します。EXISTS は最初のマッチで停止するため効率的です。IN は同等ですがパフォーマンスが異なる場合があります。相関サブクエリには EXISTS を使用します。

sql
-- Users who have at least one order
SELECT u.* FROM users u
WHERE EXISTS (
    SELECT 1 FROM orders o WHERE o.user_id = u.id
);
-- Alternative: IN
SELECT * FROM users WHERE id IN (SELECT user_id FROM orders);
25

よくある落とし穴

NULL 比較

NULL は値ではなく未知です。= NULL は常に NULL を返します(偽として扱われる)。IS NULL と IS NOT NULL を使用します。NULL は算術を通じて伝播します。デフォルトを提供するには COALESCE を使用します。

sql
-- NULL = NULL is NULL (not true!)
SELECT * FROM users WHERE phone = NULL;  -- Returns nothing
SELECT * FROM users WHERE phone IS NULL;  -- Correct
SELECT * FROM users WHERE phone IS NOT NULL;
-- NULL in arithmetic yields NULL
SELECT 5 + NULL;  -- NULL

SQL インジェクション

SQL インジェクションは攻撃者が任意の SQL を実行できるようにします。ユーザー入力をクエリに連結しないでください。常にパラメータ化クエリ/プリペアドステートメントを使用します。すべての入力を検証してサニタイズします。ORM のパラメータバインディングを使用します。

sql
-- BAD: string concatenation
query = "SELECT * FROM users WHERE name = '" + input + "'"
-- GOOD: parameterized queries
SELECT * FROM users WHERE name = ?;
-- PostgreSQL: $1
-- MySQL: ?
-- Always use parameters, never concatenate

GROUP BY の落とし穴

GROUP BY 使用時、SELECT のすべての非集計列は GROUP BY になければなりません。そうでないと結果は曖昧です。MySQL はこれを許可します(任意の値を返す)が不正確です。常に標準に従ってください。

sql
-- BAD: non-aggregated column not in GROUP BY
SELECT department, name, COUNT(*) FROM employees GROUP BY department;
-- Error: which name to show?
-- GOOD: aggregate or include in GROUP BY
SELECT department, COUNT(*) FROM employees GROUP BY department;
SELECT department, MAX(name) FROM employees GROUP BY department;

浮動小数点

FLOAT と DOUBLE は近似型です。正確な精度(お金、測定)には DECIMAL/NUMERIC を使用します。DECIMAL(10,2) は小数点以下2桁で10桁を許可します。金融データに FLOAT を使用しないでください。

sql
-- Floating point precision issues
SELECT 0.1 + 0.2;  -- 0.30000000000000004
-- Use DECIMAL for money
CREATE TABLE accounts (balance DECIMAL(10, 2));
SELECT 0.10 + 0.20;  -- 0.30 (exact)

暗黙の型変換

暗黙の型変換はインデックスを無効化し、フルテキストスキャンを引き起こす可能性があります。常にマッチする型を比較します。必要な場合は明示的にキャストします。列型をチェックし、クエリパラメータがマッチすることを確認します。

sql
-- BAD: comparing string to number
SELECT * FROM users WHERE phone = 1234567890;
-- May cause full table scan due to type conversion
-- GOOD: compare same types
SELECT * FROM users WHERE phone = '1234567890';
-- Or cast explicitly
SELECT * FROM users WHERE CAST(phone AS BIGINT) = 1234567890;

Was this helpful?