リナが AI に頼んだ SQL で先月の売上が 2 倍になった話 - 行数で検算する習慣
この記事でできるようになること
- AI が書いた SQL の結果を、行数と 1 行の中身で確かめられる
- JOIN で行が増える原因を見つけ、条件を足すか先に集計する形で直せる
- UPDATE の前に同じ WHERE で COUNT(*) を打ち、変わる行数を確かめられる
前提知識(先に読むと理解しやすい記事)
AI に SQL を書いてもらったら、エラーは出ないのに数が合わない。そんな経験はありませんか。この記事では、ライニー先輩とリナの会話で「返ってきた結果の行数と 1 行を、自分の目で確かめる習慣」を身につけます。
この記事でわかること
- エラーが出ない SQL でも、答えが問いとずれることがある理由
- JOIN で行が増えたときの見つけ方と、2 通りの直し方
- UPDATE の前に「同じ条件で数える」習慣と、AI に頼むときの 3 手の型
はじめに
結論: エラーが出ないことと、答えが合っていることは別の話だ。先月の売上が 2 倍に見えたら、まず行数を数える。
1. 何が起きた? AI の SQL をそのまま実行した
結論: 3 つの表をつなぐ SQL が、会員ごとの 1 行ではなく 18 行を返していた。合計がふくらんだのはそのためだ。
この記事のコマンドは、Penguin Gym Linux の仮想ターミナル入力したコマンドを解釈してコンピュータに実行させる対話プログラム。では動きません。自分のパソコン(Ubuntu)のターミナルで試します。最初に、下の「練習用データ gym.db の作り方(最初に 1 回だけ)」を開いて準備してください。
sqlite3 は、SQL をターミナルから打てるコマンドなんだ。sqlite3 gym.db "SQL" と打つと、gym.db というファイルに SQL を 1 回だけ実行できる。行の先頭の $ は入力する場所の目印だから、打たなくていいよ。リナのジムの練習用データ gym.db には、3 つの表があります。
| 表の名前 | 入っているもの |
|---|---|
members |
会員の一覧(id / 名前 / 都市)。12 人 |
enrollments |
入会したプランの記録。プランを変えると、前の行に終了日が入る |
payments |
先月の支払いの記録(会員の id / 金額) |
練習用データ gym.db の作り方(最初に 1 回だけ)
まず、sqlite3 が使えるかを確かめます。
- ターミナルで
sqlite3 --versionと打ち、Enter を押す。3.45.1のような番号が出れば準備はできている(番号は環境によって違う) command not foundと出たら、sudo apt install sqlite3と打つ。sudoは管理者の権限で実行するためのコマンドで、aptは Ubuntu にソフトを入れるコマンドだ- パスワードを聞かれたら、自分のログインパスワードを打って Enter を押す。打った文字は画面に出ないが、それが正常だ。途中で
[Y/n]と聞かれたら、yを打って Enter を押す - もう一度
sqlite3 --versionと打ち、番号が出ることを確かめる
次に、gym.db を作ります。
- 下のコードを、1 行目から最後の
EOFの行まですべてコピーする - ターミナルに
Ctrl+Shift+Vか、右クリックの「貼り付け」で貼り付ける。Ctrl+Vでは貼り付けられない - 2 行目から先の行頭に
>が出る。これは続きを待っている印で、エラーではない - 最後の
EOFまで出たら Enter を押す。$の行に戻れば成功だ
今いるディレクトリファイルをまとめて整理する入れ物。Windows や macOS の「フォルダ」と同じもの。(フォルダのこと)に gym.db ができます。<<'EOF' から EOF までの行は、まとめて sqlite3 に渡されます。
sqlite3 gym.db <<'EOF' DROP TABLE IF EXISTS members; DROP TABLE IF EXISTS enrollments; DROP TABLE IF EXISTS payments; CREATE TABLE members (id INTEGER PRIMARY KEY, name TEXT, city TEXT); INSERT INTO members VALUES (1,'Pip','Tokyo'), (2,'Momo','Tokyo'), (3,'Kai','Sapporo'), (4,'Luna','Tokyo'), (5,'Nico','Tokyo'), (6,'Rio','Osaka'), (7,'Sora','Osaka'), (8,'Tama','Osaka'), (9,'Ana','Lisboa'), (10,'Ken','Porto'), (11,'Yuki','Lisboa'), (12,'Leo','Austin'); CREATE TABLE enrollments (member_id INTEGER, plan TEXT, ended_at TEXT); INSERT INTO enrollments VALUES (1,'student','2025-03-31'), (1,'basic','2026-03-31'), (1,'pro',NULL), (2,'basic',NULL), (3,'basic','2025-08-31'), (3,'student','2026-01-31'), (3,'pro',NULL), (4,'student',NULL), (5,'pro','2025-06-30'), (5,'basic','2026-02-28'), (5,'pro',NULL), (6,'basic',NULL), (7,'basic','2025-09-30'), (7,'pro','2026-04-30'), (7,'pro',NULL), (9,'basic',NULL), (10,'student',NULL), (11,'basic',NULL); CREATE TABLE payments (member_id INTEGER, amount INTEGER); INSERT INTO payments VALUES (1,4500), (2,3000), (3,4500), (4,2000), (5,4500), (6,3000), (7,4500), (9,3000), (10,2000), (11,3000); EOF
この手順は、何回流しても同じ最初の状態に戻ります。最初の 3 行の DROP TABLE IF EXISTS が、前に作った表を消してから作り直すからです。データを壊したときも、この手順を流し直せば元どおりになります。
$ sqlite3 gym.db "SELECT m.name, e.plan, p.amount FROM members m JOIN enrollments e ON e.member_id = m.id JOIN payments p ON p.member_id = m.id;"
Pip|student|4500 Pip|basic|4500 Pip|pro|4500 Momo|basic|3000 Kai|basic|4500 Kai|student|4500 Kai|pro|4500 Luna|student|2000 Nico|pro|4500 Nico|basic|4500 Nico|pro|4500 Rio|basic|3000 Sora|basic|4500 Sora|pro|4500 Sora|pro|4500 Ana|basic|3000 Ken|student|2000 Yuki|basic|3000
| が列の区切りだよ。SELECT の後ろに取り出す列を、FROM の後ろに表の名前を書くんだ。members m は、members 表を短く m と呼ぶ書き方だよ。だから m.name は「members の name 列」という意味になる。JOIN は、2 つの表を「同じ会員 id の行どうし」でつなぐ書き方だよ。ON の後ろに、つなぐ条件を書くんだ。SUM(p.amount) は、amount 列の数を全部足す書き方だよ。AI の SQL で足してみよう。$ sqlite3 gym.db "SELECT SUM(p.amount) FROM members m JOIN enrollments e ON e.member_id = m.id JOIN payments p ON p.member_id = m.id;"
70000
$ sqlite3 gym.db "SELECT SUM(amount) FROM payments;"
34000
2. まず数える:会員は 12 人、結果は 18 行
結論: 会員ごとの一覧なら、行数は会員数より多くならないはずだ。12 人に 18 行なら、行がふくらんでいる。
COUNT(*) は「行が何行あるか」を数える書き方だよ。$ sqlite3 gym.db "SELECT COUNT(*) FROM members;"
12
$ sqlite3 gym.db "SELECT COUNT(*) FROM members m JOIN enrollments e ON e.member_id = m.id JOIN payments p ON p.member_id = m.id;"
18
WHERE は「この条件に合う行だけ残す」書き方だよ。id は 1 だ。SELECT * の * は「すべての列」という意味だよ。$ sqlite3 gym.db "SELECT * FROM enrollments WHERE member_id = 1;"
1|student|2025-03-31 1|basic|2026-03-31 1|pro|
JOIN は、つなげる相手の行がある数だけ行を作る。だから 4,500 円の支払いが 3 行にコピーされたんだよ。このように「1 人に対して記録が何行もある」関係を、1 対多と呼びます。1 対多の表を JOIN すると、多い側の行の数だけ結果の行が増えます。
3. 直し方は 2 つ:今の行だけにする / 先に集計する
結論: 今のプランの行だけに絞るか、先に会員ごとに足してからつなぐ。どちらも 10 行・34,000 円にそろう。
直し方1:今のプランの行だけにする
ended_at に日付が入っている。今のプランの行は空っぽだよ。この空っぽの状態を、SQL では NULL と呼ぶんだ。sqlite3 は NULL を何も表示しない。さっきの 1|pro| の最後が空なのが NULL だよ。= NULL ではなく、IS NULL と書く。2 つを打ち比べてみよう。$ sqlite3 gym.db "SELECT COUNT(*) FROM enrollments WHERE ended_at = NULL;"
0
$ sqlite3 gym.db "SELECT COUNT(*) FROM enrollments WHERE ended_at IS NULL;"
10
= NULL だと 0 行で、IS NULL だと 10 行です。エラーは出ないのに、結果がまったく違いますね。WHERE e.ended_at IS NULL を足してみよう。$ sqlite3 gym.db "SELECT m.name, e.plan, p.amount FROM members m JOIN enrollments e ON e.member_id = m.id JOIN payments p ON p.member_id = m.id WHERE e.ended_at IS NULL;"
Pip|pro|4500 Momo|basic|3000 Kai|pro|4500 Luna|student|2000 Nico|pro|4500 Rio|basic|3000 Sora|pro|4500 Ana|basic|3000 Ken|student|2000 Yuki|basic|3000
直し方2:先に集計してからつなぐ
payments だけで会員ごとに足し算して、それから名前をつなぐんだ。足し算や数え上げでまとめることを「集計」と呼ぶよ。$ sqlite3 gym.db "SELECT m.name, t.total
FROM members m
JOIN (SELECT member_id, SUM(amount) AS total
FROM payments GROUP BY member_id) t
ON t.member_id = m.id;"Pip|4500 Momo|3000 Kai|4500 Luna|2000 Nico|4500 Rio|3000 Sora|4500 Ana|3000 Ken|2000 Yuki|3000
GROUP BY member_id は「会員ごとにまとめる」という意味だよ。1 人 1 行どうしをつなぐから、行は増えないんだ。AS total は、足した結果の列に total という名前をつける書き方だよ。かっこの後ろの t は、かっこの中で作った表の呼び名なんだ。ended_at が空の行)だけ」と書いて頼みます。4. 変える前に数える:WHERE を忘れた UPDATE
結論: UPDATE を打つ前に、同じ WHERE で COUNT(*) を打つ。変わる行数が 1 行のはずなら、1 と出るのを確かめてから実行する。
UPDATE members
SET city = 'Osaka'
WHERE id = 3;
UPDATE は、表の中身を書きかえる命令だよ。SET の後ろに新しい値を、WHERE の後ろに書きかえる行の条件を書くんだ。UPDATE で書きかえたデータは、ゴミ箱に行きません。パソコンの「元に戻す」のように取り消すこともできません。実行した時点で保存されます。試す前に cp(ファイルをコピーするコマンド)でコピーを取っておくと安全です。gym.db は練習用に作ったファイルなので、壊れてもパソコンのほかのファイルには影響しません。WHERE 無しの UPDATE を試すのは、この練習用の gym.db だけにしてください。仕事のデータベースや他人のファイルでは試しません。
gym.db は練習用のデータだから、壊れても困らないけどね。cp は cp コピー元 コピー先 の順に書く。コピー先に同じ名前のファイルがあると、確認なしで上書きされるから気をつけてね。SELECT changes(); を付けよう。changes() は、直前の命令で変わった行の数を返すんだ。$ cp gym.db gym-backup.db
WHERE id = 3; の行が抜けたまま実行しちゃいました。$ sqlite3 gym.db "UPDATE members SET city = 'Osaka'; SELECT changes();"
12
$ sqlite3 gym.db "SELECT city, COUNT(*) FROM members GROUP BY city;"
Osaka|12
WHERE が無い UPDATE は、表の全部の行を書きかえるんだ。今回はコピーがあるから、元に戻そう。-i を付ける。上書きの前に overwrite? と確認してくれるから、y を打って Enter を押そう。確認の文言は、環境の言語設定によって日本語で出ることもあるよ。$ cp -i gym-backup.db gym.db
cp: overwrite 'gym.db'? y
UPDATE を打つ前に、同じ WHERE で数える。Kai 1 人を変えたいなら、答えは 1 のはずだね。$ sqlite3 gym.db "SELECT COUNT(*) FROM members WHERE id = 3;"
1
$ sqlite3 gym.db "SELECT * FROM members WHERE id = 3;"
3|Kai|Sapporo
$ sqlite3 gym.db "UPDATE members SET city = 'Osaka' WHERE id = 3; SELECT changes();"
1
5. AI に頼むときの 3 手の型
結論: 頼む前に行数を予想し、COUNT(*) で数え、1 行を目で見る。この 3 手で、多すぎる結果も少なすぎる結果も見つけられる。
| 手順 | やること | 今日の例 |
|---|---|---|
| 1 | 何行になるはずかを先に言葉にする | 会員ごとなら 12 行以下。Kai だけなら 1 行 |
| 2 | COUNT(*) で実際の行数を数える |
18 行なら多すぎる。UPDATE の前は同じ WHERE で |
| 3 | 1 行を取り出して中身を見る | Pip の行を見ると、プランの記録が 3 行あった |
$ sqlite3 gym.db "SELECT COUNT(*) FROM members WHERE city = 'Tokyo';"
4
$ sqlite3 gym.db "SELECT name FROM members WHERE city = 'Tokyo';"
Pip Momo Luna Nico
Tokyo で登録されていたら、city = 'Tokyo' では選ばれないんだ。$ sqlite3 gym.db "SELECT 'Tokyo' = 'Tokyo', 'Tokyo' = 'Tokyo';"
0|1
0 は「違う」、1 は「同じ」という意味だよ。見た目が似ていても、全角と半角は別の文字として比べられる。だから 4 人のはずが 3 人になっても、エラーは出ないんだ。よくあるつまずきを表にまとめます。
| つまずき | 起きること | 確かめ方 |
|---|---|---|
1 対多の表を JOIN する |
行が増え、合計がふくらむ | 結果の行数を会員数と比べる |
空っぽの行を = NULL で探す |
1 行も選ばれない | IS NULL と書く |
UPDATE の WHERE を忘れる |
全部の行が書きかわる | 先に同じ WHERE で COUNT(*) を打つ |
| 全角と半角が混ざっている | 選ばれるはずの行が抜け落ちる | 予想した数と比べ、1 行ずつ見る |
no such table: members と出る |
gym.db を作ったディレクトリと違う場所で打っている。sqlite3 は、無いファイルを空のまま黙って新しく作る |
pwd(今いる場所を表示するコマンド)で確かめ、cd(ディレクトリを移動するコマンド)で戻る |
ミニ課題
結論: 練習用の gym.db で 3 手の型を試そう。数えて、1 行を見て、合っているかを自分で決める。
gym.db で、3 手の型を試してみよう。ここまで使ってきた gym.db のままで試せるよ。課題1:今 pro プランの会員は何人?
やること:AI に「今 pro プランに入っている会員の数」を頼むと、SELECT COUNT(*) FROM enrollments WHERE plan = 'pro'; が返ってきました。結果は 6 です。合っているかを確かめましょう。
ヒント1(方向づけ)を見る
pro の行を 1 行ずつ見てみよう。終わったプランの行が混ざっていないかな。
ヒント2(コマンド名)を見る
sqlite3 で SELECT member_id, plan, ended_at FROM enrollments WHERE plan = 'pro'; を打ち、ended_at の列を見る。今のプランだけに絞るには、AND ended_at IS NULL を足す。AND は「両方の条件に合う行だけ」という意味。
答えを見る
$ sqlite3 gym.db "SELECT member_id, plan, ended_at FROM enrollments WHERE plan = 'pro';"
1|pro| 3|pro| 5|pro|2025-06-30 5|pro| 7|pro|2026-04-30 7|pro|
終わった pro の行が 2 行混ざっています。今の pro だけを数えると 4 人です。
$ sqlite3 gym.db "SELECT COUNT(*) FROM enrollments WHERE plan = 'pro' AND ended_at IS NULL;"
4
課題2:変える前に数えよう
やること:Luna さんが大阪に引っ越しました。都市を Osaka に変える前に、変わる行が 1 行だけかを確かめてから実行しましょう。
ヒント1(方向づけ)を見る
書きかえる前に、同じ条件で「何行あるか」と「その行の中身」を見よう。
ヒント2(コマンド名)を見る
SELECT COUNT(*) FROM members WHERE name = 'Luna'; で数え、SELECT * で中身を見る。最後に UPDATE と SELECT changes(); を打つ。
答えを見る
念のため、先にコピーを取っておきます。
$ cp gym.db gym-backup.db
$ sqlite3 gym.db "SELECT COUNT(*) FROM members WHERE name = 'Luna';"
1
$ sqlite3 gym.db "SELECT * FROM members WHERE name = 'Luna';"
4|Luna|Tokyo
$ sqlite3 gym.db "UPDATE members SET city = 'Osaka' WHERE name = 'Luna'; SELECT changes();"
1
振り返り
結論: AI の SQL を疑い続けるのではなく、行数と 1 行を見て合っているかを決める。合っていれば、安心して使える。
WHERE を忘れた UPDATE は、エラーを出さずに全部の行を書きかえる。だから変える前に数えるんだ。今日の3行まとめ
結論: 行がふくらむ JOIN、WHERE を忘れた UPDATE、表記の違い。どれも行数を数えれば気付ける。
- 会員数より行が多ければ疑う - 1 対多の表を
JOINすると行が増え、合計もふくらむ - 変える前に数える -
UPDATEの前に、同じWHEREでCOUNT(*)を打つ - 予想・数える・1 行を見る - AI の SQL は、この 3 手で合っているかを確かめる