SELECT + WHERE + ORDER BY — APIレスポンスの基本データ取得パターン
SELECT はテーブルから行・列を取り出す最基本の命令です。WHERE で条件絞り込み、ORDER BY で並び順を制御します。
SELECT col1, col2 -- 取得したい列を列挙(* で全列) FROM table_name -- 対象テーブル WHERE col1 = 'value' -- 絞り込み条件(省略可) ORDER BY col2 DESC; -- 並び順(ASC=昇順/DESC=降順、省略時はASC)
=(等しい)、!= または <>(等しくない)、> < >= <=(大小比較)、LIKE '%word%'(部分一致)、IN (a, b, c)(複数値いずれか)、IS NULL(NULL判定) — = NULL は NG
APIでユーザー一覧を返すエンドポイントでは、WHERE で active なユーザーだけ絞り、ORDER BY で最新順にするのが定石です。
users テーブルから、status が 'active' かつ plan が 'premium' のユーザーを、created_at の新しい順で取得してください。取得する列は user_id, name, email, created_at のみとします。
| user_id | name | status | plan | created_at | |
|---|---|---|---|---|---|
| 1 | 田中 太郎 | tanaka@ex.com | active | premium | 2024-03-10 |
| 2 | 佐藤 花子 | sato@ex.com | inactive | premium | 2024-02-01 |
| 3 | 鈴木 一郎 | suzuki@ex.com | active | free | 2024-04-05 |
| 4 | 山田 次郎 | yamada@ex.com | active | premium | 2024-01-20 |
| 5 | 伊藤 三郎 | ito@ex.com | active | premium | 2024-05-15 |
| user_id | name | created_at | |
|---|---|---|---|
| 5 | 伊藤 三郎 | ito@ex.com | 2024-05-15 |
| 1 | 田中 太郎 | tanaka@ex.com | 2024-03-10 |
| 4 | 山田 次郎 | yamada@ex.com | 2024-01-20 |
SELECT user_id, name, email, created_at FROM users WHERE status = 'active' -- active かつ premium に絞り込み AND plan = 'premium' ORDER BY created_at DESC; -- 新しい順 /* 実行順序(SQLの論理的な評価順): 1. FROM users → 行を読み込む 2. WHERE status, plan → 行を絞り込む 3. SELECT → 列を評価 4. ORDER BY created_at DESC → 並び替えて出力 */
LEGEND
① FROM
FROM usersusers テーブル全5行を読み込みます。この段階ではすべての行が対象です。| user_id | name | status | plan |
|---|---|---|---|
| 1 | 田中 太郎 | active | premium |
| 2 | 佐藤 花子 | inactive | premium |
| 3 | 鈴木 一郎 | active | free |
| 4 | 山田 次郎 | active | premium |
| 5 | 伊藤 三郎 | active | premium |
GET /users?status=active&plan=premium のようなクエリパラメータを WHERE 条件に変換するのが最頻出パターン。アプリ層でパラメータを受け取りバインド変数(プリペアドステートメント)で安全にSQL化する。WHERE a=1 OR b=2 AND c=3 は WHERE a=1 OR (b=2 AND c=3) と解釈される。意図を明確にするため括弧を必ず書く習慣が重要。WHERE deleted_at = NULL は常にFALSEになり1行も取れない。NULL との比較は必ず IS NULL / IS NOT NULL を使うこと。NULLは「値が不明」なため = では判定できない。deleted_at などの論理削除フラグが利用されます。そのため、WHERE status = 'active' だけでなく AND deleted_at IS NULL を付け忘れると、退会済みユーザーをAPIで誤って返してしまう事故に直結します。ORMのデフォルトスコープに頼らず生SQLを書く場合は、常に「論理削除の除外」を意識する癖をつけましょう。
INNER JOIN — 関連テーブルを結合してAPIレスポンスを1クエリで組み立てる
JOIN は複数テーブルを共通の列(外部キー)でつなぎ合わせる操作です。INNER JOINは両方のテーブルに一致するデータが存在する行だけを返します(一致しない行は除外)。
SELECT a.col1, b.col2 FROM table_a a -- a はテーブルの別名(エイリアス) INNER JOIN table_b b -- b も別名。INNER は省略可(JOIN のみでもINNER JOIN) ON a.id = b.a_id; -- 結合条件(外部キー = 主キー)
APIで「注文一覧をユーザー名付きで返す」ような処理は JOIN が必須です。アプリ側でN+1クエリを避けるために1クエリで結合するのが基本です。
orders テーブルと users テーブルを結合し、status が 'completed' の注文について order_id, user_name, amount, ordered_at を取得してください。ordered_at の新しい順に並べてください。
| order_id | user_id | amount | status | ordered_at |
|---|---|---|---|---|
| 101 | 1 | 5000 | completed | 2024-05-10 |
| 102 | 2 | 3200 | pending | 2024-05-12 |
| 103 | 1 | 8800 | completed | 2024-05-15 |
| 104 | 3 | 1500 | completed | 2024-05-08 |
| 105 | 4 | 6200 | cancelled | 2024-05-11 |
| user_id | name |
|---|---|
| 1 | 田中 太郎 |
| 2 | 佐藤 花子 |
| 3 | 鈴木 一郎 |
| 4 | 山田 次郎 |
| order_id | user_name | amount | ordered_at |
|---|---|---|---|
| 103 | 田中 太郎 | 8800 | 2024-05-15 |
| 101 | 田中 太郎 | 5000 | 2024-05-10 |
| 104 | 鈴木 一郎 | 1500 | 2024-05-08 |
SELECT o.order_id, u.name AS user_name, o.amount, o.ordered_at FROM orders o INNER JOIN users u -- 両方に存在する行だけ結合 ON o.user_id = u.user_id WHERE o.status = 'completed' -- 完了した注文のみ ORDER BY o.ordered_at DESC; /* 実行順序(SQLの論理的な評価順): 1. FROM orders o → 行を読み込む 2. INNER JOIN users u → 結合(一致行のみ) 3. WHERE o.status → 行を絞り込む 4. SELECT → 列を評価 5. ORDER BY o.ordered_at DESC → 並び替えて出力 */
LEGEND
① FROM / INNER JOIN
FROM orders o INNER JOIN users uorders テーブル(5行)と users テーブル(4行)を準備します。INNER JOIN では両テーブルに一致する行だけが結合されます。| order_id | user_id | amount | status | ordered_at |
|---|---|---|---|---|
| 101 | 1 | 5000 | completed | 2024-05-10 |
| 102 | 2 | 3200 | pending | 2024-05-12 |
| 103 | 1 | 8800 | completed | 2024-05-15 |
| 104 | 3 | 1500 | completed | 2024-05-08 |
| 105 | 4 | 6200 | cancelled | 2024-05-11 |
| user_id | name |
|---|---|
| 1 | 田中 太郎 |
| 2 | 佐藤 花子 |
| 3 | 鈴木 一郎 |
| 4 | 山田 次郎 |
テーブル名.列名 または エイリアス.列名 で必ず明示すること。エイリアスはクエリを短く読みやすくする。FROM orders, users や JOIN users(ON なし)は全行×全行のデカルト積(クロス結合)になる。ordersが100行・usersが1000行なら100,000行が生成されDBが応答不能になりうる。ON は必ず書くこと。FROM orders o, users u WHERE o.user_id = u.user_id という旧式の結合記法は、JOIN条件と絞り込み条件が混在して読みにくい。明示的な INNER JOIN ... ON を使うこと。GROUP BY + HAVING — 集計関数でKPIサマリAPIを1クエリで作る
GROUP BY は指定した列の値ごとに行をグループ化し、集計関数(COUNT/SUM/AVG/MAX/MIN)でグループ内を1行に集約します。HAVING は集計後の結果をさらに絞り込みます(WHERE の集計後バージョン)。
SELECT col1, COUNT(*) AS cnt, -- COUNT(*): グループ内の全行数 SUM(col2) AS total, -- SUM: 合計 AVG(col2) AS avg -- AVG: 平均 FROM table_name GROUP BY col1 -- col1 の値ごとにグループ化 HAVING COUNT(*) > 1; -- HAVING: 集計後の絞り込み(WHERE は集計前)
HAVING COUNT(*) > 5 のように集計関数を条件にする場合は必ず HAVING を使う。orders テーブルから、status が 'completed' の注文について、ユーザーIDごとに 注文件数(order_count) と 合計金額(total_amount) を集計してください。ただし 注文件数が2件以上 のユーザーだけを、合計金額の大きい順で返してください。
| order_id | user_id | amount | status |
|---|---|---|---|
| 1 | U01 | 3000 | completed |
| 2 | U01 | 5000 | completed |
| 3 | U02 | 8000 | completed |
| 4 | U03 | 2000 | cancelled |
| 5 | U01 | 1500 | completed |
| 6 | U02 | 4500 | pending |
| 7 | U03 | 6000 | completed |
| 8 | U03 | 2500 | completed |
| user_id | order_count | total_amount |
|---|---|---|
| U01 | 3 | 9500 |
| U03 | 2 | 8500 |
SELECT user_id, COUNT(*) AS order_count, SUM(amount) AS total_amount FROM orders WHERE status = 'completed' -- 集計「前」に行を絞り込み GROUP BY user_id HAVING COUNT(*) >= 2 -- 集計「後」にグループを絞り込み ORDER BY total_amount DESC; /* 実行順序(SQLの論理的な評価順): 1. FROM orders → 行を読み込む 2. WHERE status → 行を絞り込む 3. GROUP BY user_id → グループ化 4. COUNT(*) / SUM(amount) → 集計関数を評価 5. HAVING COUNT(*) >= 2 → グループを絞り込む 6. SELECT → 列を評価 7. ORDER BY total_amount DESC → 並び替えて出力 */
LEGEND
① FROM
FROM ordersorders テーブル全8行を読み込みます。| order_id | user_id | amount | status |
|---|---|---|---|
| 1 | U01 | 3000 | completed |
| 2 | U01 | 5000 | completed |
| 3 | U02 | 8000 | completed |
| 4 | U03 | 2000 | cancelled |
| 5 | U01 | 1500 | completed |
| 6 | U02 | 4500 | pending |
| 7 | U03 | 6000 | completed |
| 8 | U03 | 2500 | completed |
GET /reports/users/summary のような集計系エンドポイントはこのパターンが土台。WHERE で期間・ステータスを絞り、GROUP BY で集計軸を決め、HAVING で足切り条件を付ける。SELECT order_id, user_id, COUNT(*) と書いてもorder_idはグループ内で複数値があるためエラーになる。WHERE status='active' で事前に絞る(パフォーマンスが良い)。「集計後に件数が5以上だけ欲しい」→ HAVING COUNT(*) >= 5。集計関数を条件にするなら HAVING 一択。HAVING status = 'completed' と書くのは動くが非推奨。集計前の行レベル条件は必ず WHERE に書く。WHERE に書くと集計対象行が減りパフォーマンスが向上する。SELECT user_id, COUNT(*) FROM orders のようにGROUP BY なしで集計関数と普通の列を混在させるとエラー。PostgreSQLは 「user_idをどう集約すればいいか不明」 と判断するため必ずGROUP BYに含めること。GROUP BY をかけるとDBのメモリを食いつぶし、スロークエリやタイムアウトを引き起こします。実務では「まずは WHERE 句で対象期間(直近1週間など)を絞り込んでから GROUP BY する」のが鉄則です。絞り込みにより集約対象のデータ量が激減するため、安全かつ高速にKPIサマリAPIを返すことができます。
LIMIT + OFFSET — ページネーションAPIの基本と番号ベース実装
LIMIT は返す行数の上限を指定し、OFFSET は先頭から何行スキップするかを指定します。APIのページネーション(/items?page=2&per_page=10)の基本実装です。
SELECT col1, col2 FROM table_name ORDER BY id -- ORDER BY は必須(順序がないとページの中身が不定) LIMIT 10 -- 最大10行返す(1ページあたりの件数) OFFSET 20; -- 先頭20行をスキップ(3ページ目 = (page-1)*per_page)
OFFSET が大きくなるとDBは先頭からOFFSET行分スキャンするため大量データでは低速化します。本番の大規模APIでは「最後に取得したIDより大きいIDを取る」カーソルベースが推奨されます。
OFFSET = (page - 1) * per_page。1ページ目はOFFSET 0(スキップなし)、2ページ目はOFFSET 10、3ページ目はOFFSET 20(per_page=10の場合)。products テーブルから、1ページあたり3件、2ページ目のデータを取得してください。product_id, name, price を price の安い順で返してください。
| product_id | name | price |
|---|---|---|
| 1 | ペン | 100 |
| 2 | ノート | 200 |
| 3 | 消しゴム | 80 |
| 4 | 定規 | 150 |
| 5 | ハサミ | 300 |
| 6 | のり | 120 |
| 7 | クリップ | 50 |
| product_id | name | price |
|---|---|---|
| 6 | のり | 120 |
| 4 | 定規 | 150 |
| 2 | ノート | 200 |
SELECT product_id, name, price FROM products ORDER BY price ASC -- 安い順(ASC は省略可) LIMIT 3 -- 取得する最大行数 OFFSET 3; -- スキップする行数(4件目から) /* 実行順序(SQLの論理的な評価順): 1. FROM products → 行を読み込む 2. ORDER BY price ASC → 並び替えて出力 3. OFFSET 3 → 行をスキップ 4. LIMIT 3 → 件数を制限 */
LEGEND
① FROM
FROM productsproducts テーブル全7行を読み込みます。この段階では挿入順のままです。| product_id | name | price |
|---|---|---|
| 1 | ペン | 100 |
| 2 | ノート | 200 |
| 3 | 消しゴム | 80 |
| 4 | 定規 | 150 |
| 5 | ハサミ | 300 |
| 6 | のり | 120 |
| 7 | クリップ | 50 |
GET /products?page=2&per_page=3 を受け取ったら、サーバー側で OFFSET = (page-1) * per_page = 3 を計算してSQLに渡す。総件数も SELECT COUNT(*) FROM products で別取得しレスポンスに含めるのが一般的。LIMIT 1 で最新1件、LIMIT 5 で上位5件を取得できる。ランキングAPIや「最新投稿を3件表示」といった用途に頻出。WHERE id > :last_id ORDER BY id LIMIT 10 のように最後のIDを条件にすることで、常に高速なインデックスアクセスが可能になる。LIMIT 10 だけでは毎回異なる行が返る可能性がある。RDBMSは内部的に任意の順序で行を管理するため、ORDER BY なしの順序は保証されない。OFFSET 100000 のようにスキップ数が増えると、DBは先頭から10万行を読み込んで捨てるという無駄な処理を行うため、ページが深くなるほどAPIが遅くなります。これを防ぐため、モダンなAPI設計(TwitterやSlackなど)では、最後のIDを基準にするカーソルベースのページネーション(WHERE id > :last_id LIMIT 10)が主流になっています。データ量が増える見込みのあるシステムでは初期段階からカーソルベースを検討しましょう。
INSERT + RETURNING — 新規作成APIでDB採番IDをそのまま返す
INSERT はテーブルに新しい行を追加します。RETURNING(PostgreSQL拡張)を付けると INSERT した行の列値を SELECT のように返せます。新規作成API(POST /items)でDBが自動採番したIDをアプリに返す際の必須パターンです。
INSERT INTO table_name (col1, col2) -- 挿入先テーブルと列名 VALUES ('val1', 'val2') -- 挿入する値(列名と同じ順番・同じ数) RETURNING id, col1; -- 挿入した行の列値を返す(省略するとINSERTのみ)
シリアル列(SERIAL / BIGSERIAL)や GENERATED ALWAYS AS IDENTITY はINSERT時に自動で採番されます。アプリ側でIDを事前に決める必要はありません。
tasks テーブルに新しいタスクを作成してください。title='バッチ処理の実装'、status='pending'、created_at=現在時刻 を挿入し、DBが自動採番した id と created_at を返してください。
| 列名 | 型 | 備考 |
|---|---|---|
| id | BIGSERIAL | 自動採番・主キー(指定不要) |
| title | TEXT | タスク名 |
| status | TEXT | 'pending'/'running'/'done' |
| created_at | TIMESTAMPTZ | 作成日時 |
| id | title | status | created_at |
|---|---|---|---|
| 1 | ユーザー同期 | done | 2024-05-01 09:00 |
| 2 | メール送信 | running | 2024-05-10 11:00 |
| id | created_at |
|---|---|
| 3 | 2024-06-01 14:30:00+09 |
INSERT INTO tasks ( title, status, created_at ) VALUES ( 'バッチ処理の実装', 'pending', NOW() -- 現在日時 ) RETURNING id, created_at; -- 挿入された行の値を返す(PostgreSQL) /* 実行順序(SQLの論理的な評価順): 1. INSERT INTO tasks → 行の領域を準備 2. VALUES → 値を列に埋め込む 3. BIGSERIAL を自動採番 → id を採番 4. RETURNING id, created_at → 挿入行を返す */
LEGEND
① INSERT INTO
INSERT INTO tasks — 挿入前のテーブルtasks テーブルには既存の2行があります。INSERT 実行前の状態です。BIGSERIAL 列 id は次の挿入で id=3 を自動採番します。| id | title | status | created_at |
|---|---|---|---|
| 1 | ユーザー同期 | done | 2024-05-01 09:00 |
| 2 | メール送信 | running | 2024-05-10 11:00 |
BIGINT + シーケンス(SEQUENCE) の糖衣構文。INSERTのたびにシーケンスが自動インクリメントされる。SERIAL は INT(約21億上限)、BIGSERIALはBIGINT(約922京上限)なので大規模サービスではBIGSERIALを推奨。RETURNING * で挿入行の全列を返せる。DBのデフォルト値(DEFAULT)が入った列も返ってくるため、INSERT後のレスポンスとして使いやすい。INSERT INTO tasks (title, status) VALUES ('test') は列2つに対して値1つなのでエラー。列名リストと VALUES の要素数は必ず一致させること。INSERT INTO tasks (...) VALUES (...), (...), (...) のように複数行を1クエリで挿入するバルクインサート(一括挿入)を多用します。この際 RETURNING id を付ければ、挿入された数千件分のIDを一瞬でリストとして取得でき、後続の処理(別テーブルへの紐付けなど)が非常にスムーズになります。