はじめに
こんにちは!iimonでエンジニアをしているつかちゃんです!
SQLという単語は入社してからずっと耳にしていました。でも自分が触ることはなく、なんとなく「データベースに問い合わせるやつ」くらいのイメージしか持っていませんでした。
今回、Redash経由でデータを集計する機会があり、初めてSQLを自分で書くことになりました。実際に書いて、エラーになって、直して、結果が返ってきたとき「あ、これがSQLか!」となりました。
使ったのはRedashから Athena に接続し、federated query という仕組みでデータを取得するというものでした。SQLの書き方でつまずいたポイントを、仮想の例を使って整理してみます。
| 用語 | 説明 |
|---|---|
| Redash | データベースへのクエリ実行や結果の可視化ができるBIツール |
| Federated Query | 外部のデータソースを直接SQLで検索する仕組み |
題材: 注文テーブルとユーザーテーブル
説明用に、こんな2つのテーブルがあるとします。
orders(注文テーブル) ← 誰が何を注文したか
| id | user_id | item | status |
|---|---|---|---|
| 1 | 101 | りんご | done(完了) |
| 2 | 102 | みかん | done(完了) |
| 3 | 101 | ぶどう | canceled(キャンセル) |
users(ユーザーテーブル) ← ユーザーの一覧
| id | name |
|---|---|
| 101 | Alice |
| 102 | Bob |
| 103 | Carol |
Carolは注文が1件もありません。
ここから「ユーザーごとの完了済み注文数」を集計したい、というのがやりたいことです。
欲しい結果はこれです:
結果: Alice → 1件、Bob → 1件、Carol → 0件
Carolは注文が1件もないので0になってほしいのがポイントです。
つまずきポイント①: JOINの条件をどこに書くか問題
一番時間を使ったのがここでした。
最初、こんな感じで「とりあえずJOINしてWHEREで絞ればいいんでしょ?」というノリで書いていました。
SELECT u.id, u.name, COUNT(o.id) AS done_order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.status = 'done' GROUP BY u.id, u.name
実行結果:
| id | name | done_order_count |
|---|---|---|
| 101 | Alice | 1 |
| 102 | Bob | 1 |
Carolが消えてしまっています。LEFT JOINしているのに、です。
原因は、JOIN条件(ON句)とフィルタ条件(WHERE句)を混同していたこと でした。
WHERE o.status = 'done'は「JOINした後の行」に対してかかる条件- Carolは
orders側がNULLなので、o.status = 'done'という条件自体を満たせず、行ごと弾かれてしまう - つまり
LEFT JOINしているのに、実質INNER JOINと同じ挙動になっていた
正しくは、絞り込み条件をON句側に入れる必要がありました。
SELECT u.id, u.name, COUNT(o.id) AS done_order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'done' GROUP BY u.id, u.name
実行結果:
| id | name | done_order_count |
|---|---|---|
| 101 | Alice | 1 |
| 102 | Bob | 1 |
| 103 | Carol | 0 |
こうすると、Carolもdone_order_count = 0として結果に残ります。
つまずきポイント②: GROUP BYと集計列の対応
集計(COUNTやSUM)を使うとき、SELECTに書いた列は基本的に全部GROUP BYに入れるか、集計関数でくるむ必要がある、というルールにも地味に苦戦しました。
-- これはエラーになる SELECT u.id, u.name, u.created_at, COUNT(o.id) AS done_order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'done' GROUP BY u.id, u.name
実行結果:
ERROR: Column 'u.created_at' must appear in the GROUP BY clause or be used in an aggregate function
u.created_atをSELECTに追加しただけでエラーになって、「なんで?」と思ったんですが、GROUP BYに含まれていない列(かつ集計関数でもない列)はそのまま選択できない、というSQLのルールに当たっていただけでした。
-- GROUP BYにも追加すればOK SELECT u.id, u.name, u.created_at, COUNT(o.id) AS done_order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'done' GROUP BY u.id, u.name, u.created_at
実行結果:
| id | name | created_at | done_order_count |
|---|---|---|---|
| 101 | Alice | 2024-01-01 | 1 |
| 102 | Bob | 2024-02-15 | 1 |
| 103 | Carol | 2024-03-10 | 0 |
TypeScriptの型エラーみたいに「ルールに沿ってないと教えてくれる」感じで、わかってしまえば納得感はありました。
つまずきポイント③: JSONカラムから日付を取り出して期間絞り込み
こんなクエリを書く場面がありました。
WHERE date_parse(json_extract_scalar(params, '$.created_at'), '%Y-%m-%dT%H:%i:%s.%fZ') >= date_add('day', -7, current_date) AND date_parse(json_extract_scalar(params, '$.created_at'), '%Y-%m-%dT%H:%i:%s.%fZ') < current_date
複雑に見えますが、分解してみると3つの部品で出来ていました。
① json_extract_scalar: JSONから値を取り出す
paramsカラムにはJSON文字列が入っていることがあります。例えばこんな形です。
{"created_at": "2025-06-24T10:30:00.000Z", "user_id": 101}
ここからcreated_atの値だけ取り出すのがjson_extract_scalarです。
json_extract_scalar(params, '$.created_at') -- => '2025-06-24T10:30:00.000Z' (文字列)
$.created_atの$はJSON全体を指していて、.created_atでキーを指定するイメージです。ただしこの時点ではあくまで文字列です。
② date_parse: 文字列→日付型に変換する
取り出した文字列'2025-06-24T10:30:00.000Z'は、このままでは日付として比較できません。そこでdate_parseで日付型に変換します。
date_parse('2025-06-24T10:30:00.000Z', '%Y-%m-%dT%H:%i:%s.%fZ') -- => timestamp型の値
第2引数がフォーマット指定で、文字列の形式に合わせて書く必要があります。%Yが年、%mが月、%dが日、%H:%i:%sが時分秒、%fがミリ秒です。フォーマットが1文字でも違うとエラーになるので、ここは正確に合わせる必要がありました。
③ date_add と current_date: 上限を書き忘れると今日分まで入ってくる
-- 最初はこう書いていた(下限だけ指定) WHERE date_parse(json_extract_scalar(params, '$.created_at'), '%Y-%m-%dT%H:%i:%s.%fZ') >= date_add('day', -7, current_date) -- 6/23以降
実行結果: 6/23〜6/30の8日分が返ってきてしまいます。
date_add('day', -7, current_date)は7日前(6/23)を返すので、「6/23以降」という下限だけの条件になります。上限を指定していないので、今日(6/30)のデータも含まれてしまい、結果が6/23〜6/30の8日分になっていました。
欲しかったのは6/23〜6/29の7日分だったので、上限として< current_date(今日より前)を追加するのが正解でした。
-- 上限を追加して7日分に絞る WHERE date_parse(json_extract_scalar(params, '$.created_at'), '%Y-%m-%dT%H:%i:%s.%fZ') >= date_add('day', -7, current_date) -- 6/23以降 AND date_parse(json_extract_scalar(params, '$.created_at'), '%Y-%m-%dT%H:%i:%s.%fZ') < current_date -- 6/30より前(=6/29まで)
実行結果: 6/23〜6/29の7日分が返ってきます。
< current_dateにすることで今日を除外でき、ちょうど7日分になります。期間を絞るときは上限・下限の両方を意識する必要があると学びました。
学んだこと
- ON vs WHERE:
LEFT JOINを使うときは、絞り込み条件をONに書くかWHEREに書くかで結果が変わる。「結合する条件」と「絞り込む条件」を分けて考える - GROUP BYの対応: 集計するときは、
SELECTに書く列とGROUP BYに書く列の対応関係を常に意識する - JSONと日付の変換は3ステップ:
json_extract_scalarで文字列取り出し →date_parseで日付型に変換 →date_add/current_dateで範囲を作る、という順番で分解すると読みやすくなる - 期間は上限・下限の両方を書く: 下限(
>= 7日前)だけだと今日分まで含まれてしまう。< current_dateで上限も指定して初めて「昨日まで7日分」になる
おわりに
最初は何を書けばいいかイメージすらできず、かなり手探りでした。ただ一つひとつ分解して考えれば、複雑に見えるクエリも理解できると気づいてからだいぶスムーズになりました。次は集計結果をRedashのダッシュボードで可視化するところまでやってみたいと思います。
最後まで読んでくださりありがとうございました。
現在弊社ではエンジニアを募集しています! この記事を読んで少しでも興味を持ってくださった方は、ぜひカジュアル面談でお話ししましょう!
iimon採用サイト / Wantedly / Green