← レッスン一覧に戻る

02.3 集計とJOIN — データを結合して意味を取り出す¶

前のレッスンでは、1つのテーブルに対する CRUD(作成・読み取り・更新・削除)を学びました。 実際のアプリケーションでは、データは複数のテーブルに分かれています。 「顧客」「商品」「注文」がそれぞれ別のテーブルにあるとき、JOIN で結合して初めて 「誰が何をいくつ買って、いくら使ったか」という意味のある情報になります。

このレッスンのゴール:

  • GROUP BY / HAVING で集計できる
  • 複数テーブルを JOIN で結合できる
  • JOIN のキーを間違えると、結果の行数が膨らんだり消えたりすることを実データで確認できる

非自明ポイント: JOIN は「間違えても動いてしまう」¶

JOIN はキーを間違えても SQL エラーにはなりません。黙って違う結果を返すだけです。

  • キーがユニークでない列で結合すると、行が膨らみます(1つの行が複数の行にマッチしてしまう)
  • INNER JOIN で参照先が存在しないと、その行は結果から消えます(LEFT JOIN なら残ります)

このレッスンでは、この2つの「静かな失敗」を実際のデータで再現し、正しい結果と数字で比較します。

In [1]:
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 を持ち出さない設計にしています)。

In [2]:
%%
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行にマッチしてしまいます。

In [3]:
%%
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 なら残ります。

In [4]:
%%
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円以上の顧客」だけに絞り込みます。

In [5]:
%%
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名前売上合計注文件数
2suzuki29000円2
1tanaka6000円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 してください。

In [6]:
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 できないため、各ノートブックにこの定義を置いています)

In [7]:
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))
}
In [8]:
%%
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 で「正規化」を詳しく見ます。