02.3 集計とJOIN — データを結合して意味を取り出す¶
前のレッスンでは、1つのテーブルに対する CRUD(作成・読み取り・更新・削除)を学びました。 実際のアプリケーションでは、データは複数のテーブルに分かれています。 「顧客」「商品」「注文」がそれぞれ別のテーブルにあるとき、JOIN で結合して初めて 「誰が何をいくつ買って、いくら使ったか」という意味のある情報になります。
このレッスンのゴール:
GROUP BY/HAVINGで集計できる- 複数テーブルを
JOINで結合できる - JOIN のキーを間違えると、結果の行数が膨らんだり消えたりすることを実データで確認できる
非自明ポイント: JOIN は「間違えても動いてしまう」¶
JOIN はキーを間違えても SQL エラーにはなりません。黙って違う結果を返すだけです。
- キーがユニークでない列で結合すると、行が膨らみます(1つの行が複数の行にマッチしてしまう)
- INNER JOIN で参照先が存在しないと、その行は結果から消えます(LEFT JOIN なら残ります)
このレッスンでは、この2つの「静かな失敗」を実際のデータで再現し、正しい結果と数字で比較します。
import (
"database/sql"
"errors"
"fmt"
"strings"
_ "modernc.org/sqlite"
"github.com/janpfeifer/gonb/gonbui"
)
// ErrUnanswered は、練習問題が未回答のときにプレースホルダ関数が返す特別なエラー。
var ErrUnanswered = errors.New("未回答: この関数はまだ実装されていません")
func openCourseDB(path string) *sql.DB {
db, err := sql.Open("sqlite", path)
if err != nil {
panic(err)
}
db.SetMaxOpenConns(1)
return db
}
func renderTable(headers []string, rows [][]string) string {
var b strings.Builder
b.WriteString(`<table border="1" cellpadding="4" style="border-collapse:collapse"><tr>`)
for _, h := range headers {
b.WriteString(fmt.Sprintf("<th>%s</th>", h))
}
b.WriteString("</tr>")
for _, row := range rows {
b.WriteString("<tr>")
for _, c := range row {
b.WriteString(fmt.Sprintf("<td>%s</td>", c))
}
b.WriteString("</tr>")
}
b.WriteString("</table>")
return b.String()
}
1. データを用意する¶
顧客(customers)・商品(products)・注文(orders) の3テーブルを作ります。 わざと 2つの罠を仕込みます:
customersに 同じ名前 "sato" を持つ顧客が2人(id=3 と id=4)— 名前で結合すると危険な例productsに 同じ名前 "mouse" を持つ商品が2つ(id=2 と id=4、値段が違う)— 同上ordersに 存在しない顧客(id=99)を参照する注文(id=6)— INNER JOIN で消える例
このセルは DB 作成からシード投入まで1セルで完結させます(GoNB は %% セルを1つの関数として
実行するため、defer db.Close() を置いたセルの外に *sql.DB を持ち出さない設計にしています)。
%%
db := openCourseDB("_02_3_sql_aggregation_joins.db")
defer db.Close()
mustExec := func(query string, args ...any) {
if _, err := db.Exec(query, args...); err != nil {
panic(err)
}
}
mustExec(`DROP TABLE IF EXISTS orders`)
mustExec(`DROP TABLE IF EXISTS customers`)
mustExec(`DROP TABLE IF EXISTS products`)
mustExec(`CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT)`)
mustExec(`CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price INTEGER)`)
mustExec(`CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, product_id INTEGER, quantity INTEGER)`)
mustExec(`INSERT INTO customers (id, name) VALUES (1,'tanaka'), (2,'suzuki'), (3,'sato'), (4,'sato')`)
mustExec(`INSERT INTO products (id, name, price) VALUES (1,'keyboard',3000), (2,'mouse',1500), (3,'monitor',20000), (4,'mouse',1800)`)
mustExec(`INSERT INTO orders (id, customer_id, product_id, quantity) VALUES
(1,1,1,1),
(2,1,2,2),
(3,2,3,1),
(4,3,2,1),
(5,2,1,3),
(6,99,3,1)`)
fmt.Println("✅ シード完了: customers=4行 / products=4行 / orders=6行(うちid=6は存在しない顧客id=99を参照)")
✅ シード完了: customers=4行 / products=4行 / orders=6行(うちid=6は存在しない顧客id=99を参照)
2. 罠1: ユニークでないキーで結合すると行が膨らむ¶
orders.product_id は products.id と結合するのが正解です。もし誤って
「商品名の一致」で結合してしまうと、名前が重複している商品("mouse" が2つ)に対して
1つの注文が2行にマッチしてしまいます。
%%
db := openCourseDB("_02_3_sql_aggregation_joins.db")
defer db.Close()
var correctByID, wrongByName int
if err := db.QueryRow(`SELECT COUNT(*) FROM orders o JOIN products p ON p.id = o.product_id`).Scan(&correctByID); err != nil {
panic(err)
}
if err := db.QueryRow(`SELECT COUNT(*) FROM orders o JOIN products p ON p.name = (SELECT name FROM products WHERE id = o.product_id)`).Scan(&wrongByName); err != nil {
panic(err)
}
fmt.Println("正しいJOIN(product_id = products.id)の行数:", correctByID)
fmt.Println("誤ったJOIN(商品名で結合)の行数:", wrongByName)
gonbui.DisplayHTML(renderTable(
[]string{"結合方法", "結合キー", "結果の行数"},
[][]string{
{"正しい", "product_id = products.id(ユニークキー)", fmt.Sprint(correctByID)},
{"誤り", "products.name('mouse' が2つあり重複)", fmt.Sprint(wrongByName)},
},
))
gonbui.Sync()
正しいJOIN(product_id = products.id)の行数: 6 誤ったJOIN(商品名で結合)の行数: 8
| 結合方法 | 結合キー | 結果の行数 |
|---|---|---|
| 正しい | product_id = products.id(ユニークキー) | 6 |
| 誤り | products.name('mouse' が2つあり重複) | 8 |
注文は6件しかないのに、名前で結合すると8行に膨らみました。 "mouse" を注文した2件(id=2, id=4)が、それぞれ id=2 と id=4 の両方の商品にマッチしてしまったからです。 ユニークでない列を JOIN のキーに使うと、エラーにならずに結果だけが不正確になるのが怖いところです。
3. 罠2: INNER JOIN は参照先が無いと行ごと消える¶
orders の id=6 は、存在しない顧客 id=99 を参照しています(データ不整合の例)。
INNER JOIN はマッチしない行を黙って除外します。LEFT JOIN なら残ります。
%%
db := openCourseDB("_02_3_sql_aggregation_joins.db")
defer db.Close()
var totalOrders, innerCount, leftCount int
db.QueryRow(`SELECT COUNT(*) FROM orders`).Scan(&totalOrders)
if err := db.QueryRow(`SELECT COUNT(*) FROM orders o JOIN customers c ON o.customer_id = c.id`).Scan(&innerCount); err != nil {
panic(err)
}
if err := db.QueryRow(`SELECT COUNT(*) FROM orders o LEFT JOIN customers c ON o.customer_id = c.id`).Scan(&leftCount); err != nil {
panic(err)
}
gonbui.DisplayHTML(renderTable(
[]string{"", "行数"},
[][]string{
{"orders テーブルの全行数", fmt.Sprint(totalOrders)},
{"INNER JOIN customers(存在しない顧客id=99の注文は消える)", fmt.Sprint(innerCount)},
{"LEFT JOIN customers(消えない。customer名はNULLになる)", fmt.Sprint(leftCount)},
},
))
gonbui.Sync()
| 行数 | |
|---|---|
| orders テーブルの全行数 | 6 |
| INNER JOIN customers(存在しない顧客id=99の注文は消える) | 5 |
| LEFT JOIN customers(消えない。customer名はNULLになる) | 6 |
orders は6件あるのに、INNER JOIN の結果は5件しかありません。id=6の注文が黙って消えました。
これがデータの不整合(存在しない顧客を参照する注文)に気づかないまま集計を行ってしまう典型的な事故です。
4. GROUP BY と HAVING で集計する¶
ここからは正しいJOIN(id同士の結合)だけを使います。顧客ごとの売上合計と注文件数を集計し、
HAVING で「売上5000円以上の顧客」だけに絞り込みます。
%%
db := openCourseDB("_02_3_sql_aggregation_joins.db")
defer db.Close()
rows, err := db.Query(`
SELECT c.id, c.name, SUM(p.price * o.quantity) AS total, COUNT(*) AS cnt
FROM orders o
JOIN products p ON p.id = o.product_id
JOIN customers c ON c.id = o.customer_id
GROUP BY c.id, c.name
HAVING SUM(p.price * o.quantity) >= 5000
ORDER BY total DESC
`)
if err != nil {
panic(err)
}
defer rows.Close()
var tableRows [][]string
for rows.Next() {
var id, total, cnt int
var name string
if err := rows.Scan(&id, &name, &total, &cnt); err != nil {
panic(err)
}
tableRows = append(tableRows, []string{fmt.Sprint(id), name, fmt.Sprintf("%d円", total), fmt.Sprint(cnt)})
}
gonbui.DisplayHTML(renderTable([]string{"顧客ID", "名前", "売上合計", "注文件数"}, tableRows))
gonbui.Sync()
| 顧客ID | 名前 | 売上合計 | 注文件数 |
|---|---|---|---|
| 2 | suzuki | 29000円 | 2 |
| 1 | tanaka | 6000円 | 2 |
sato(顧客id=3)は売上1500円のため HAVING SUM(...) >= 5000 の条件から外れ、表に出てきません。
WHERE ではなく HAVING を使う理由は、SUM(...) のような集計結果に対して条件を掛けたいからです
(WHERE は集計前の1行ずつにしか条件を掛けられません)。
5. 直感・類推: 名簿の名寄せ¶
JOIN は、2つの名簿を同じ人・同じ物を指す番号(キー)で突き合わせる作業です。 「名前」で突き合わせると、同姓同名の人("sato" が2人いたように)を取り違えます。 郵便物を「名前」だけで配ると誤配が起きるのと同じで、必ずユニークな番号(id)で突き合わせる必要があります。
また、INNER JOIN は「両方の名簿に載っている人だけ」を残す作業です。片方の名簿にしか無い人(存在しない 顧客id=99を参照する注文)は、静かに弾かれてしまいます。
練習問題 2.3: TopCustomersBySpend(売上上位の顧客)を実装しよう¶
orders × products × customers をid で正しく結合し、顧客ごとの売上合計を計算する関数
TopCustomersBySpend を実装してください。
仕様:
func TopCustomersBySpend(db *sql.DB, minTotal int) ([]CustomerSpend, error)
orders.product_id = products.idとorders.customer_id = customers.idのid結合を使う- 顧客ごとに
SUM(price * quantity)をTotal、注文件数をOrderCountとして集計する HAVINGでTotal >= minTotalの顧客だけに絞り込むTotalの降順で並べて返す
ヒント: 上の「4. GROUP BY と HAVING で集計する」のクエリがほぼそのまま使えます。
rows.Scan で受け取った値を CustomerSpend に詰めてスライスに append してください。
type CustomerSpend struct {
CustomerID int
Name string
Total int
OrderCount int
}
// YOUR CODE HERE
// TopCustomersBySpend を実装してください。
// (未実装のままチェックセルを実行すると「未回答」と表示されます)
func TopCustomersBySpend(db *sql.DB, minTotal int) ([]CustomerSpend, error) {
return nil, ErrUnanswered
}
チェックのためのヘルパー¶
答え合わせに使う小さなヘルパー mustEqual を定義します。
(GoNB はローカルパッケージを import できないため、各ノートブックにこの定義を置いています)
import "reflect"
func mustEqual(got, want any, name string) {
if reflect.DeepEqual(got, want) {
fmt.Printf("✅ Passed: %s\n", name)
return
}
panic(fmt.Sprintf("❌ %s\n got = %v (%T)\n want = %v (%T)", name, got, got, want, want))
}
%%
db := openCourseDB("_02_3_sql_aggregation_joins.db")
defer db.Close()
result, err := TopCustomersBySpend(db, 5000)
if errors.Is(err, ErrUnanswered) {
fmt.Println("⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください")
} else {
if err != nil {
panic(err)
}
mustEqual(len(result), 2, "5000円以上の顧客は2人")
mustEqual(result[0], CustomerSpend{CustomerID: 2, Name: "suzuki", Total: 29000, OrderCount: 2}, "1位はsuzuki(29000円)")
mustEqual(result[1], CustomerSpend{CustomerID: 1, Name: "tanaka", Total: 6000, OrderCount: 2}, "2位はtanaka(6000円)")
fmt.Println("🎉 すべてのチェックが通りました")
}
⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください
まとめ¶
JOINはユニークなidで行う。名前のような重複しうる列で結合すると行が膨らむINNER JOINは片方に無い行を消す。データ不整合に気づきたいならLEFT JOINで確認するHAVINGは集計結果(SUM/COUNT等)に対する絞り込みに使う(WHEREは集計前の行に対して使う)
答え合わせは 02.3-sql-aggregation-joins-solutions.ipynb で行ってください。
次は 02.4 で「正規化」を詳しく見ます。