در این فصل چه یاد میگیری#
دو پرسش مینویسیم که کلِ جدول را بینِ خودشان تقسیم میکنند: «چاپِ بیشتر از دوم» و «چاپِ دوم یا کمتر». جوابشان ۷۹۶ و ۴۰۴ است و جمعشان دقیقاً ۱۲۰۰ — یعنی حساب میخواند و هیچ سطری جا نیفتاده.
و هر دو عدد غلطاند. جوابهای درست ۷۸۹ و ۴۱۱ هستند. هفت سطر در دستهٔ اشتباه نشستهاند، چون مقدارشان در ستونی که INTEGER اعلام شده، متن ذخیره شده است.
این فصل دربارهٔ همان چیزی است که این هفت سطر را ساخت: نوعِ مقدار. و در راه، مقایسهای را میبینیم که ۳۸۴ جواب میدهد جای ۲۰۲، و آخرش با یک تابعِ ساده آن ۱۲۳ نویسندهٔ فصلِ قبل را به ۱۲۰ برمیگردانیم.

آخر این فصل میتوانی:
- نوعِ هر مقدار را با
typeofببینی و بگویی چرا نوعِ ستون تضمینش نمیکند - بگویی مقایسهٔ متن با عدد در
SQLiteچه میکند، و باCASTدرستش کنی - تقسیمِ صحیح را بشناسی و جلوی صفر شدنِ ناخواسته را بگیری
- با
ROUNDوCASE WHENخروجیِ خواندنی بسازی - تاریخِ
ISO-8601را باdate()وstrftimeبخوانی و بدانی کجا بیصدا اشتباه میکند
قبل از شروع#
از فصلِ ۴: آن ۱۲۳ نامِ یکتا که ۱۲۰ نفر بودند. این فصل با SQL درستش میکند.
یک جمله که کلِ فصل روی آن سوار است: در SQLite نوع، صفتِ مقدار است نه صفتِ ستون. در بیشترِ پایگاه دادههای دیگر برعکس است، و همین یک تفاوت منشأِ همهٔ تلههای این فصل است.
📓 نوتبوک: نوتبوک این فصل را در Colab باز کن — همهٔ کدهای این فصل آماده و بهترتیب داخلش هست.
| جدول | تعدادِ سطر | زمانِ ساخت |
|---|---|---|
books |
۱٬۲۰۰ | کمتر از یک ثانیه |
branches |
۶ | آنی |
۱. پنج نوع، و تابعی که نوعِ هر مقدار را میگوید#
SQLite پنج نوعِ ذخیرهسازی دارد: INTEGER (عددِ صحیح)، REAL (عددِ اعشاری)، TEXT (متن)، BLOB (بایتِ خام) و NULL (نبودِ مقدار). typeof(x) نوعِ همان مقدار را در همان سطر برمیگرداند.
print(q("""
SELECT typeof(id) AS t_id,
typeof(pages) AS t_pages,
typeof(rating) AS t_rating,
typeof(price_text) AS t_price,
typeof(edition) AS t_edition
FROM books
LIMIT 3
"""))
t_id t_pages t_rating t_price t_edition
0 integer integer real text integer
1 integer integer real text integer
2 integer integer null text integer
سه سطر، و ستونِ t_rating در سطرِ سوم null است در حالی که در دو سطرِ بالا real بود. یعنی نوع واقعاً سطر به سطر فرق میکند. این همان NULLی است که در فصلهای قبل چند بار سراغش رفتیم و فصلِ بعد کاملاً بازش میکند.
۲. type affinity: ستونی که INTEGER اعلام شده و متن دارد#
وقتی جدول را ساختیم، edition را INTEGER اعلام کردیم. SQLite این را یک گرایش (affinity) میفهمد، نه یک قانون: سعی میکند مقدارِ ورودی را به عدد تبدیل کند؛ اگر نتوانست، همانطور که هست ذخیره میکند و خطا نمیدهد.
رشتهٔ '3' به عددِ ۳ تبدیل میشود. رشتهٔ '۳' — همان سه، با رقمِ فارسی — تبدیل نمیشود، چون SQLite رقمِ فارسی را عدد نمیشناسد. پس متن میماند.
for kind in ("integer", "text", "real", "null"):
n = int(q("SELECT COUNT(*) FROM books WHERE typeof(edition) = ?", (kind,)).iloc[0, 0])
print(f" edition از نوع {kind:<8}: {n:>5} سطر")
edition از نوع integer : 1163 سطر
edition از نوع text : 37 سطر
edition از نوع real : 0 سطر
edition از نوع null : 0 سطر
سیوهفت سطر از ۱٬۲۰۰ سطر در ستونی که «عدد» اعلام شده، متناند. ببینیمشان:
print(q("""
SELECT id, edition, typeof(edition) AS kind
FROM books
WHERE typeof(edition) = ?
ORDER BY id
LIMIT 5
""", ("text",)))
id edition kind
0 14 ۴ text
1 31 ۳ text
2 46 ۵ text
3 138 ۶ text
4 191 ۴ text
با چشم که نگاه میکنی، اینها عددند. برای SQLite رشتهاند.
۳. ۷۹۶ و ۴۰۴، که جمعشان درست است و هر دو غلطاند#
حالا سؤالِ واقعی: چند کتاب چاپِ بیشتر از دوم دارند؟
above = int(q("SELECT COUNT(*) FROM books WHERE edition > ?", (2,)).iloc[0, 0])
below = int(q("SELECT COUNT(*) FROM books WHERE edition <= ?", (2,)).iloc[0, 0])
text_rows = int(q("SELECT COUNT(*) FROM books WHERE typeof(edition) = ?", ("text",)).iloc[0, 0])
text_small = int(q("""
SELECT COUNT(*) FROM books
WHERE typeof(edition) = ? AND edition IN (?, ?)
""", ("text", "۱", "۲")).iloc[0, 0])
print("edition > 2 :", above)
print("edition <= 2 :", below)
print("جمع دو تکه :", above + below, "| کل جدول: 1200")
print()
print("از سطرهای دستهٔ اول، چندتا متناند؟", text_rows)
print("و از همانها چندتا واقعاً ۱ یا ۲ هستند؟", text_small)
print("پس جواب درست این است → بیشتر از ۲:", above - text_small,
"| کمتر یا مساوی ۲:", below + text_small)
edition > 2 : 796
edition <= 2 : 404
جمع دو تکه : 1200 | کل جدول: 1200
از سطرهای دستهٔ اول، چندتا متناند؟ 37
و از همانها چندتا واقعاً ۱ یا ۲ هستند؟ 7
پس جواب درست این است → بیشتر از ۲: 789 | کمتر یا مساوی ۲: 411
اینجا مهمترین درسِ کلِ ترم است: شمارشِ کنترلیِ «جمع دو تکه باید کل بشود» کاملاً پاس شد، و هر دو عدد غلط بودند.
چرا هر ۳۷ سطرِ متنی در دستهٔ «بیشتر از ۲» افتادند؟ چون SQLite وقتی متن را با عدد مقایسه میکند، همیشه متن را بزرگتر میداند — قاعدهای ثابت و مستند: NULL < عدد < متن < BLOB. پس '۱' > 2 هم درست است.
و درسِ روشیِ این بخش این است: یک شمارشِ کنترلی که فقط جمع را میسنجد، خطاهایی را میگیرد که سطر گم میکنند، ولی خطاهایی را که سطر را در دستهٔ اشتباه میگذارند نمیگیرد. برای آنها باید سراغِ خودِ داده بروی — که همان کاری است که دو خطِ آخر کردند.
✅ چک کن: ۷۸۹ + ۴۱۱ باید بشود ۱۲۰۰، و ۷۹۶ − ۷ باید بشود ۷۸۹. اگر عددهایت فرق کرد، احتمالاً
IN (?, ?)را با رقمِ انگلیسی نوشتهای؛ آن سطرها با رقمِ فارسی ذخیره شدهاند.
۴. مقایسهٔ متن با عدد، و CAST#
همان قاعده روی ستونِ price_text — که واقعاً و کاملاً متن است — نتیجهٔ بدتری میدهد، چون آنجا متن با متن مقایسه میشود و مقایسهٔ متنی حرفبهحرف است، نه عددی.
as_text = int(q("SELECT COUNT(*) FROM books WHERE price_text > ?", ("500000",)).iloc[0, 0])
as_number = int(q("""
SELECT COUNT(*) FROM books WHERE CAST(price_text AS INTEGER) > ?
""", (500000,)).iloc[0, 0])
print("price_text > '500000' :", as_text)
print("CAST(price_text AS INTEGER) > ... :", as_number)
print("اختلاف :", as_text - as_number, "سطر")
print()
print(q("SELECT ? > ? AS text_compare, CAST(? AS INTEGER) > ? AS number_compare",
("99000", "500000", "99000", 500000)))
price_text > '500000' : 384
CAST(price_text AS INTEGER) > ... : 202
اختلاف : 182 سطر
text_compare number_compare
0 1 0
صد و هشتاد و دو سطر اختلاف، و خطِ آخر کلِ دلیل را در دو عدد نشان میدهد.
'99000' > '500000' درست است. چون مقایسهٔ متنی از چپ شروع میکند و میبیند '9' بزرگتر از '5' است — همینجا تمام. طولِ رشته اصلاً بررسی نمیشود. یعنی همهٔ کتابهای ۹۰ تا ۹۹ هزار تومانی در فهرستِ «گرانتر از ۵۰۰ هزار» نشستند.
CAST(x AS INTEGER) مقدار را به عدد تبدیل میکند — ولی آنچه با مقدارهای غیرِعددی میکند خودش یک تلهٔ تازه است:
print(q("""
SELECT CAST('123' AS INTEGER) AS ascii_digits,
CAST('۱۲۳' AS INTEGER) AS persian_digits,
CAST('' AS INTEGER) AS empty_string,
CAST('نامشخص' AS INTEGER) AS a_word
"""))
print()
empty = int(q("SELECT COUNT(*) FROM books WHERE price_text = ?", ("",)).iloc[0, 0])
word = int(q("SELECT COUNT(*) FROM books WHERE price_text = ?", ("نامشخص",)).iloc[0, 0])
zeroed = int(q("SELECT COUNT(*) FROM books WHERE CAST(price_text AS INTEGER) = ?",
(0,)).iloc[0, 0])
print("رشته خالی :", empty)
print("کلمه «نامشخص» :", word)
print("بعد از CAST صفر شدند :", zeroed)
print("یعنی رقم فارسی داشتند:", zeroed - empty - word)
ascii_digits persian_digits empty_string a_word
0 123 0 0 0
رشته خالی : 65
کلمه «نامشخص» : 38
بعد از CAST صفر شدند : 169
یعنی رقم فارسی داشتند: 66
CAST هرگز خطا نمیدهد. هر چیزی را که نتواند بخواند، صفر میکند.
۱۶۹ سطر بعد از CAST صفر شدند و اسمِ هیچکدامشان «صفر تومان» نیست: ۶۵تا رشتهٔ خالی، ۳۸تا کلمهٔ «نامشخص»، و ۶۶تا قیمتِ کاملاً معتبر که فقط با رقمِ فارسی تایپ شده است.
و حالا ببین این ۱۶۹ صفرِ ساختگی با یک میانگین چه میکند:
naive = float(q("SELECT AVG(CAST(price_text AS INTEGER)) FROM books").iloc[0, 0])
careful = float(q("""
SELECT AVG(CAST(price_text AS INTEGER)) FROM books
WHERE CAST(price_text AS INTEGER) > ?
""", (0,)).iloc[0, 0])
print("میانگین قیمت، با صفرها :", naive)
print("میانگین قیمت، بدون صفرها:", careful)
print("اختلاف :", round(careful - naive))
میانگین قیمت، با صفرها : 283413.3333333333
میانگین قیمت، بدون صفرها: 329870.0290979631
اختلاف : 46457
چهلوشش هزار تومان اختلاف در میانگین، تماماً از ۱۶۹ صفری که خودمان ساختیم. و هیچکدام از این دو عدد هنوز جوابِ درست نیست: عددِ دوم آن ۶۶ قیمتِ سالمِ رقمفارسی را هم انداخته بیرون. بخشِ ۷ ابزارِ نجات دادنشان را میدهد.
۵. تقسیمِ صحیح#
print(q("SELECT 7/2 AS a, 7.0/2 AS b, 7/2.0 AS c, -7/2 AS d"))
print()
print(q("""
SELECT title, pages, pages/100 AS shelves_wrong, pages/100.0 AS shelves_right
FROM books
LIMIT 3
"""))
a b c d
0 3 3.5 3.5 -3
title pages shelves_wrong shelves_right
0 روزهای پل 730 7 7.30
1 مه در باران 564 5 5.64
2 روزهای خاک 907 9 9.07
عدد تقسیم بر عدد، عددِ صحیح میدهد. 7/2 میشود ۳ نه ۳٫۵، و اعشار حذف میشود نه گرد. برای منفی هم بهسمتِ صفر بریده میشود: -7/2 میشود ۳- نه ۴-.
راهِحل ساده است: یک طرف را اعشاری کن. pages/100.0 یا CAST(pages AS REAL)/100.
🌱 ریشهاش کجاست: تقسیم دو سؤالِ کاملاً متفاوت است — «بینِ چند نفر پخش کنم؟» و «چند دستهٔ چندتایی میشود؟» — و باقیمانده در هرکدام معنیِ متفاوتی دارد. تقسیمِ صحیحِ
SQLجوابِ سؤالِ دوم را میدهد و باقیمانده را دور میریزد. اگر این تمایز برایت تازه است، ریشه ترمِ ۱ فصل ۵ با دوازده شکلات روی میز کاملاً بازش میکند.
۶. ROUND و CASE WHEN: خروجیِ خواندنی#
فصلِ ۲ به عددِ 3.7868937048503613 رسید و گفت بعداً درستش میکنیم. ROUND(x, n) همان است:
print(q("""
SELECT ROUND(AVG(rating), 2) AS rating_2,
ROUND(AVG(pages), 1) AS pages_1,
ROUND(AVG(pages)) AS pages_0
FROM books
"""))
rating_2 pages_1 pages_0
0 3.79 503.8 504.0
⚠️ مواظب باش:
ROUNDرا فقط در آخرین قدم و فقط برای نمایش بهکار ببر. اگر عددی را گرد کنی و بعد رویش حساب کنی، خطا انباشته میشود. و دقت کن کهROUND(x)بدونِ رقمِ دوم، خروجیِREALمیدهد —504.0نه504.
CASE WHEN یک زنجیرهٔ شرط است که بهجای عدد، برچسب میسازد:
print(q("""
SELECT title,
borrowed,
CASE
WHEN borrowed = 0 THEN 'هرگز امانت نرفته'
WHEN borrowed < 20 THEN 'کمامانت'
ELSE 'پرامانت'
END AS band
FROM books
ORDER BY id
LIMIT 4
"""))
title borrowed band
0 روزهای پل 33 پرامانت
1 مه در باران 20 پرامانت
2 روزهای خاک 1 کمامانت
3 مه پشت آتش 86 پرامانت
شرطها از بالا به پایین امتحان میشوند و اولین شرطی که درست باشد برنده است. به سطرِ دوم نگاه کن: borrowed برابرِ ۲۰ است و برچسبش «پرامانت» شد، چون شرطِ < 20 برای ۲۰ درست نیست. مرزها را همیشه با یک نمونهٔ دقیقاً روی مرز امتحان کن.
۷. متن را دستکاری کن — و ۱۲۳ دوباره ۱۲۰ شود#
|| دو متن را به هم میچسباند. lower حروف را کوچک میکند، trim فاصلههای دو سر را میبرد، substr(x, start, len) تکهای از متن را برمیدارد و length طولش را میدهد.
print(q("""
SELECT title || ' — ' || author AS label,
lower('SQLite') AS lowered,
trim(' sqlite ') AS trimmed,
substr(added_at, 1, 4) AS added_year,
length(title) AS title_length
FROM books
ORDER BY id
LIMIT 2
"""))
label lowered trimmed added_year title_length
0 روزهای پل — مهدی سلیمی sqlite sqlite 2022 9
1 مه در باران — رویا رحیمی sqlite sqlite 2019 11
⚠️ مواظب باش:
lowerوupperدرSQLiteفقط روی حروفِASCIIکار میکنند. روی متنِ فارسی هیچ کاری نمیکنند و هیچ خطایی هم نمیدهند. این محدودیتِ مستندِ موتور است، نه باگ.
و حالا replace(x, old, new) — تابعی که فصلِ ۴ منتظرش بود:
raw = len(q("SELECT DISTINCT author FROM books"))
fixed = len(q("""
SELECT DISTINCT replace(replace(author, 'ي', 'ی'), 'ك', 'ک') AS author
FROM books
"""))
maryam = int(q("""
SELECT COUNT(*) FROM books
WHERE replace(replace(author, 'ي', 'ی'), 'ك', 'ک') = ?
""", ("مریم کریمی",)).iloc[0, 0])
print("نامهای یکتا، خام :", raw)
print("نامهای یکتا، یکسانشده :", fixed)
print("کتابهای «مریم کریمی» :", maryam)
نامهای یکتا، خام : 123
نامهای یکتا، یکسانشده : 120
کتابهای «مریم کریمی» : 9
۱۲۳ شد ۱۲۰، و ۳ شد ۹. همان عددهایی که فصلِ ۴ اشتباهشان را نشان داد، حالا درستاند.
ولی این یک وصله است، نه درمان. هر پرسشی که از این به بعد روی author بنویسی باید همان دو replace را داشته باشد، و اولین باری که یادت برود، عددت دوباره غلط میشود. درمانِ واقعی این است که داده یک بار موقعِ ورود پاک شود — که کارِ فصلِ ۷ است.
۸. تاریخی که متن است#
SQLite نوعِ «تاریخ» ندارد. تاریخها متناند، بهشکلِ YYYY-MM-DD. و این تصادفی نیست: در این قالب، مرتبسازیِ متنی دقیقاً همان مرتبسازیِ زمانی است، چون سال قبل از ماه و ماه قبل از روز میآید و همهشان طولِ ثابت دارند.
date(x, ...) روی تاریخ حساب میکند و strftime(fmt, x) تکهای از آن را بیرون میکشد.
print(q("""
SELECT added_at,
date(added_at, '+30 days') AS due,
strftime('%Y', added_at) AS year_part,
strftime('%m', added_at) AS month_part
FROM books
ORDER BY id
LIMIT 3
"""))
print()
print("کتابهایی که در ۲۰۲۴ به فهرست اضافه شدند:",
int(q("SELECT COUNT(*) FROM books WHERE strftime('%Y', added_at) = ?",
("2024",)).iloc[0, 0]))
added_at due year_part month_part
0 2022-07-06 2022-08-05 2022 07
1 2019-03-16 2019-04-15 2019 03
2 2020-11-03 2020-12-03 2020 11
کتابهایی که در ۲۰۲۴ به فهرست اضافه شدند: 178
strftime متن برمیگرداند، نه عدد — به همین دلیل مقایسهاش را با '2024' نوشتیم و نه با 2024. اگر عددی مقایسهاش کنی، همان قاعدهٔ «عدد کوچکتر از متن است» را میخوری و صفر سطر میگیری.
و یک هشدارِ آخر که در کارِ واقعی گران تمام میشود:
print(q("""
SELECT date('2024-02-30') AS overflow,
date('2024-13-45') AS nonsense,
date('۱۴۰۳-۰۱-۰۱') AS persian_digits
"""))
overflow nonsense persian_digits
0 2024-03-01 None None
سیام فوریه وجود ندارد و SQLite بیصدا اولِ مارس تحویل داد. نه خطا، نه هشدار — یک تاریخِ کاملاً معتبر که تاریخِ تو نیست. دو مقدارِ دیگر NULL شدند، که دستِکم صادقانه است.
📏 اندازه بگیر: یک سطر یعنی چه؟ در بخشِ ۳ یک سطر یک کتاب است، ولی «دستهٔ چاپِ بیشتر از دوم» چیزی است که با نوعِ مقدار تعیین شد، نه با معنیاش. با کدام شمارشِ دوم سنجیدی؟ سهتا: جمعِ دو دسته که ۱۲۰۰ شد (و پاس شدنش گمراهکننده بود)، شمارشِ سطرهای متنی داخلِ هر دسته (۳۷ و ۷)، و تجزیهٔ ۱۶۹ صفرِ
CASTبه سه دلیلِ متفاوت (۶۵ + ۳۸ + ۶۶). چه کسی از قلم افتاد؟ ۶۶ قیمتِ سالم که چون رقمشان فارسی است در هر حسابِ عددی صفر میشوند؛ ۷ کتاب که در دستهٔ اشتباهِ چاپ نشستهاند؛ و همچنان ۲۴۰ سطرِarrivals.csvو ۲۳۱ سطرِ بیامتیاز.
🔧 اگر کار نکرد: اگر از پایگاه دادهٔ دیگری آمدهای، احتمالاً اولین کاری که میکنی این است که تابعِ آشنایت را صدا بزنی:
try:
q("SELECT DATEDIFF(added_at, added_at) FROM books LIMIT 1")
except Exception as error:
print(type(error).__module__ + "." + type(error).__name__)
print(error)
pandas.errors.DatabaseError
Execution failed on sql 'SELECT DATEDIFF(added_at, added_at) FROM books LIMIT 1': no such function: DATEDIFF
SQLite تابعِ DATEDIFF ندارد. معادلش julianday(a) - julianday(b) است، که فاصله را برحسبِ روز — با اعشار — میدهد. این یکی از جاهایی است که گویشِ SQL واقعاً بینِ موتورها فرق میکند.
🤖 از دستیارت بپرس: «
type affinityدرSQLiteیعنی چه و چه فرقی با نوعِ سختگیرانه در پایگاه دادههای دیگر دارد؟» بعد این را بپرس: «چطور کاری کنم که یک ستون فقط عددِ صحیح بپذیرد و اگر متن دادم خطا بدهد؟» — جواب یک قید در تعریفِ جدول است، و ترمِ ۴ دقیقاً همان را میسازد.
واژههای تازهٔ این فصل#
| کلمه | تلفظ به حروف فارسی | یعنی چه |
|---|---|---|
| type affinity | تایپ افینیتی | گرایشِ نوعِ ستون؛ توصیه است نه اجبار |
| CAST | کست | تبدیلِ صریحِ یک مقدار به نوعی دیگر |
| integer division | اینتیجر دیویژن | تقسیمی که اعشار را دور میریزد |
| expression | اکسپرشن | عبارتی که از ستونها و عملگرها یک مقدارِ تازه میسازد |
| ISO-8601 | ایزو ۸۶۰۱ | قالبِ استانداردِ تاریخ بهشکلِ YYYY-MM-DD |
| CASE WHEN | کیس ون | زنجیرهٔ شرط که برچسب یا مقدار میسازد |
تمرینها
اول خودت فکر کن یا امتحان کن — بعد اینجا را باز کن.
در فصل بعد#
سه فصل است میگوییم «فصلِ ۶ دربارهٔ NULL است» و هر بار یک تکهاش را نشان دادهایم: ۲۳۱ سطری که AVG نشمرد، = NULL که صفر جواب داد، و همین حالا typeof که در یک سطر null برگرداند. فصلِ بعد همهشان را یکجا باز میکند — و با عددی تمام میشود که کلِ روایتِ این ترم را عوض میکند: آن ۲۳۱ کتابِ بیامتیاز تصادفی انتخاب نشدهاند؛ بیشترین امانتِ کلِ آن گروه ۲ است.
به آخر این فصل رسیدی!
اگر ساختی و جواب داد، این دکمه مال توست.