در این فصل چه یاد میگیری#
یک پرسشِ کاملاً معمولی: شعبهٔ کوشا چند صندلی دارد؟
جدولی که جلوی ماست دو جواب میدهد. ۱۲۷ سطر میگویند ۶۰ و ۹۸ سطر میگویند ۹۶. هیچکدام خطا نمیدهند، هیچکدام هشدار نمیدهند، و اگر پرسشت اتفاقاً سراغِ یکی از آن دو دسته برود، عددی میگیری که کاملاً قابلِ باور است و کاملاً هم ممکن است غلط باشد.
دلیلش یک تصمیمِ ساده است که همه یک بار در زندگیشان میگیرند: همهچیز را در یک جدولِ پهن ریختن. این فصل نشان میدهد آن تصمیم چطور به تناقض میرسد، و ابزاری را میآورد که تمامِ این ترم رویش سوار است: JOIN.

آخر این فصل میتوانی:
- بگویی چرا نگه داشتنِ یک واقعیت در هزار سطر دیر یا زود به دو جوابِ متناقض میرسد
- دو جدول را با
INNER JOIN ... ONکنارِ هم بیاوری - با نامِ مستعارِ جدول، ابهامِ نامِ ستون را حل کنی و پیامِ خطایش را بخوانی
- ثابت کنی که یک
JOINمشخص، تعدادِ سطرها را عوض نکرده
قبل از شروع#
از ترمِ ۱ و ۲: SELECT و WHERE و GROUP BY، COUNT(*) و COUNT(DISTINCT ...) و SUM، DISTINCT، و اینکه هر مقدار با پارامترِ ? به پرسش میرود.
هیچ آشناییِ قبلی با JOIN فرض نشده. تا امروز هر پرسشی که نوشتی یک FROM داشت با یک نامِ جدول. از این فصل به بعد بیشتر از یکی.
دانهبندیِ جدولهایی که با آنها کار میکنیم: یک سطر از books یعنی یک عنوانِ ثبتشده در یکی از شعبهها. یک سطر از branches یعنی یک شعبه. یک سطر از loans یعنی یک بار امانت رفتنِ یک کتاب.
📓 نوتبوک: نوتبوک این فصل را در Colab باز کن — همهٔ کدهای این فصل آماده و بهترتیب داخلش هست.
| جدول | تعدادِ سطر | زمانِ ساخت |
|---|---|---|
books |
۱٬۲۰۰ | کمتر از یک ثانیه |
branches |
۷ | آنی |
members |
۳۰۰ | آنی |
loans |
۳٬۹۹۸ | کمتر از یک ثانیه |
sections |
۱۲ | آنی |
books_wide (نسخهٔ پهن و معیوب) |
۱٬۲۰۰ | آنی |
این همان کتابخانهٔ ترمِ ۱ است، این بار در چند جدول و با یک برشِ تازه از داده — پس عددهای کلی با ترمِ ۱ یکی نیستند و هیچ ادعایی در این ترم به عددهای آنجا گره نمیخورد. داده ساختگی است و با یک SEED ثابت ساخته میشود، پس روی هر ماشینی همان عددها را میگیری.
۱. یک جدولِ پهن، و دو جوابِ متناقض#
books_wide همان چیزی است که آدم اولین بار میسازد: یک جدول که همهچیز در آن است — مشخصاتِ کتاب، و کنارش مشخصاتِ شعبهای که کتاب در آن است.
print(q("SELECT id, title, branch_name, branch_seats FROM books_wide LIMIT 3"))
id title branch_name branch_seats
0 1 روزهای پل نسیم 120
1 2 خانه پشت باران پویا 45
2 3 شب و مه دانش 90
خواندنش راحت است و برای یک نگاهِ سریع هم بد نیست. مشکل جای دیگری است: branch_seats یک واقعیت دربارهٔ شعبه است، نه دربارهٔ کتاب — و اینجا بهازای هر کتاب یک بار نوشته شده.
شعبهٔ کوشا سالِ گذشته سالنِ تازهای اضافه کرد و صندلیهایش از ۶۰ به ۹۶ رسید. کسی که این جدول را نگه میداشت، سطرهایی را که آن روز جلوی چشمش بود بهروز کرد. حالا ببین چه چیزی در جدول مانده:
print(q("""
SELECT branch_name, branch_seats, COUNT(*) AS rows_saying_so
FROM books_wide
WHERE branch_name = ?
GROUP BY branch_name, branch_seats
""", ("کوشا",)))
branch_name branch_seats rows_saying_so
0 کوشا 60 127
1 کوشا 96 98
یک شعبه، دو عددِ صندلی، و جدول هیچ نظری ندارد کدام درست است.
اسمِ این وضعیت ناهنجاریِ بهروزرسانی است: وقتی یک واقعیت در ۲۲۵ سطر تکرار شده، بهروز کردنش یعنی ۲۲۵ نوشتنِ درست پشتِ سرِ هم. کافی است یکی از قلم بیفتد تا جدول دربارهٔ خودش دروغ بگوید.
و همان مکانیزم، یک عددِ دیگر را هم خراب کرده. بشمار چند شهرِ متمایز در این جدول هست:
print("شهرهای متمایز در books_wide:",
int(q("SELECT COUNT(DISTINCT branch_city) AS n FROM books_wide").iloc[0, 0]))
print("شهرهای متمایز در branches :",
int(q("SELECT COUNT(DISTINCT city) AS n FROM branches").iloc[0, 0]))
print()
print(q("SELECT branch_city, COUNT(*) AS n FROM books_wide GROUP BY branch_city ORDER BY n DESC"))
شهرهای متمایز در books_wide: 9
شهرهای متمایز در branches : 7
branch_city n
0 اصفهان 225
1 مشهد 208
2 کرمانشاه 199
3 تهران 197
4 شیراز 187
5 تبریز 168
6 تبريز 6
7 كرمانشاه 5
8 شيراز 5
نُه شهر، در حالی که این کتابخانه شش شهر دارد که کتاب در آنها هست.
سه سطرِ آخر همان نامهایند با نویسهٔ عربی — همان تلهٔ فصلِ ۴ ترمِ ۱، این بار در نامِ شهر. شانزده سطر از ۱٬۲۰۰. و دقیقاً به همین دلیل که نامِ شهر ۱٬۲۰۰ بار تایپ شده، ۱٬۲۰۰ فرصت برای غلط تایپ کردنش هم وجود داشته.
✅ چک کن: جمعِ ستونِ
nباید دقیقاً ۱۲۰۰ شود. اگر نشد، پرسش را عوض کردهای؛ اگر شد، مطمئنی که هیچ سطری از قلم نیفتاده و آن نُه دسته واقعاً کلِ جدولاند.
۲. درمان: هر واقعیت فقط یک جا#
راهِ درست این است که واقعیتهای شعبه در جدولِ شعبهها بنشینند و در جدولِ کتابها فقط یک اشاره به آنها بماند.
print(q("SELECT id, name, city, seats FROM branches"))
print()
print(q("SELECT id, title, branch_id, pages FROM books LIMIT 3"))
id name city seats
0 1 مرکزی تهران 240
1 2 نسیم مشهد 120
2 3 دانش شیراز 90
3 4 بهار تبریز 75
4 5 کوشا اصفهان 96
5 6 پویا کرمانشاه 45
6 7 آفتاب یزد 30
id title branch_id pages
0 1 روزهای پل 2 311
1 2 خانه پشت باران 6 206
2 3 شب و مه 3 762
حالا نامِ هر شهر یک بار نوشته شده، نه ۱٬۲۰۰ بار. اگر کوشا فردا صندلی اضافه کند، یک سطر عوض میشود و کارِ تمام است. غلطِ تایپی هم اگر پیش بیاید، در یک سطر است و همانجا دیده میشود.
دو نامِ فنی که از همینجا شروع میشوند و ترمِ ۴ کاملشان میکند — فعلاً همینقدر بدان:
- کلیدِ اصلی: ستونی که هر سطر را یکتا میکند. در
branchesاینidاست. - کلیدِ خارجی: ستونی که در جدولِ دیگری به آن کلیدِ اصلی اشاره میکند. در
booksاینbranch_idاست.
branch_id عددِ بیمعنایی نیست؛ یک ارجاع است. و کاری که JOIN میکند دقیقاً دنبال کردنِ همین ارجاع است.
۳. کوچکترین JOIN ممکن#
قبل از ۱٬۲۰۰ سطر، روی چیزی که با انگشت میشود شمرد. سه کتاب، سه شعبه:
con.execute("DROP TABLE IF EXISTS tiny_books")
con.execute("DROP TABLE IF EXISTS tiny_branches")
con.execute("CREATE TABLE tiny_branches (branch_id INTEGER PRIMARY KEY, city TEXT)")
con.execute("CREATE TABLE tiny_books (id INTEGER PRIMARY KEY, title TEXT, branch_id INTEGER)")
con.executemany("INSERT INTO tiny_branches VALUES (?, ?)",
[(1, "تهران"), (2, "مشهد"), (3, "شیراز")])
con.executemany("INSERT INTO tiny_books VALUES (?, ?, ?)",
[(1, "الف", 1), (2, "ب", 1), (3, "پ", 2)])
con.commit()
print(q("SELECT * FROM tiny_books"))
print()
print(q("SELECT * FROM tiny_branches"))
id title branch_id
0 1 الف 1
1 2 ب 1
2 3 پ 2
branch_id city
0 1 تهران
1 2 مشهد
2 3 شیراز
جوابِ «هر کتاب در چه شهری است؟» را با چشم میشود داد: الف و ب در تهران، پ در مشهد. حالا همان را از پایگاه داده بخواه:
print(q("""
SELECT tiny_books.title, tiny_branches.city
FROM tiny_books
JOIN tiny_branches ON tiny_branches.branch_id = tiny_books.branch_id
"""))
title city
0 الف تهران
1 ب تهران
2 پ مشهد
سه سطر رفت، سه سطر برگشت — و همان سهتایی که با انگشت شمردی.
بندِ ON شرطِ جفت شدن است: هر سطر از جدولِ چپ با هر سطر از جدولِ راست جفت میشود که این شرط برایشان درست باشد. JOIN بدونِ کلمهٔ دیگری یعنی INNER JOIN؛ «درونی» یعنی فقط جفتهای کامل میمانند.
و یک چیز همینجا از قلم افتاد: شیراز در خروجی نیست، چون هیچ کتابی به آن اشاره نکرده. INNER JOIN بیصدا انداختش بیرون. فصلِ بعد تمامش دربارهٔ همین سطرِ گمشده است.
وقتی ستونِ کلید در هر دو جدول دقیقاً یک نام داشته باشد، USING کوتاهترش میکند:
print(q("""
SELECT title, city
FROM tiny_books
JOIN tiny_branches USING (branch_id)
"""))
title city
0 الف تهران
1 ب تهران
2 پ مشهد
همان نتیجه، با نوشتنِ کمتر. ولی USING فقط وقتی کار میکند که دو ستون همنام باشند، و جدولهای واقعیِ ما نیستند: در books نامش branch_id است و در branches نامش id. برای همین ON ابزارِ عمومی است و USING یک میانبُر.
۴. همان کار روی ۱٬۲۰۰ سطر#
print(q("""
SELECT b.title, br.name AS branch, br.city
FROM books AS b
JOIN branches AS br ON br.id = b.branch_id
ORDER BY b.id
LIMIT 3
"""))
print()
print("سطرهای books :",
int(q("SELECT COUNT(*) AS n FROM books").iloc[0, 0]))
print("سطرهای نتیجهٔ JOIN :",
int(q("""
SELECT COUNT(*) AS n
FROM books AS b
JOIN branches AS br ON br.id = b.branch_id
""").iloc[0, 0]))
title branch city
0 روزهای پل نسیم مشهد
1 خانه پشت باران پویا کرمانشاه
2 شب و مه دانش شیراز
سطرهای books : 1200
سطرهای نتیجهٔ JOIN : 1200
۱۲۰۰ سطر رفت، ۱۲۰۰ سطر برگشت. یک ستون اضافه شد و تعدادِ سطرها دستنخورده ماند.
این جمله را جدی بگیر، چون ادعای این فصل است، نه قاعدهٔ عمومیِ JOIN. اینجا درست است چون هر کتاب دقیقاً به یک شعبه اشاره میکند: از سمتِ books رابطه «یکبهیک» است. فصلِ ۳ همین JOIN را به جدولی میزند که یک کتاب چند سطر در آن دارد، و آنجا تعدادِ سطرها عوض میشود — بیصدا.
نامِ مستعارِ جدول (AS b و AS br) از این فصل به بعد در هر پرسشی میبینی. کارش فقط کوتاه کردن نیست؛ در پرسشی که یک جدول دو بار در آن آمده — فصلِ ۴ — تنها راهِ حرف زدن دربارهٔ هرکدام است.
۵. ابهامِ نامِ ستون#
هر دو جدول ستونی به نامِ id دارند. اگر بدونِ پیشوند صدایش کنی، SQLite نمیداند کدام را میخواهی — و برخلافِ خیلی از خطاهای این دوره، این یکی صریح اعتراض میکند:
🔧 اگر کار نکرد: پیامِ زیر یعنی نامِ ستون بینِ دو جدول مشترک است و باید بگویی از کدام جدول:
try:
q("""
SELECT id, name
FROM books AS b
JOIN branches AS br ON br.id = b.branch_id
LIMIT 1
""")
except Exception as error:
print(type(error).__module__ + "." + type(error).__name__)
print(error)
pandas.errors.DatabaseError
Execution failed on sql '
SELECT id, name
FROM books AS b
JOIN branches AS br ON br.id = b.branch_id
LIMIT 1
': ambiguous column name: id
b.id یا br.id — هر کدام را که بخواهی، باید بگویی. جالب اینکه name مشکلی نداشت، چون فقط branches ستونی به این نام دارد.
و یک عادت که از همینجا برایت میسازیم: در پرسشی که بیشتر از یک جدول دارد، هر ستون را با پیشوندِ جدولش بنویس، حتی وقتی مجبور نیستی. پرسشی که امروز بیابهام است، فردا با اضافه شدنِ یک ستون به یکی از دو جدول مبهم میشود — و آنوقت پرسشی که سالِ گذشته کار میکرد، ناگهان خطا میدهد.
۶. ترتیبِ نوشتنِ جدولها نتیجه را عوض نمیکند#
سؤالِ طبیعیِ هر کسی که اولین JOINش را نوشته: کدام جدول باید اول بیاید؟
left_first = q("""
SELECT COUNT(*) AS rows, SUM(b.pages) AS pages
FROM books AS b
JOIN branches AS br ON br.id = b.branch_id
""")
right_first = q("""
SELECT COUNT(*) AS rows, SUM(b.pages) AS pages
FROM branches AS br
JOIN books AS b ON b.branch_id = br.id
""")
print("books اول :", int(left_first["rows"][0]), "سطر |", int(left_first["pages"][0]), "صفحه")
print("branches اول:", int(right_first["rows"][0]), "سطر |", int(right_first["pages"][0]), "صفحه")
print("یکیاند؟", left_first.equals(right_first))
books اول : 1200 سطر | 588049 صفحه
branches اول: 1200 سطر | 588049 صفحه
یکیاند؟ True
برای INNER JOIN جای دو جدول فرقی نمیکند. مجموعهٔ سطرهای نتیجه یکی است.
دو نکته که این جمله را از یک شعار به یک ادعای دقیق تبدیل میکنند: اولاً ترتیبِ نمایشِ سطرها تضمینشده نیست — همان قاعدهٔ فصلِ ۴ ترمِ ۱، که اگر ترتیب برایت مهم است ORDER BY بنویس. و ثانیاً این تقارن مالِ INNER است؛ برای LEFT JOIN که فصلِ بعد میآید، جای دو جدول کاملاً نتیجه را عوض میکند، و این تفاوت دلیلِ وجودِ آن فصل است.
📏 اندازه بگیر: یک سطر یعنی چه؟ یک سطر از نتیجهٔ بخشِ ۴ یعنی «یک کتاب، بههمراهِ مشخصاتِ شعبهاش» — دقیقاً همان دانهبندیِ
books، نه چیزِ تازهای. با کدام شمارشِ دوم سنجیدی؟ باCOUNT(*)قبل و بعد ازJOINکه هر دو ۱۲۰۰ شدند، و باSUM(pages)که از دو ترتیبِ متفاوتِ نوشتن یک عدد داد. چه کسی از قلم افتاد؟ شعبهٔ آفتاب: هفت شعبه داریم و در نتیجهٔJOINفقط ششتا دیده میشوند، چون هیچ کتابی به آفتاب اشاره نمیکند.INNER JOINبیصدا انداختش بیرون و هیچ عددی در این فصل به تو نگفت.
آن جملهٔ آخر مهمتر از چیزی است که بهنظر میرسد: برای فهمیدنِ اینکه شعبهٔ آفتاب از قلم افتاده، باید از بیرونِ پرسش میدانستی هفت شعبه داری. خودِ نتیجه هیچ نشانهای نداشت. فصلِ بعد ابزاری میآورد که همین را از داخلِ پرسش قابلِ دیدن میکند.
🤖 از دستیارت بپرس: «چرا نگه داشتنِ نامِ شهر در هر سطرِ جدولِ کتابها به تناقض میرسد؟» بعد این را بپرس: «در یک
INNER JOIN، چه چیزی تصمیم میگیرد که تعدادِ سطرهای نتیجه از تعدادِ سطرهای جدولِ چپ بیشتر شود؟» — جوابِ درست دربارهٔ تعدادِ جفتهای هر سطر حرف میزند، نه دربارهٔ اندازهٔ جدولها. اگر جواب فقط گفت «بستگی به داده دارد»، ناقص است؛ همین سؤال موضوعِ فصلِ ۳ است.
واژههای تازهٔ این فصل#
| کلمه | تلفظ به حروف فارسی | یعنی چه |
|---|---|---|
| JOIN | جوین | کنارِ هم آوردنِ سطرهای دو جدول بر اساسِ یک شرط |
| INNER JOIN | اینر جوین | فقط سطرهایی که در هر دو طرف جفت دارند |
| ON | آن | شرطی که میگوید کدام سطر با کدام سطر جفت میشود |
| USING | یوزینگ | میانبُرِ ON وقتی نامِ ستونِ کلید در دو جدول یکی است |
| primary key | پرایمری کی | ستونی که هر سطر را یکتا میکند |
| foreign key | فارن کی | ستونی که به کلیدِ اصلیِ جدولِ دیگری اشاره میکند |
| redundancy | ریداندنسی | افزونگی؛ نوشتنِ یک واقعیت در چند جا |
| update anomaly | آپدیت آنومالی | ناهنجاریِ بهروزرسانی؛ وقتی یک نسخه بهروز میشود و بقیه نه |
تمرینها
اول خودت فکر کن یا امتحان کن — بعد اینجا را باز کن.
در فصل بعد#
در همین فصل دو سطر بیصدا ناپدید شدند: شیراز در جدولِ اسباببازی، و شعبهٔ آفتاب در جدولِ واقعی. هیچ خطایی ندادند و هیچ عددی به ما نگفت. فصلِ بعد LEFT JOIN را میآورد که نگهشان میدارد، و میشمارد دقیقاً چند سطر بودند: ۲۰۰ کتابی که هرگز امانت نرفتهاند و ۴۲ عضوی که هرگز کتابی نبردهاند. و بدتر از آن، تلهای را نشان میدهد که یک شرطِ ساده در جای اشتباه، LEFT JOIN را بیصدا به INNER JOIN برمیگرداند.
به آخر این فصل رسیدی!
اگر ساختی و جواب داد، این دکمه مال توست.