در این فصل چه یاد میگیری#
یک پرسشِ کاملاً معمولی: «مجموعِ قیمتِ کتابهای این کتابخانه چقدر است؟» یک SUM مینویسی و یک عدد میگیری — ۳۴۰٬۰۹۶٬۰۰۰. بدونِ خطا، بدونِ هشدار، با ظاهری کاملاً سالم.
آن عدد جمعِ ۱٬۰۳۱ کتاب است، نه ۱٬۲۰۰ کتاب. صد و شصت و نه کتاب بیصدا صفر حساب شدند، و هیچ چیزی در خروجی این را نمیگوید.
ترمِ ۱ سه تابعِ تجمیعی داد: COUNT(*) و SUM و AVG. این فصل جعبهابزار را کامل میکند — MIN و MAX و COUNT(DISTINCT ...) و TOTAL — و در همان راه سه شکلِ متفاوتِ سکوت را نشان میدهد: تجمیعی که NULL میدهد، تجمیعی که صفر میدهد، و تجمیعی که عددِ خوشقیافهٔ غلط میدهد.

آخر این فصل میتوانی:
- کمینه و بیشینهٔ یک ستون را بگیری و بگویی چرا
MAXدرSQLiteدو کارِ کاملاً متفاوت میکند - تعدادِ مقدارهای متفاوت یک ستون را بشماری و بگویی چرا با تعدادِ سطرها یکی نیست
- تفاوتِ «هیچ سطری نبود» را با «سطر بود ولی مقدارش نامعلوم است» با عدد نشان بدهی
- روی یک عبارت تجمیع بگیری، نه فقط روی یک ستون
- ثابت کنی که یک
SUMعددِ درستی داده یا فقط عددی داده
قبل از شروع#
از ترمِ ۱: SELECT و WHERE و ORDER BY، سه تابعِ COUNT(*) و SUM و AVG روی کلِ جدول، NULL و منطقِ سهارزشی، و type affinity — اینکه در SQLite نوع، صفتِ مقدار است نه صفتِ ستون.
همان پایگاه دادهٔ ترمِ ۱ است، با همان SEED. پس هر عددی که آنجا دیدی اینجا هم همان است: ۱٬۲۰۰ کتاب، ۹۴٬۱۹۱ امانت، ۹۶۹ کتابِ امتیازدار، ۲۳۱ کتابِ بیامتیاز، و ۲۴۰ سطری که هنوز در arrivals.csv روی دیسک منتظرند.
📓 نوتبوک: نوتبوک این فصل را در Colab باز کن — همهٔ کدهای این فصل آماده و بهترتیب داخلش هست.
| جدول | تعدادِ سطر | زمانِ ساخت |
|---|---|---|
books |
۱٬۲۰۰ | کمتر از یک ثانیه |
branches |
۶ | آنی |
arrivals.csv (روی دیسک، وارد نشده) |
۲۴۰ | آنی |
۱. MIN و MAX: دو سرِ داده#
MIN(col) کوچکترین مقدارِ یک ستون را میدهد و MAX(col) بزرگترین را. مثلِ SUM و AVG، این دو هم از همهٔ سطرها یک عدد میسازند.
print(q("""
SELECT MIN(borrowed) AS least_borrowed,
MAX(borrowed) AS most_borrowed,
MIN(pages) AS thinnest,
MAX(pages) AS thickest,
MIN(added_at) AS first_added,
MAX(added_at) AS last_added
FROM books
"""))
least_borrowed most_borrowed thinnest thickest first_added last_added
0 0 190 50 940 2019-01-01 2025-06-30
شش عدد، یک سطر. و دقت کن که MIN و MAX روی متن هم کار میکنند — added_at یک ستونِ TEXT است و «کوچکترین» در متن یعنی همان چیزی که ترمِ ۱ فصلِ ۴ نشانش داد: ترتیبِ نویسهها. برای تاریخِ ISO-8601 این دقیقاً همان ترتیبِ زمانی است، پس جواب درست است — ولی به این دلیل درست است، نه به این دلیل که SQLite تاریخ میفهمد.
و همینجا اولین تلهٔ فصل:
print(q("""
SELECT MIN(edition) AS lowest,
MAX(edition) AS highest,
typeof(MIN(edition)) AS type_of_lowest,
typeof(MAX(edition)) AS type_of_highest
FROM books
"""))
lowest highest type_of_lowest type_of_highest
0 1 ۶ integer text
بیشترین چاپ، «۶» با رقمِ فارسی درآمد — و نوعش text است.
این همان ۳۷ سطری است که در ترمِ ۱ فصلِ ۵ پیدا کردیم: ستونی که INTEGER اعلام شده و در ۳۷ سطر متن دارد. قاعدهٔ ترتیبِ SQLite میگوید متن از هر عددی بزرگتر است، پس MAX مجبور بود یکی از آن ۳۷ سطر را برگرداند. کمینه و بیشینه هیچوقت میانگین نیستند؛ همیشه یک سطرِ واقعیاند — و به همین دلیل خرابترین سطرِ جدولت را پیدا میکنند.
MIN و MAX را بهعنوان ارزانترین بازرسِ داده به کار ببر. قبل از هر گزارشی، دو سرِ هر ستونی را که رویش حساب میکنی نگاه کن؛ اگر یکی از آن دو معنا نداشت، هیچ میانگینی روی آن ستون معنا ندارد.
۲. MAX در SQLite دو تابعِ کاملاً متفاوت است#
این یکی از آن رفتارهایی است که تا وقتی گرفتارش نشوی باور نمیکنی: MAX با یک ورودی تجمیعی است و با دو ورودی نیست.
print("MAX با یک ورودی — یک عدد از کل جدول:")
print(q("SELECT MAX(borrowed) AS aggregate_max FROM books"))
print()
print("MAX با دو ورودی — یک مقدار برای هر سطر:")
print(q("""
SELECT id, borrowed, pages, MAX(borrowed, pages) AS row_max
FROM books
ORDER BY id
LIMIT 3
"""))
MAX با یک ورودی — یک عدد از کل جدول:
aggregate_max
0 190
MAX با دو ورودی — یک مقدار برای هر سطر:
id borrowed pages row_max
0 1 33 730 730
1 2 20 564 564
2 3 1 907 907
پرسشِ اول یک سطر داد، پرسشِ دوم سه سطر. با یک ورودی، MAX همهٔ سطرها را میبلعد و یک عدد میسازد. با دو ورودی، MAX یک تابعِ معمولی است که در هر سطر بزرگترِ دو مقدار را برمیگرداند و هیچ سطری را نمیبلعد.
این برای MIN هم همینطور است، و در بیشترِ پایگاه دادههای دیگر اصلاً چنین چیزی وجود ندارد. اگر جایی MAX(a, b) نوشتی و انتظارِ یک عدد داشتی، بیصدا یک ستونِ کامل میگیری.
🔧 اگر کار نکرد: همین شوخی روی
COUNTجواب نمیدهد، و خوشبختانه خطا میدهد:
try:
q("SELECT COUNT(borrowed, pages) FROM books")
except Exception as error:
print(type(error).__module__ + "." + type(error).__name__)
print(error)
pandas.errors.DatabaseError
Execution failed on sql 'SELECT COUNT(borrowed, pages) FROM books': wrong number of arguments to function COUNT()
COUNT فقط یک ورودی میگیرد (یا *)، پس اشتباهِ تایپی سرِ جایش گیر میافتد. MAX نمیگیرد، و این دقیقاً همان تفاوتِ «خطای پرسروصدا» و «خطای بیصدا» است که کلِ این دوره دربارهٔ آن است.
۳. COUNT(DISTINCT col): شمردنِ چیزهای متفاوت، نه سطرها#
ترمِ ۱ دو شمارش داد: COUNT(*) سطرها را میشمارد و COUNT(col) سطرهایی را که در آن ستون مقدار دارند. شمارشِ سوم این است: چند مقدارِ متفاوت؟
print("سه شمارش روی یک ستون:")
print(q("""
SELECT COUNT(*) AS rows_total,
COUNT(rating) AS rows_with_rating,
COUNT(DISTINCT rating) AS different_ratings
FROM books
"""))
print()
print("و همان شمارش سوم روی سه ستون دیگر:")
print(q("""
SELECT COUNT(DISTINCT author) AS authors,
COUNT(DISTINCT category) AS categories,
COUNT(DISTINCT branch) AS branches
FROM books
"""))
سه شمارش روی یک ستون:
rows_total rows_with_rating different_ratings
0 1200 969 25
و همان شمارش سوم روی سه ستون دیگر:
authors categories branches
0 123 8 6
چهار عدد و چهار دانهبندیِ کاملاً متفاوت. ۱٬۲۰۰ یعنی «کتاب»، ۹۶۹ یعنی «کتابِ امتیازدار»، ۲۵ یعنی «مقدارِ متفاوتِ امتیاز»، و ۱۲۳ یعنی «رشتهٔ متفاوت در ستونِ نویسنده». هیچکدام از اینها با هم قابلِمقایسه نیستند، و همین است که این فصل را لازم میکند.
و آن ۱۲۳ همان دامِ ترمِ ۱ فصلِ ۴ است. نویسندههای واقعی ۱۲۰ نفرند؛ سه نفرشان نامشان با نویسهٔ عربی هم ثبت شده و COUNT(DISTINCT ...) آنها را دو نفر میشمارد، چون ي و ی برای SQLite دو حرفِ متفاوتاند.
print(q("""
SELECT COUNT(DISTINCT author) AS raw,
COUNT(DISTINCT replace(replace(author, 'ي', 'ی'), 'ك', 'ک')) AS normalised
FROM books
"""))
raw normalised
0 123 120
✅ چک کن: ۱۲۳ منهای ۱۲۰ باید بشود ۳. اگر عددت فرق کرد، یکی از دو
replaceرا جا انداختهای — هميلازم است و همك. و اگر همین حالا وسوسه شدی که «پس همیشهCOUNT(DISTINCT ...)را باreplaceبنویس»، جوابِ درستتر این است که داده یک بار موقعِ ورود پاک شود؛ ترمِ ۱ فصلِ ۷ نشان داد چرا.
COUNT(DISTINCT ...) اولین جایی است که یک گزارش پنهانی دانهبندیاش را عوض میکند. «۱۲۳ نویسنده» جملهای دربارهٔ آدمهاست، ولی عددش از رشتهها ساخته شده. هر بار که این تابع را مینویسی، بپرس: چیزی که میشمارم واقعاً همان چیزی است که اسمش را میبرم؟
۴. «هیچ سطری نبود» با «مقدارش نامعلوم است» فرق دارد#
این تفاوت در گزارشهای واقعی گرانترین است، چون هر دو حالت یک چیز چاپ میکنند: None.
اول حالتِ «هیچ سطری نبود». دستهٔ «ورزش» در این کتابخانه وجود ندارد:
print(q("""
SELECT COUNT(*) AS n,
SUM(borrowed) AS total,
AVG(borrowed) AS mean,
MIN(borrowed) AS lo,
MAX(borrowed) AS hi,
TOTAL(borrowed) AS total_function
FROM books
WHERE category = ?
""", ("ورزش",)))
n total mean lo hi total_function
0 0 None None None None 0.0
یک سطرِ خروجی گرفتیم، با اینکه هیچ سطرِ ورودیای نبود. یک تجمیعِ بدونِ GROUP BY همیشه دقیقاً یک سطر میدهد، حتی وقتی هیچ دادهای برای تجمیع کردن نیست.
و در آن یک سطر، COUNT(*) صفر است ولی بقیه NULL هستند. این کاملاً درست است: تعدادِ چیزی که وجود ندارد صفر است، ولی جمع و میانگین و کمینهاش «نامعلوم» است، نه صفر.
و TOTAL دقیقاً همان SUM است با یک تفاوت: هرگز NULL نمیدهد، صفر میدهد. این یک تابعِ مخصوصِ SQLite است و تصمیمی راحتکننده، نه درستتر. اگر برنامهات با None میشکند TOTAL نجاتت میدهد؛ ولی اگر گزارشت قرار است بگوید «چقدر؟»، آن صفر ادعایی بیپشتوانه است که خودت به داده اضافه کردهای.
حالا حالتِ دوم: سطر هست، مقدار نیست. ستونِ audio_minutes در هر ۱٬۲۰۰ سطر خالی است.
print(q("""
SELECT COUNT(*) AS n,
COUNT(audio_minutes) AS with_value,
SUM(audio_minutes) AS total,
TOTAL(audio_minutes) AS total_function,
MAX(audio_minutes) AS hi
FROM books
"""))
n with_value total total_function hi
0 1200 0 None 0.0 None
SUM باز هم NULL داد و TOTAL باز هم صفر — ولی این بار COUNT(*) هزار و دویست است.
پس همان دو خروجیِ یکسان از دو وضعیتِ کاملاً متفاوت آمدند، و تنها چیزی که آن دو را از هم جدا میکند COUNT(*) کنارِ COUNT(col) است. هر جا یک تجمیع NULL داد، این دو شمارش را کنارش بگذار؛ بدونِ آنها نمیدانی با کدامیک طرفی.
۵. تجمیع روی یک عبارت، نه فقط یک ستون#
هر چیزی که در SELECT میشود نوشت، در یک تابعِ تجمیعی هم میشود گذاشت. این کوچک به نظر میرسد و در عمل نصفِ کارِ گزارشنویسی است.
print(q("""
SELECT SUM(pages) AS all_pages,
SUM(pages * borrowed) AS pages_read,
SUM(rating >= 4.0) AS four_and_up,
SUM(rating IS NULL) AS unrated,
AVG(pages > 500) AS share_thick
FROM books
"""))
all_pages pages_read four_and_up unrated share_thick
0 604558 47402053 416 231 0.5
سه ستونِ آخر با اولی فرق دارند و باید بدانی چرا: در SQLite یک شرط، مقدارِ ۱ یا ۰ میدهد. پس SUM(rating >= 4.0) یعنی «چند سطر این شرط برایشان درست بود» — یعنی یک شمارشِ شرطی، در همان پرسش، بدونِ WHERE. و AVG(شرط) مستقیم سهم را میدهد، چون میانگینِ یک ستون از صفر و یک همان نسبتِ یکهاست.
ستونِ آخر دقیقاً 0.5 درآمد، و همین باید مشکوکت کند: ۰٫۵ ضربدر ۱٬۲۰۰ یعنی ۶۰۰ کتاب، نصفِ دقیقِ جدول — و در این دادهٔ ساختگی یک تصادف. این را با یک SELECT COUNT(*) FROM books WHERE pages > 500ِ مستقل بسنج، نه با ضرب. قاعدهٔ عملی: هر عددِ گِردی را با یک شمارشِ مستقل بسنج، حتی وقتی خوشحالت میکند.
این الگو در ادامهٔ ترم مدام برمیگردد، چون میگذارد چند شمارشِ متفاوت را در یک پرسش کنارِ هم بگیری و مطمئن باشی همهشان دقیقاً از یک مجموعه سطر آمدهاند.
⚠️ مواظب باش:
SUM(rating >= 4.0)سطرهایی را کهratingشانNULLاست نمیشمارد — شرط برایشان «نامعلوم» میشود، نه ۱ و نه ۰. پس ۴۱۶ بهاضافهٔ سطرهای زیرِ چهار، ۱٬۲۰۰ نمیشود؛ ۹۶۹ میشود. همان تلهٔ ترمِ ۱ فصلِ ۶، این بار داخلِ یک تابعِ تجمیعی. فصلِ ۴ همین را به یک مسئلهٔ مخرج تبدیل میکند.
۶. جمعی که خطا نمیدهد و عددِ غلط برمیگرداند#
حالا پرسشِ اولِ فصل. price_text یک ستونِ TEXT است — ترمِ ۱ فصلِ ۵ نشان داد که مقدارهایش سه شکلِ خراب دارند: رشتهٔ خالی، کلمهٔ «نامشخص»، و قیمتِ کاملاً درستی که با رقمِ فارسی تایپ شده.
SUM روی چنین ستونی چه میکند؟ خطا نمیدهد.
naive = q("SELECT SUM(price_text) AS total, COUNT(*) AS rows_counted FROM books")
print(naive)
print()
print(q("""
SELECT COUNT(*) AS rows_total,
SUM(CAST(price_text AS INTEGER) > 0) AS rows_that_helped,
SUM(price_text = '') AS empty_string,
SUM(price_text = 'نامشخص') AS the_word
FROM books
"""))
total rows_counted
0 340096000.0 1200
rows_total rows_that_helped empty_string the_word
0 1200 1031 65 38
عدد آمد، COUNT(*) هم ۱٬۲۰۰ گفت، و آن جمع فقط از ۱٬۰۳۱ سطر ساخته شده بود.
SUM هر مقداری را که نتواند به عدد تبدیل کند صفر میگیرد و جلو میرود — همان کاری که CAST در ترمِ ۱ کرد، این بار بدونِ اینکه اسمِ CAST جایی نوشته شده باشد. و COUNT(*) کنارش هیچ کمکی نمیکند، چون سطرها را میشمارد نه سهمشان را در جمع.
حالا رقمهای فارسی را نجات بدهیم و ببینیم چقدر عوض میشود:
LATIN = "replace(replace(replace(replace(replace(price_text, '۰','0'), '۱','1'), '۲','2'), '۳','3'), '۴','4')"
LATIN = "replace(replace(replace(replace(replace(" + LATIN + ", '۵','5'), '۶','6'), '۷','7'), '۸','8'), '۹','9')"
print(q(f"""
SELECT SUM(CAST({LATIN} AS INTEGER)) AS rescued_total,
SUM(CAST({LATIN} AS INTEGER) > 0) AS rows_that_helped
FROM books
"""))
rescued_total rows_that_helped
0 361156000 1097
۳۴۰٬۰۹۶٬۰۰۰ شد ۳۶۱٬۱۵۶٬۰۰۰، و ۱٬۰۳۱ سطر شد ۱٬۰۹۷ سطر. بیستویک میلیون تومان از ۶۶ کتابی آمد که قیمتشان از اول درست بود و فقط رقمشان فارسی بود.
و عددِ تازه هم هنوز جوابِ نهایی نیست: ۱٬۲۰۰ منهای ۱٬۰۹۷ میشود ۱۰۳ سطر که هنوز صفر حساب میشوند — ۶۵ رشتهٔ خالی و ۳۸ کلمهٔ «نامشخص». برای آن ۱۰۳ سطر هیچ عددی وجود ندارد که بشود در جمع گذاشت، و درستترین کار این است که در گزارش بنویسی جمع روی ۱٬۰۹۷ کتاب از ۱٬۲۰۰ کتاب گرفته شده.
دقت کن که آن رشتهٔ LATIN متنِ خودِ پرسش است، نه یک مقدار. ساختنِ متنِ پرسش در پایتون مجاز است و اینجا فقط برای این است که ده replace تودرتو در یک خط جا نشوند؛ مقدارها همچنان با ? میروند و هیچوقت به متنِ پرسش چسبانده نمیشوند.
📏 اندازه بگیر: یک سطر یعنی چه؟ خروجیِ هر پرسشِ این فصل یک سطر دارد و آن سطر یعنی «کلِ فهرست» — نه یک کتاب، نه یک نویسنده. حتی وقتی نتیجه صفر سطرِ ورودی داشت (دستهٔ «ورزش») باز هم یک سطرِ خروجی گرفتیم. با کدام شمارشِ دوم سنجیدی؟ سهتا:
COUNT(*)در برابرِCOUNT(col)روی هر ستونی که رویش حساب کردیم؛ ۱۲۳ در برابرِ ۱۲۰ بعد از یکسانسازیِ نویسه؛ و ۱٬۰۳۱ در برابرِ ۱٬۰۹۷ در برابرِ ۱٬۲۰۰ برای جمعِ قیمتها. چه کسی از قلم افتاد؟ ۱۰۳ کتاب که قیمتِ عددی ندارند و در هر جمعی صفر میشوند؛ ۲۳۱ کتاب که در هر شرطی رویratingنامعلوم میمانند؛ و همچنان ۲۴۰ سطرِarrivals.csvکه وارد نشدهاند.
🤖 از دستیارت بپرس: «چرا در
SQLiteتابعِTOTALوجود دارد در حالی کهSUMهست؟» بعد این را بپرس: «اگرSUMروی یک ستونِ متنی خطا نمیدهد، چطور میتوانم مطمئن شوم جمعی که گرفتهام از همهٔ سطرها آمده؟» — جوابِ کامل باید یک شمارش پیشنهاد بدهد که کنارِ جمع اجرا شود؛ اگر جواب فقط گفت «ستون را درست کن»، پرسشِ گزارش را جواب نداده است.
واژههای تازهٔ این فصل#
| کلمه | تلفظ به حروف فارسی | یعنی چه |
|---|---|---|
| MIN / MAX | مین / مکس | کوچکترین و بزرگترین مقدارِ یک ستون |
DISTINCT (در COUNT) |
دیستینکت | فقط مقدارهای متفاوت شمرده شوند |
| TOTAL | توتال | جمعِ SQLite که بهجای NULL صفر میدهد |
| cardinality | کاردینالیتی | تعدادِ مقدارهای متفاوتِ یک ستون |
| empty result | امپتی ریزالت | نتیجهای که هیچ سطرِ ورودی نداشته |
| silent failure | سایلنت فیلیور | خطایی که پیام نمیدهد و فقط عدد را عوض میکند |
تمرینها
اول خودت فکر کن یا امتحان کن — بعد اینجا را باز کن.
در فصل بعد#
تا اینجا هر تجمیعی از همهٔ سطرها یک عدد ساخت. فصلِ بعد GROUP BY را میآورد و همان یک عدد را به یک جدول تبدیل میکند: ۱٬۲۰۰ سطرِ کتاب به ۶ سطرِ شعبه، که پرشمارترینش ۲۲۴ کتاب دارد و کمشمارترینش ۱۸۰. و بلافاصله سراغِ سؤالی میرود که همهجا آدم را زمین میزند: چرا شرطی که با WHERE مینویسی و شرطی که با HAVING مینویسی دو چیزِ متفاوتاند.
به آخر این فصل رسیدی!
اگر ساختی و جواب داد، این دکمه مال توست.