リナが AI に頼んだ SQL で先月の売上が 2 倍になった話 - 行数で検算する習慣

リナが AI に頼んだ SQL で先月の売上が 2 倍になった話 - 行数で検算する習慣

この記事でできるようになること

  • AI が書いた SQL の結果を、行数と 1 行の中身で確かめられる
  • JOIN で行が増える原因を見つけ、条件を足すか先に集計する形で直せる
  • UPDATE の前に同じ WHERE で COUNT(*) を打ち、変わる行数を確かめられる

前提知識(先に読むと理解しやすい記事)

AI に SQL を書いてもらったら、エラーは出ないのに数が合わない。そんな経験はありませんか。この記事では、ライニー先輩とリナの会話で「返ってきた結果の行数と 1 行を、自分の目で確かめる習慣」を身につけます。

この記事でわかること

  • エラーが出ない SQL でも、答えが問いとずれることがある理由
  • JOIN で行が増えたときの見つけ方と、2 通りの直し方
  • UPDATE の前に「同じ条件で数える」習慣と、AI に頼むときの 3 手の型

はじめに

結論: エラーが出ないことと、答えが合っていることは別の話だ。先月の売上が 2 倍に見えたら、まず行数を数える。

リナ: ライニー先輩、大変です。AI に「会員ごとに、プランと先月の支払い額を一覧にして」と頼んだんです。返ってきた SQL の金額を足したら、70,000 円になりました。
ライニー先輩: レジの記録だと、先月の売上はいくらだったの?
リナ: 34,000 円です。約 2 倍なんです。エラーは 1 つも出なかったのに…。
ライニー先輩: エラーが出ないことと、答えが合っていることは別の話なんだ。今日は「数えて確かめる」方法を一緒に見ていこう。

1. 何が起きた? AI の SQL をそのまま実行した

結論: 3 つの表をつなぐ SQL が、会員ごとの 1 行ではなく 18 行を返していた。合計がふくらんだのはそのためだ。

この記事のコマンドは、Penguin Gym Linux の仮想ターミナル入力したコマンドを解釈してコンピュータに実行させる対話プログラム。では動きません。自分のパソコン(Ubuntu)のターミナルで試します。最初に、下の「練習用データ gym.db の作り方(最初に 1 回だけ)」を開いて準備してください。

ライニー先輩: まず言葉をそろえよう。データベースは、データを表の形でしまっておく入れ物だよ。
ライニー先輩: 表は「テーブル」とも呼ぶ。横の 1 件分を「行」、縦の項目を「列」と呼ぶんだ。
ライニー先輩: SQL は、データベースに「この条件で取り出して」と頼むための言葉だよ。sqlite3 は、SQL をターミナルから打てるコマンドなんだ。
ライニー先輩: sqlite3 gym.db "SQL" と打つと、gym.db というファイルに SQL を 1 回だけ実行できる。行の先頭の $ は入力する場所の目印だから、打たなくていいよ。

リナのジムの練習用データ gym.db には、3 つの表があります。

表の名前 入っているもの
members 会員の一覧(id / 名前 / 都市)。12 人
enrollments 入会したプランの記録。プランを変えると、前の行に終了日が入る
payments 先月の支払いの記録(会員の id / 金額)
練習用データ gym.db の作り方(最初に 1 回だけ)

まず、sqlite3 が使えるかを確かめます。

  1. ターミナルで sqlite3 --version と打ち、Enter を押す。3.45.1 のような番号が出れば準備はできている(番号は環境によって違う)
  2. command not found と出たら、sudo apt install sqlite3 と打つ。sudo は管理者の権限で実行するためのコマンドで、apt は Ubuntu にソフトを入れるコマンドだ
  3. パスワードを聞かれたら、自分のログインパスワードを打って Enter を押す。打った文字は画面に出ないが、それが正常だ。途中で [Y/n] と聞かれたら、y を打って Enter を押す
  4. もう一度 sqlite3 --version と打ち、番号が出ることを確かめる

次に、gym.db を作ります。

  1. 下のコードを、1 行目から最後の EOF の行まですべてコピーする
  2. ターミナルに Ctrl+Shift+V か、右クリックの「貼り付け」で貼り付ける。Ctrl+V では貼り付けられない
  3. 2 行目から先の行頭に > が出る。これは続きを待っている印で、エラーではない
  4. 最後の 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 が、前に作った表を消してから作り直すからです。データを壊したときも、この手順を流し直せば元どおりになります。

リナ: AI には「会員ごとに、プランと先月の支払い額を一覧にして」と頼みました。返ってきたのがこの SQL です。
$ 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
ライニー先輩: 出力は 1 行が 1 件で、| が列の区切りだよ。SELECT の後ろに取り出す列を、FROM の後ろに表の名前を書くんだ。
ライニー先輩: members m は、members 表を短く m と呼ぶ書き方だよ。だから m.name は「members の name 列」という意味になる。
ライニー先輩: JOIN は、2 つの表を「同じ会員 id の行どうし」でつなぐ書き方だよ。ON の後ろに、つなぐ条件を書くんだ。
リナ: この金額の列を表計算ソフトで足したら、70,000 円になりました。
ライニー先輩: 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
リナ: 表計算ソフトと同じ 70,000 円です。支払いの記録だけを足すと、どうなるんでしょう。
$ sqlite3 gym.db "SELECT SUM(amount) FROM payments;"
34000
リナ: 34,000 円。レジの記録と同じです。じゃあ、どこで 2 倍になったんですか?

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
リナ: 会員は 12 人なのに、結果は 18 行…。1 人 1 行なら、12 行より多くなるはずがないですね。
ライニー先輩: その通り。次は 1 人分だけ取り出して、自分の目で見てみよう。WHERE は「この条件に合う行だけ残す」書き方だよ。
ライニー先輩: Pip の会員 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|
リナ: Pip さんは、プランの記録が 3 行あります。student と basic には、終わった日が入っていますね。
ライニー先輩: pro の行は、最後が空だね。終わった日がまだ無い、つまり今のプランなんだ。
ライニー先輩: Pip は 2 回プランを変えたんだ。JOIN は、つなげる相手の行がある数だけ行を作る。だから 4,500 円の支払いが 3 行にコピーされたんだよ。
リナ: 1 人に記録が何行もあると、つないだときに行が増えるんですね。これが 2 倍の正体だったんだ。

このように「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 行です。エラーは出ないのに、結果がまったく違いますね。
ライニー先輩: そうなんだ。では、AI の SQL に 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
リナ: 10 行になりました。足すと 34,000 円です。でも、会員は 12 人ですよね?
ライニー先輩: Tama と Leo は、今どのプランにも入っていない。支払いもないから、つなぐ相手がいないんだ。12 人のうち 10 人なら、行数として筋が通っているよ。

直し方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
ライニー先輩: かっこの中で、会員 1 人につき 1 行の表を先に作っている。GROUP BY member_id は「会員ごとにまとめる」という意味だよ。1 人 1 行どうしをつなぐから、行は増えないんだ。
ライニー先輩: AS total は、足した結果の列に total という名前をつける書き方だよ。かっこの後ろの t は、かっこの中で作った表の呼び名なんだ。
リナ: こっちも 10 行で 34,000 円。…あれ、ということは、AI の SQL が間違っていたわけじゃないんですか?
ライニー先輩: AI は「プランと支払い」を、記録のある分だけ全部つないだんだ。リナの問いに「今のプラン」とは書いてなかったよね。
リナ: たしかに…。問いがあいまいだったんですね。次からは「今のプラン(ended_at が空の行)だけ」と書いて頼みます。

4. 変える前に数える:WHERE を忘れた UPDATE

結論: UPDATE を打つ前に、同じ WHERE で COUNT(*) を打つ。変わる行数が 1 行のはずなら、1 と出るのを確かめてから実行する。

リナ: Kai さんが札幌から大阪に引っ越したので、AI に「Kai の都市を Osaka に変える SQL」を頼みました。こんな SQL が返ってきました。
UPDATE members
SET city = 'Osaka'
WHERE id = 3;
ライニー先輩: UPDATE は、表の中身を書きかえる命令だよ。SET の後ろに新しい値を、WHERE の後ろに書きかえる行の条件を書くんだ。
ライニー先輩: 実行する前に、念のためコピーを取っておこう。この gym.db は練習用のデータだから、壊れても困らないけどね。
ライニー先輩: cp は cp コピー元 コピー先 の順に書く。コピー先に同じ名前のファイルがあると、確認なしで上書きされるから気をつけてね。
ライニー先輩: 実行するときは、後ろに SELECT changes(); を付けよう。changes() は、直前の命令で変わった行の数を返すんだ。
$ cp gym.db gym-backup.db
リナ: では、SQL を貼り付けて実行…。あっ、最後の 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
リナ: えっ、12 人全員が大阪になっています。エラーも出ていないのに…。
ライニー先輩: 変わった行は 12 行。WHERE が無い UPDATE は、表の全部の行を書きかえるんだ。今回はコピーがあるから、元に戻そう。
ライニー先輩: 戻すときは -i を付ける。上書きの前に overwrite? と確認してくれるから、y を打って Enter を押そう。確認の文言は、環境の言語設定によって日本語で出ることもあるよ。
$ cp -i gym-backup.db gym.db
cp: overwrite 'gym.db'? y
ライニー先輩: コピーを取り忘れたときは、「練習用データ gym.db の作り方」の手順を流し直せばいい。最初の状態に戻るよ。
ライニー先輩: 本番のやり方はこうだよ。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
リナ: 1 行で、中身も Kai さんです。ここまで確かめてから実行しますね。
$ sqlite3 gym.db "UPDATE members SET city = 'Osaka' WHERE id = 3; SELECT changes();"
1
リナ: 変わったのは 1 行だけ。今回の AI の SQL は合っていました。
ライニー先輩: そう、合っていると確かめられたのも大事な結果だよ。疑うためではなく、安心して実行するために数えるんだ。

5. AI に頼むときの 3 手の型

結論: 頼む前に行数を予想し、COUNT(*) で数え、1 行を目で見る。この 3 手で、多すぎる結果も少なすぎる結果も見つけられる。

リナ: AI に頼むのは、やめたほうがいいんでしょうか。
ライニー先輩: やめなくていいよ。返ってきたものの「行数」と「1 行」を、自分の目で見る習慣を足すだけだ。3 手にまとめておこう。
手順 やること 今日の例
1 何行になるはずかを先に言葉にする 会員ごとなら 12 行以下。Kai だけなら 1 行
2 COUNT(*) で実際の行数を数える 18 行なら多すぎる。UPDATE の前は同じ WHERE で
3 1 行を取り出して中身を見る Pip の行を見ると、プランの記録が 3 行あった
リナ: さっそく試します。名簿だと、東京の会員は 4 人のはずです。AI に「東京の会員数」を頼みました。
$ 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
リナ: 予想どおり 4 人で、名前も名簿と同じです。これは合っていますね。
ライニー先輩: うん。逆に、数が少なすぎる場合もある。たとえば 1 人だけ全角の 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 行を見て合っているかを決める。合っていれば、安心して使える。

リナ: 売上が 2 倍になったのは、1 人に何行もある記録をつないだからでした。問いがあいまいだと、AI は別の読み方で SQL を書くんですね。問いを具体的にしても、最後は行数と 1 行で確かめます。
ライニー先輩: そうだね。そして WHERE を忘れた UPDATE は、エラーを出さずに全部の行を書きかえる。だから変える前に数えるんだ。
リナ: 予想して、数えて、1 行を見る。これなら AI に頼みながらでも、自分で確かめられます。
ライニー先輩: その 3 手があれば、AI の答えが合っているときも自信を持って使える。それがいちばん大事なところだよ。

今日の3行まとめ

結論: 行がふくらむ JOIN、WHERE を忘れた UPDATE、表記の違い。どれも行数を数えれば気付ける。

  1. 会員数より行が多ければ疑う - 1 対多の表を JOIN すると行が増え、合計もふくらむ
  2. 変える前に数える - UPDATE の前に、同じ WHERE で COUNT(*) を打つ
  3. 予想・数える・1 行を見る - AI の SQL は、この 3 手で合っているかを確かめる

次に読む

結論: 行数を数える考え方は、Linux の wc コマンドとも同じだ。ターミナルでも数えて確かめる練習をしよう。

この記事を共有

次の一手