সরাসরি পর্বে যান
পর্ব ১৩ · ৬৭-এর মধ্যে · মডিউল ৩

CREATE TABLE — ডেটা টাইপ আর constraint

CREATE TABLE — SQL Data Types, Constraints, Auto-increment IDs and SQLite's Type Affinity

Taught in Bangla, this lesson covers CREATE TABLE, IF NOT EXISTS, DROP TABLE and naming rules, numeric, text, date-time, boolean, BLOB, JSON and UUID types across five DBMS products, SQLite type affinity and STRICT tables, column versus table constraints including CHECK, named constraints and FOREIGN KEY, auto-increment ids and CREATE TABLE AS SELECT, all in live SQL.

এই পর্বে যা শিখবেন

  • CREATE TABLE, IF NOT EXISTS, DROP TABLE, অস্থায়ী টেবিল আর নাম রাখার নিয়ম
  • সংখ্যা, লেখা (বাংলাসহ), তারিখ-সময়, boolean, BLOB, JSON, UUID — পাঁচটি DBMS-এ কোন টাইপ কী নামে
  • SQLite-এর type affinity আর STRICT টেবিল — হাতে-কলমে পার্থক্য
  • Column constraint বনাম table constraint, named constraint, আর প্রতিটি INSERT-এ DBMS কী যাচাই করে
  • Auto-increment id, CREATE TABLE AS SELECT আর schema/namespace

১ · CREATE TABLE — একটি টেবিলের জন্মসনদ

মুদি দোকানের নতুন খাতা কেনার পর দোকানদার প্রথম পাতায় কলাম টানেন: তারিখ, ক্রেতার নাম, পণ্য, দাম। কলামের মাথায় যা লেখা হয়, পুরো খাতা সেভাবেই চলে। দামের ঘরে কেউ “আগামী সপ্তাহে দেব” লিখলে হিসাব মেলে না। ডেটাবেসে এই প্রথম পাতাটিই CREATE TABLE. পার্থক্য একটাই: খাতা ভুল লেখা আটকাতে পারে না, ডেটাবেস পারে।

পর্ব ১২-এ SQL-এর সাথে পরিচয় হয়েছে। CREATE TABLE হলো DDL (Data Definition Language) — ডেটা নয়, ডেটার কাঠামো বানায়। পর্ব ১০-এ ER ডায়াগ্রাম থেকে যে টেবিলগুলো বের করেছিলেন, এখানে সেগুলো আসল SQL হবে। সাধারণ রূপ:

CREATE TABLE table_name (
  column_1  data_type  [column constraint ...],
  column_2  data_type  [column constraint ...],
  ...
  [table constraint, ...]
);

প্রতিটি কলামের তিনটি অংশ: নাম, টাইপ, আর ঐচ্ছিক নিয়ম। কলামগুলোর মাঝে কমা, শেষ কলাম বা শেষ নিয়মের পরে কমা নয় — নতুনদের সবচেয়ে সাধারণ syntax ত্রুটি এটিই। এবার db-shop-এর customers টেবিলটি শূন্য থেকে বানাই। এই এডিটরে কোনো নমুনা ডেটা নেই, ডেটাবেস একদম খালি:

-- db-shop-এর customers টেবিল, একদম শূন্য থেকে
CREATE TABLE customers (
  customer_id INTEGER PRIMARY KEY,
  name        TEXT NOT NULL,
  city        TEXT,
  phone       TEXT UNIQUE,
  joined_on   TEXT NOT NULL      -- 'YYYY-MM-DD'
);
INSERT INTO customers VALUES
 (1, 'Rahim Uddin',  'Dhaka',      '01711000001', '2025-01-15'),
 (2, 'Karima Begum', 'Chattogram', '01811000002', '2025-02-03');
SELECT * FROM customers;
PRAGMA table_info(customers);

চারটি ফল আসে। প্রথমে “✓ CREATE TABLE”, তারপর “✓ INSERT — ২টি সারি বদলেছে”, তারপর দুই সারির টেবিল (Rahim Uddin আর Karima Begum). শেষ ফলটি সবচেয়ে কাজের: PRAGMA table_info হলো SQLite-এর নিজস্ব আদেশ, যা টেবিলের কাঠামো দেখায়। পাঁচটি সারি, প্রতি কলামে একটি: 0 | customer_id | INTEGER | 0 | NULL | 1, 1 | name | TEXT | 1 | NULL | 0, 2 | city | TEXT | 0 | NULL | 0, 3 | phone | TEXT | 0 | NULL | 0, 4 | joined_on | TEXT | 1 | NULL | 0. এখানে notnull = 1 মানে NOT NULL আছে, pk = 1 মানে primary key-র অংশ। UNIQUE এখানে দেখা যায় না, কারণ সেটি আলাদা একটি index হিসেবে থাকে (PRAGMA index_list(customers)). অন্য DBMS-এ একই কাজ করে PostgreSQL-এর psql-এ \d customers, MySQL-এ DESCRIBE customers, আর সব বড় DBMS-এ standard information_schema.columns.

নাম রাখার নিয়ম

  • ছোট হাতের অক্ষর আর আন্ডারস্কোর (snake_case): order_items, joined_on. PostgreSQL উদ্ধৃতিচিহ্ন ছাড়া নামকে ছোট হাতের করে নেয়, Oracle বড় হাতের। MySQL-এ টেবিলের নাম Linux সার্ভারে সাধারণত case-sensitive, Windows-এ নয় — মিশ্র অক্ষর তাই ঝামেলা আনে।
  • সংরক্ষিত শব্দ এড়ান: order, user, group, select SQL-এর নিজের শব্দ। টেবিলের নাম order দিলে প্রতিবার "order" লিখতে হবে। এজন্যই db-shop-এ orders.
  • বাংলা নাম সম্ভব, কিন্তু নয়: "গ্রাহক" উদ্ধৃতিচিহ্নে দিলে অনেক DBMS মেনে নেয়, কিন্তু টুল, ORM আর সহকর্মীর কিবোর্ড ঝামেলা করবে। ডেটা বাংলায় রাখুন, নাম ইংরেজিতে।
  • দৈর্ঘ্যের সীমা আছে: PostgreSQL-এ ৬৩ বাইট, MySQL-এ ৬৪ অক্ষর, Oracle 12.2-এর আগে মাত্র ৩০ বাইট।

IF NOT EXISTS, DROP TABLE আর অস্থায়ী টেবিল

একই নামের টেবিল আগে থাকলে CREATE TABLE ত্রুটি দেয় (table customers already exists). Setup স্ক্রিপ্ট বারবার চালাতে হলে লিখুন CREATE TABLE IF NOT EXISTS — টেবিল থাকলে চুপচাপ কিছুই করবে না। সাবধান: পুরোনো টেবিলের কাঠামো আলাদা হলেও সে তা যাচাই করে না। উল্টো কাজ DROP TABLE: টেবিল, তার সব ডেটা, index আর trigger একসাথে মুছে দেয়, আর সাধারণত ব্যাকআপ ছাড়া তা ফেরানো যায় না। ব্যতিক্রম: PostgreSQL, SQLite আর SQL Server-এ DROP একটি transaction-এর ভেতরে থাকলে ROLLBACK করা যায়; MySQL-এ DROP সঙ্গে সঙ্গে স্থায়ী হয়। DROP TABLE IF EXISTS না-থাকা টেবিলে ত্রুটি দেয় না। আর অস্থায়ী টেবিল (CREATE TEMP TABLE) শুধু এই সংযোগের জন্য বাঁচে, সংযোগ বন্ধ হলেই উধাও:

-- customers আগেই আছে: IF NOT EXISTS থাকায় ত্রুটি নয়, কিছুই হয় না
CREATE TABLE IF NOT EXISTS customers (x INTEGER);
SELECT COUNT(*) AS n FROM customers;

-- নেই এমন টেবিল মুছতে চাইলেও ত্রুটি নয়
DROP TABLE IF EXISTS old_customers;

-- শুধু এই সংযোগের জন্য: একটি কেনাকাটার ঝুড়ি
CREATE TEMP TABLE cart (product_id INTEGER, qty INTEGER);
INSERT INTO cart VALUES (101, 1), (108, 3);
SELECT * FROM cart;
SELECT type, name FROM sqlite_temp_schema;

প্রথমটি “✓ CREATE TABLE” দেখায়, কিন্তু আসলে কিছুই বানায়নি — পরের SELECT-এ n = 8, অর্থাৎ পুরোনো customers আর তার ৮ জন গ্রাহক অক্ষত; (x INTEGER) কাঠামোটি উপেক্ষিত। DROP TABLE IF EXISTS-ও “✓ DROP TABLE”. এরপর cart বানানো, ২টি সারি ঢোকানো, আর SELECT-এ 101 | 1, 108 | 3. শেষ ফলে table | cart: SQLite অস্থায়ী টেবিল রাখে আলাদা “temp” ডেটাবেসে, মূল ফাইলে নয়। এই db-shop-এ DROP TABLE customers; চালিয়ে দেখুন — FOREIGN KEY constraint failed. orders টেবিল customers-এর দিকে আঙুল তুলে আছে, আর foreign key চালু থাকলে SQLite DROP-এর আগে সব সারি মোছার চেষ্টা করে, যা foreign key ভাঙে।

২ · সংখ্যার টাইপ — পূর্ণসংখ্যা, টাকা আর floating-এর ফাঁদ

ডেটা টাইপ হলো কলামের domain-এর মোটা দাগের সীমানা (পর্ব ৫): কোন ধরনের মান আসতে পারে, কত বড়, কত জায়গা লাগবে, কীভাবে তুলনা ও সাজানো হবে। “10” আর “9” লেখা হিসেবে সাজালে “10” আগে আসে (‘1’ < ‘9’), সংখ্যা হিসেবে পরে — তাই টাইপ শুধু জায়গার প্রশ্ন নয়, অর্থেরও।

পূর্ণসংখ্যা: SMALLINT, INTEGER, BIGINT

টাইপজায়গাসীমা (প্রায়)কোথায় মানায়
SMALLINT২ বাইট−৩২,৭৬৮ থেকে ৩২,৭৬৭বছর, ছোট গণনা
INTEGER / INT৪ বাইটপ্রায় ±২১৪ কোটিস্টক, পরিমাণ, মাঝারি id
BIGINT৮ বাইটপ্রায় ±৯.২২ × ১০১৮বড় সিস্টেমের id, লেনদেন নম্বর, পয়সায় টাকা

৪ বাইটের INTEGER-এর সর্বোচ্চ মান 2,147,483,647. শুনতে বিশাল, কিন্তু একটি জনপ্রিয় অ্যাপের লেনদেন বা লগ-টেবিলের id কয়েক বছরে এটি পার হতে পারে — তখন নতুন সারি ঢোকা বন্ধ, আর কোটি সারির টেবিলে BIGINT-এ বদলানো ঘণ্টার কাজ। তাই বড় হতে পারে এমন id শুরু থেকেই BIGINT. SQLite-এ এই ভাগ নেই: তার INTEGER মানের আকার দেখে ১ থেকে ৮ বাইটে জমা হয়।

টাকা: DECIMAL / NUMERIC(p, s)

DECIMAL(p, s) (একই জিনিস NUMERIC) একটি নির্দিষ্ট দশমিকের সংখ্যা: মোট p অঙ্ক, তার মধ্যে s অঙ্ক দশমিকের পরে। DECIMAL(12,2) রাখে 9,999,999,999.99 পর্যন্ত, আর প্রতিটি পয়সা নিখুঁতভাবে। কেন এটি জরুরি, দেখুন floating point (REAL, DOUBLE PRECISION, FLOAT) কী করে। এগুলো দশমিক নয়, দুই-ভিত্তিক ভগ্নাংশে সংখ্যা রাখে, আর 0.1 দুই-ভিত্তিতে ঠিক লেখা যায় না — যেমন ১/৩ দশমিকে 0.333… হয়ে চলতেই থাকে। SQLite-এ SELECT 0.1 + 0.2, 0.1 + 0.2 = 0.3; চালালে আসে:

0.1 + 0.2             0.1 + 0.2 = 0.3
0.30000000000000004   0

অর্থাৎ ডেটাবেসের চোখে 0.1 + 0.2 আর 0.3 সমান নয়। লাখো লেনদেনে এই ক্ষুদ্র ভুল জমে হিসাব মেলে না, আর WHERE amount = 0.3 সারি খুঁজে পায় না। নিয়ম তাই সোজা:

টাকা, ওজনে বিক্রি, সুদের হার → DECIMAL(p,s) বা পূর্ণসংখ্যা পয়সায় (৳১১৫০.৫০ = 115050)
মাপজোখ, বৈজ্ঞানিক মান, GPS → REAL / DOUBLE PRECISION

SQLite-এ সত্যিকারের DECIMAL নেই (অংশ ৪-এ দেখবেন DECIMAL(10,2) লিখলেও মান REAL হিসেবে জমা হতে পারে), তাই SQLite-এ টাকা রাখার প্রচলিত উপায় পয়সায় INTEGER. db-shop সরলতার জন্য দাম REAL-এ রেখেছে, কারণ সব দাম পূর্ণ টাকা — বাস্তব অ্যাপে তা করবেন না।

৩ · লেখা, তারিখ আর বাকি টাইপ — পাঁচ DBMS পাশাপাশি

CHAR(n), VARCHAR(n) আর TEXT

  • CHAR(n) — ঠিক n অক্ষর; ছোট মান দিলে ডান দিকে ফাঁকা জায়গা বসে। CHAR(2)-এ দেশের কোড 'BD' মানায়, নামের জন্য নয়।
  • VARCHAR(n) — সর্বোচ্চ n অক্ষর, যতটুকু দরকার ততটুকু জায়গা। সীমা নিজেই একটি নিয়ম: VARCHAR(11)-এ ১২ অক্ষরের ফোন নম্বর ঢুকবে না (PostgreSQL ত্রুটি দেয়; MySQL strict mode-এ ত্রুটি, পুরোনো non-strict mode-এ চুপচাপ কেটে ফেলত)।
  • TEXT — দৈর্ঘ্যের সীমাহীন (বা খুব বড় সীমার) লেখা। PostgreSQL-এ TEXT আর VARCHAR(n)-এর গতি একই; পার্থক্য শুধু সীমা যাচাইয়ে। SQLite VARCHAR(50) লিখলে মেনে নেয়, কিন্তু ৫০-এর সীমা যাচাই করে না।

বাংলা লেখা — Unicode আর utf8mb4

বাংলা অক্ষর Unicode-এর U+0980–U+09FF অংশে। আজকের প্রচলিত এনকোডিং UTF-8-এ প্রতিটি বাংলা অক্ষর (কার-চিহ্নসহ প্রতিটি code point) ৩ বাইট, ইংরেজি অক্ষর ১ বাইট, আর অনেক emoji ৪ বাইট। SQLite-এ SELECT length('বাংলাদেশ'), length(CAST('বাংলাদেশ' AS BLOB)); দেয় 8 | 24 — ৮টি code point (ব, া, ং, ল, া, দ, ে, শ), ২৪ বাইট। “n অক্ষর” তাই DBMS ভেদে আলাদা জিনিস:

  • MySQL: পুরোনো utf8 আসলে utf8mb3 — প্রতি অক্ষরে সর্বোচ্চ ৩ বাইট। বাংলা চলে, কিন্তু ৪ বাইটের emoji (😀) ঢোকাতে গেলে ত্রুটি বা প্রশ্নবোধক চিহ্ন। সমাধান utf8mb4, যা MySQL 8.0 থেকে ডিফল্ট। পুরোনো সার্ভারে টেবিল বানাতে লিখুন ... ) CHARACTER SET utf8mb4;
  • Oracle: VARCHAR2(10) ডিফল্টভাবে প্রায়ই ১০ বাইট বোঝায় (সেটিং-নির্ভর) — মাত্র ৩টি বাংলা অক্ষর! VARCHAR2(10 CHAR) লিখলে ১০ অক্ষর।
  • SQL Server: বাংলার জন্য ঐতিহ্যগতভাবে NVARCHAR, আর লেখার আগে N: N'রহিম'. N ছাড়া লিখলে বাংলা প্রশ্নবোধক চিহ্নে বদলে যেতে পারে।
  • PostgreSQL আর SQLite: ডেটাবেস UTF-8 হলে VARCHAR(n) n অক্ষর গোনে, বাড়তি কিছু লাগে না।

তারিখ আর সময়

DATE (2025-10-20), TIME (14:30:00), আর তারিখ-সময় একসাথে TIMESTAMP. সবচেয়ে বড় প্রশ্ন time zone. ঢাকার সময় UTC-এর চেয়ে ৬ ঘণ্টা এগিয়ে। “2025-10-20 10:00” লেখা থাকলে সেটি ঢাকার সকাল দশটা, না লন্ডনের? PostgreSQL-এর TIMESTAMPTZ (timestamp with time zone) ঢোকার সময় মানটিকে UTC-তে রূপান্তর করে রাখে, আর দেখানোর সময় সংযোগের time zone-এ দেখায় — একাধিক দেশে চলা অ্যাপে এটিই নিরাপদ। MySQL-এর TIMESTAMP-ও UTC-তে রাখে, কিন্তু সীমা ২০৩৮ সালের ১৯ জানুয়ারি পর্যন্ত; DATETIME-এ time zone নেই। Oracle-এর DATE-এ সেকেন্ড পর্যন্ত সময়ও থাকে — নাম দেখে ভুল বুঝবেন না।

SQLite-এ তারিখের কোনো টাইপই নেই। প্রচলিত উপায় ISO 8601 লেখা: '2025-10-20' বা '2025-10-20 14:30:00'. এই ক্রমে (বছর-মাস-দিন) লিখলে লেখা হিসেবে সাজালেও তারিখের ক্রম ঠিক থাকে, আর SQLite-এর date(), strftime() ফাংশন এটি বোঝে (পর্ব ১৭)। '20/10/2025' লিখলে সাজানো আর তুলনা দুটিই ভুল হবে।

Boolean, BLOB, JSON, UUID আর ENUM

  • Boolean: PostgreSQL-এ আসল BOOLEAN. MySQL-এ BOOLEAN আসলে TINYINT(1), SQL Server-এ BIT, SQLite-এ 0/1 পূর্ণসংখ্যা (TRUE লিখলে 1 জমা হয়)। Oracle-এ SQL-এর BOOLEAN এসেছে সাম্প্রতিক 23ai সংস্করণে; আগে NUMBER(1) বা CHAR(1).
  • BLOB / binary: ছবি, PDF, এনক্রিপ্ট করা বাইট। PostgreSQL-এ BYTEA, SQL Server-এ VARBINARY(MAX). বড় ফাইল সাধারণত ডিস্ক বা cloud storage-এ রেখে ডেটাবেসে শুধু তার পথ রাখা হয়।
  • JSON: PostgreSQL-এ JSON আর ভেতরে ভেঙে রাখা দ্রুততর JSONB; MySQL 5.7 থেকে JSON; SQLite-এ লেখা হিসেবে রেখে JSON ফাংশন দিয়ে পড়া। পর্ব ৩০-এ বিস্তারিত।
  • UUID: PostgreSQL-এ নিজস্ব UUID টাইপ (১৬ বাইট), SQL Server-এ UNIQUEIDENTIFIER, MySQL-এ সাধারণত BINARY(16) বা CHAR(36).
  • ENUM বনাম CHECK: “status চারটি শব্দের একটি” — MySQL-এ ENUM('pending','shipped',…), PostgreSQL-এ CREATE TYPE … AS ENUM. Portable উপায় CHECK: CHECK (status IN ('pending', 'shipped', 'delivered', 'cancelled')), যা db-shop-এর orders-এ আছে। তালিকা প্রায়ই বদলালে আলাদা lookup টেবিল আর foreign key (যেমন categories) সবচেয়ে নমনীয়।
প্রয়োজনSQLitePostgreSQLMySQLSQL ServerOracle
পূর্ণসংখ্যাINTEGERINTEGER, BIGINTINT, BIGINTINT, BIGINTNUMBER(10), NUMBER(19)
টাকা (নিখুঁত দশমিক)INTEGER (পয়সায়)NUMERIC(12,2)DECIMAL(12,2)DECIMAL(12,2)NUMBER(12,2)
Floating pointREAL (৮ বাইট)REAL, DOUBLE PRECISIONFLOAT, DOUBLEREAL, FLOATBINARY_DOUBLE
লেখা (বাংলাসহ)TEXTVARCHAR(n), TEXTVARCHAR(n), TEXT + utf8mb4NVARCHAR(n), NVARCHAR(MAX)VARCHAR2(n CHAR), CLOB
তারিখTEXT 'YYYY-MM-DD'DATEDATEDATEDATE (সময়সহ)
তারিখ-সময়TEXT ISO 8601 (UTC)TIMESTAMP, TIMESTAMPTZDATETIME, TIMESTAMPDATETIME2, DATETIMEOFFSETTIMESTAMP [WITH TIME ZONE]
BooleanINTEGER 0/1BOOLEANBOOLEAN = TINYINT(1)BITBOOLEAN (23ai), আগে NUMBER(1)
BinaryBLOBBYTEABLOB, VARBINARYVARBINARY(MAX)BLOB, RAW
JSONTEXT + json ফাংশনJSON, JSONBJSONNVARCHAR(MAX) + JSON ফাংশনJSON (21c থেকে)
UUIDTEXT বা BLOBUUIDBINARY(16)UNIQUEIDENTIFIERRAW(16)
মানের তালিকাCHECKENUM টাইপ বা CHECKENUM বা CHECKCHECKCHECK

টেবিলটি মুখস্থের জন্য নয়। শিক্ষাটি হলো: SQL standard থাকলেও টাইপের নাম আর আচরণ DBMS ভেদে আলাদা, তাই এক সিস্টেম থেকে আরেকটিতে schema সরানোর সময় প্রতিটি টাইপ মিলিয়ে নিতে হয় (পর্ব ৩১)। SQL Server আর অন্যদের নতুন সংস্করণে টাইপ যোগ হচ্ছে, তাই নিজের সংস্করণের ডকুমেন্টেশন দেখে নিন।

৪ · SQLite-এর type affinity আর STRICT টেবিল

এখন এমন একটি বিষয়, যা শুধু SQLite-এর — অন্য কোনো বড় DBMS-এ এমন হয় না। PostgreSQL-এ INTEGER কলামে 'abc' ঢোকাতে গেলে সঙ্গে সঙ্গে ত্রুটি। SQLite-এর সাধারণ টেবিলে তা ঢুকে যায়! কারণ SQLite dynamic typing ব্যবহার করে: টাইপ থাকে প্রতিটি মানের সাথে, কলামের সাথে নয়। কলামের ঘোষিত টাইপ শুধু একটি “পছন্দ” — একে বলে type affinity. মান ঢোকার সময় SQLite সেটিকে পছন্দের টাইপে বদলানোর চেষ্টা করে; ক্ষতি ছাড়া বদলানো গেলে বদলায়, না গেলে যেমন আছে তেমনই রাখে।

কোন কলামের affinity কী হবে, তা ঠিক হয় ঘোষিত টাইপের নামের ভেতরে কোন অক্ষরগুচ্ছ আছে তা দেখে — পাঁচটি নিয়ম, এই ক্রমেই। নিচে '007' লেখাটি বিভিন্ন কলামে ঢোকালে কী হয়, দেখুন:

affinity-র পাঁচ নিয়ম (ক্রমানুসারে)লাল = যে নিয়ম খাটল

            
একটি কলাম যাচাই
ঘোষিত টাইপ
affinity
'007' ঢোকালে জমা হয়
  1. পাঁচটি নিয়ম, ক্রম জরুরি। SQLite ঘোষিত টাইপের নামটি বাঁ থেকে ডানে পড়ে না, শুধু দেখে কোন অক্ষরগুচ্ছ আছে। প্রথম যে নিয়ম মেলে, সেটিই চূড়ান্ত। নিয়ম ঠিক হলে প্রতিটি নতুন মান সেই affinity-তে বদলানোর চেষ্টা হয়।
  2. INTEGER → নিয়ম ১. নামে 'INT' আছে, affinity INTEGER. '007' লেখাটি দেখতে পূর্ণসংখ্যার মতো, তাই বদলে 7 জমা হলো — সামনের দুটি শূন্য হারাল।
  3. VARCHAR(50) → নিয়ম ২. 'CHAR' আছে, তাই TEXT. '007' লেখা হিসেবেই থাকল। (৫০-এর সীমা SQLite যাচাই করে না।) ফোন নম্বর, পোস্ট কোড, NID-এর মতো “সংখ্যার মতো দেখতে লেখা” এমন কলামেই রাখুন।
  4. টাইপ নেই → নিয়ম ৩. CREATE TABLE t (note) — টাইপ না লিখলে BLOB affinity, যার মানে “কোনো রূপান্তর নয়”। যা দেবেন, ঠিক তা-ই জমা হবে।
  5. DOUBLE → নিয়ম ৪. 'DOUB' আছে, তাই REAL. '007' হলো 7.0 — একটি floating point সংখ্যা।
  6. DECIMAL(10,2) → নিয়ম ৫. কোনো চেনা অক্ষরগুচ্ছ নেই, তাই NUMERIC. পূর্ণসংখ্যা হলে INTEGER, না হলে REAL হিসেবে জমা হয়: '007' → 7, আর '12.50' → 12.5 (REAL). অর্থাৎ SQLite-এ DECIMAL লিখলেও টাকা floating point হয়ে যেতে পারে।
  7. “FLOATING POINT” → নিয়ম ১ (চমক!). POINT শব্দে 'INT' লুকিয়ে আছে, আর নিয়ম ১ আগে যাচাই হয়। তাই affinity INTEGER, REAL নয়। SQLite-এর নিজের ডকুমেন্টেশনেই এই উদাহরণ দেওয়া আছে।
  8. STRING → নিয়ম ৫ (দ্বিতীয় চমক). “STRING” শুনে লেখা মনে হয়, কিন্তু নামে CHAR/TEXT নেই, তাই NUMERIC. '007' হয়ে গেল 7. অন্য ভাষার অভ্যাসে phone STRING লিখলে ফোন নম্বরের শুরুর 0 নিঃশব্দে উধাও।

এবার আসল SQLite-এ দেখুন। টেবিলটি একটি Excel ফাইল থেকে স্টক আমদানির মতো — যেখানে কেউ পরিমাণের ঘরে “বারো” লিখেছেন, দামের ঘরে “N/A”. typeof() ফাংশন প্রতিটি মানের আসল টাইপ বলে:

-- সাধারণ (non-STRICT) টেবিল: note কলামে টাইপই নেই
CREATE TABLE stock_loose (qty INTEGER, price REAL, code TEXT, note);
INSERT INTO stock_loose VALUES ('12', '450.00', 0172, '12');
INSERT INTO stock_loose VALUES ('বারো', 'N/A', 'A-7', 12);
INSERT INTO stock_loose VALUES (3.0, 99, 3.5, 3.0);
SELECT qty, typeof(qty), price, typeof(price),
       code, typeof(code), note, typeof(note)
FROM stock_loose;

তিনটি INSERT-ই সফল — কোনো ত্রুটি নেই! ফলের তিন সারি:

  • 12 | integer | 450 | real | 172 | text | 12 | text — '12' বদলে পূর্ণসংখ্যা, '450.00' বদলে REAL (পাতাটি 450.0-কে 450 দেখায়, কিন্তু typeof বলছে real). কিন্তু code-এ দেওয়া হয়েছিল উদ্ধৃতিচিহ্ন ছাড়া 0172, যা SQL-এর কাছে সংখ্যা 172; TEXT কলামে সেটি '172' লেখা হলো — শুরুর 0 আগেই হারিয়েছে। note-এ (BLOB affinity) '12' লেখাই থাকল।
  • বারো | text | N/A | text | A-7 | text | 12 | integer — “বারো” আর “N/A” সংখ্যা নয়, তাই যেমন ছিল তেমন TEXT হিসেবে INTEGER আর REAL কলামে বসে গেল। এখন SUM(qty) করলে SQLite “বারো”-কে 0 ধরবে, আর হিসাব ভুল হবে নিঃশব্দে।
  • 3 | integer | 99 | real | 3.5 | text | 3 | real — 3.0 ক্ষতি ছাড়াই 3 হতে পারে, তাই INTEGER কলামে পূর্ণসংখ্যা 3; কিন্তু টাইপহীন note কলামে সেটি REAL-ই থাকল (দেখায় 3, typeof real). TEXT কলামে 3.5 হলো '3.5'.

নমনীয়তার এই দাম এড়াতে SQLite 3.37.0 (২০২১) থেকে STRICT টেবিল এসেছে: টেবিলের শেষে STRICT লিখলে কলামের টাইপ হতে হবে INT, INTEGER, REAL, TEXT, BLOB বা ANY-এর একটি, আর ক্ষতি ছাড়া বদলানো না গেলে মান ফিরিয়ে দেওয়া হয়। একই আমদানি এবার STRICT টেবিলে:

CREATE TABLE stock_strict (qty INTEGER, price REAL, code TEXT) STRICT;

-- ক্ষতি ছাড়া বদলানো যায়: '12' → 12, '450.00' → 450.0, 3.0 → 3
INSERT INTO stock_strict VALUES ('12', '450.00', '0172');
INSERT INTO stock_strict VALUES (3.0, 99, 'A-7');
SELECT qty, typeof(qty), price, typeof(price), code FROM stock_strict;

-- 'বারো' কোনোভাবেই পূর্ণসংখ্যা নয়: বাতিল
INSERT INTO stock_strict VALUES ('বারো', 450, 'A-8');

প্রথম দুটি INSERT সফল, SELECT-এ 12 | integer | 450 | real | 0172 আর 3 | integer | 99 | real | A-7 — এবার code উদ্ধৃতিচিহ্নসহ '0172' দেওয়ায় শূন্যটি টিকে আছে। শেষটিতে ত্রুটি: cannot store TEXT value in INTEGER column stock_strict.qty. এটিই PostgreSQL বা SQL Server-এর মতো আচরণ। আরও দুটি জিনিস চেষ্টা করে দেখুন: qty-তে 3.5 দিলে cannot store REAL value in INTEGER column, আর STRICT টেবিলে DATE টাইপ লিখলে টেবিলই তৈরি হয় না — unknown datatype. STRICT-এ তারিখ রাখতে হয় TEXT-এ।

এই আচরণকে সর্বজনীন ভাববেন না। Type affinity আর dynamic typing শুধু SQLite-এর। PostgreSQL, MySQL (strict mode-এ, যা 5.7 থেকে ডিফল্ট), SQL Server আর Oracle টাইপ না মিললে ত্রুটি দেয়। আবার উল্টো দিকও আছে: PostgreSQL '12' লেখাকে INTEGER কলামে মেনে নেয় (লেখাটি সংখ্যায় রূপান্তর হয়), কিন্তু 'বারো' নয়। SQLite-এ নতুন প্রজেক্ট শুরু করলে STRICT টেবিল ব্যবহার করুন — ভুল ডেটা ঢোকার মুহূর্তেই ধরা পড়বে।

৫ · Constraint — কলামের পাশে নাকি টেবিলের শেষে

পর্ব ৬-এ দেখেছেন integrity constraint কী আর কেন। এখানে প্রশ্ন হলো, SQL-এ সেগুলো লেখা হয় কীভাবে। দুটি জায়গায় লেখা যায়:

  • Column constraint — কলামের সংজ্ঞার পাশেই, শুধু সেই কলামের জন্য: name TEXT NOT NULL, phone TEXT UNIQUE, stock INTEGER CHECK (stock >= 0).
  • Table constraint — সব কলামের পরে, আলাদা লাইনে; একাধিক কলাম জড়িত নিয়ম এখানেই লিখতে হয়: PRIMARY KEY (order_id, product_id), UNIQUE (product_id, customer_id), CHECK (sale_price <= price), FOREIGN KEY (…) REFERENCES ….

এক কলামের নিয়ম দুই জায়গাতেই লেখা যায় — phone TEXT UNIQUE আর শেষে UNIQUE (phone) একই। কিন্তু NOT NULL আর DEFAULT শুধু কলামের পাশে বসে। এক নজরে ছয়টি নিয়ম:

নিয়মকাজউদাহরণ
NOT NULLঘর খালি থাকবে নাname TEXT NOT NULL
DEFAULTমান না দিলে কী বসবেstock INTEGER DEFAULT 0
UNIQUEএকই মান দুবার নয় (NULL বাদে)phone TEXT UNIQUE
PRIMARY KEYUNIQUE + NOT NULL, টেবিলে একটিইcustomer_id INTEGER PRIMARY KEY
FOREIGN KEYমান অন্য টেবিলে থাকতে হবেREFERENCES customers (customer_id)
CHECKসারির ভেতরের যেকোনো শর্তCHECK (price > 0)

চাহিদার তালিকা থেকে কীভাবে একটি পূর্ণ টেবিল-সংজ্ঞা গড়ে ওঠে, ধাপে ধাপে দেখুন। প্রতিটি কলামের জন্য তিনটি প্রশ্ন: কোন টাইপ? খালি থাকতে পারে? আর কোন নিয়ম?

টেবিলের সংজ্ঞা (গড়ে উঠছে)লাল = এই ধাপে যোগ হলো

            
চাহিদা → সিদ্ধান্ত
দোকানের কথা
টাইপ · খালি? · নিয়ম
constraint কোথায়
  1. চাহিদা দিয়ে শুরু। দোকান চায় পণ্যের তালিকা। ER মডেলে (পর্ব ৭–১০) Product একটি entity, তার attribute-গুলো এখন কলাম হবে। এখনো সংজ্ঞায় কিছু নেই।
  2. প্রথমে চেনার নম্বর। প্রতিটি পণ্যের একটি স্থির, ছোট, কখনো খালি নয় এমন id — একটি surrogate primary key (পর্ব ৬)। SQLite-এ INTEGER PRIMARY KEY মান না দিলে নিজেই নম্বর বসায় (অংশ ৭)।
  3. নাম — TEXT NOT NULL. নামহীন পণ্য অর্থহীন, তাই NOT NULL. নামটি একা UNIQUE নয়, কারণ দুই শ্রেণিতে একই নাম থাকতে পারে (যেমন “Classic” নামের বই আর পোশাক)।
  4. শ্রেণি — foreign key, কিন্তু NULL চলবে. মান থাকলে categories-এ থাকতেই হবে, তাই REFERENCES. কিন্তু “পরে ঠিক হতে পারে” বলায় NOT NULL দিইনি।
  5. দাম — টাইপ আর নিয়ম দুটিই. টাকা বলে DECIMAL(10,2) (অংশ ২)। শূন্য বা ঋণাত্মক দাম চলবে না: CHECK. নিয়মটির নাম দিলাম chk_price, যাতে ভাঙলে ত্রুটির বার্তায় নামটি আসে আর পরে ALTER TABLE দিয়ে নাম ধরে সরানো যায় (পর্ব ২৩)। (db-shop SQLite-এ চলে বলে সেখানে price REAL.)
  6. স্টক — DEFAULT আর CHECK. নতুন পণ্য ঢোকানোর সময় স্টক না বললে 0 বসবে। কিন্তু DEFAULT আর NOT NULL আলাদা কাজ করে: DEFAULT শুধু কলাম বাদ দিলে খাটে; কেউ ইচ্ছে করে NULL দিলে NOT NULL আটকায়।
  7. দুই কলামের নিয়ম — টেবিলের শেষে. “একই শ্রেণিতে একই নাম নয়” নিয়মটি name আর category_id দুটিকে একসাথে দেখে, তাই কোনো একটি কলামের পাশে বসানো যায় না — table constraint. সংজ্ঞা সম্পূর্ণ। (db-shop-এ এই শেষ নিয়মটি নেই; এটি ডিজাইনের একটি বাড়তি সিদ্ধান্ত।)

নিয়ম ভেঙে দেখুন — NOT NULL, UNIQUE আর PRIMARY KEY

এই এডিটর নিজের একটি ছোট টেবিল বানিয়ে নেয়: sellers (seller_id, shop_name, phone, trade_license), যেখানে shop_name NOT NULL, phone NOT NULL UNIQUE, trade_license UNIQUE, আর আগে থেকে একজন বিক্রেতা আছেন — (1, 'Rahim Store', '01711000001', 'TL-1001'). রানার প্রথম ত্রুটিতেই থামে, তাই মন্তব্য করা লাইনগুলো একবারে একটি করে চালু করে দেখুন:

-- বৈধ: সব নিয়ম মানছে (trade_license অজানা, NULL চলে)
INSERT INTO sellers VALUES (2, 'Karim Traders', '01811000002', NULL);

-- নিচের যেকোনো একটির সামনের -- মুছে চালান:
-- INSERT INTO sellers VALUES (2, 'Mitu Fashion', '01911000003', NULL);
-- INSERT INTO sellers VALUES (3, 'Mitu Fashion', '01711000001', NULL);
-- INSERT INTO sellers VALUES (3, 'Mitu Fashion', '01911000003', 'TL-1001');

-- দোকানের নাম নেই: NOT NULL ভাঙে
INSERT INTO sellers VALUES (3, NULL, '01911000003', 'TL-1003');

যেমন আছে তেমন চালালে প্রথমটি “✓ INSERT — ১টি সারি বদলেছে”, তারপর NOT NULL constraint failed: sellers.shop_name. মন্তব্য করা তিনটি লাইনের বার্তা: id 2 আবার দিলে UNIQUE constraint failed: sellers.seller_id (primary key-র লঙ্ঘনকেও SQLite “UNIQUE” বলে), Rahim Store-এর ফোন নম্বর দিলে UNIQUE constraint failed: sellers.phone, আর একই trade license দিলে UNIQUE constraint failed: sellers.trade_license. প্রতিটি বার্তায় টেবিল আর কলামের নাম — কোন নিয়ম ভাঙল, তা ঠিক কোথায়, DBMS নিজেই বলে দেয়।

DEFAULT কাজের সময়

DEFAULT-এ ধ্রুব মান ('pending', 0) বা কিছু বিশেষ ফাংশন বসে, যেমন CURRENT_TIMESTAMP — সারি ঢোকার মুহূর্তের তারিখ-সময়। এই এডিটর একটি নতুন অর্ডার-টেবিল বানায়, যেখানে status, qty আর created_at-এর ডিফল্ট আছে:

CREATE TABLE orders_new (
  order_id    INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL,
  status      TEXT NOT NULL DEFAULT 'pending',
  qty         INTEGER NOT NULL DEFAULT 1,
  created_at  TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- শুধু customer_id দিলাম, বাকি সব ডিফল্ট থেকে
INSERT INTO orders_new (customer_id) VALUES (3);
INSERT INTO orders_new (customer_id, status, qty) VALUES (5, 'shipped', 2);
SELECT * FROM orders_new;
-- ইচ্ছে করে NULL দিলে DEFAULT খাটে না
INSERT INTO orders_new (customer_id, status) VALUES (8, NULL);

SELECT-এ দুটি সারি: 1 | 3 | pending | 1 | … আর 2 | 5 | shipped | 2 | …. order_id নিজে থেকে 1, 2 হয়েছে, প্রথম সারিতে status আর qty এসেছে ডিফল্ট থেকে, দ্বিতীয় সারিতে দেওয়া মান ডিফল্টকে হারিয়েছে। created_at-এ আসে এই মুহূর্তের সময়, যেমন 2026-10-25 09:09:12 — আপনি যখন চালাবেন তখনকার। লক্ষ করুন, এটি UTC, ঢাকার ঘড়ির চেয়ে ৬ ঘণ্টা পিছিয়ে; SQLite-এর CURRENT_TIMESTAMP সবসময় UTC দেয়। শেষ INSERT-এ ত্রুটি: NOT NULL constraint failed: orders_new.status. DEFAULT শুধু তখনই বসে, যখন কলামটি INSERT-এ একেবারে বাদ থাকে; স্পষ্ট NULL দিলে সেটি “একটি মান”, আর NOT NULL তা আটকায়। (MySQL-এ DATETIME DEFAULT CURRENT_TIMESTAMP, PostgreSQL-এ TIMESTAMPTZ DEFAULT now().)

৬ · CHECK, named constraint আর FOREIGN KEY

CHECK একটি সারির ভেতরের যেকোনো শর্ত লিখতে দেয়। কলামের পাশে লিখলে সাধারণত শুধু সেই কলামের শর্ত; টেবিলের শেষে লিখলে একাধিক কলাম তুলনা করা যায়, যেমন “ছাড়ের দাম আসল দামের বেশি নয়”। CONSTRAINT নাম দিয়ে নাম দিলে ত্রুটির বার্তায় সেই নাম আসে — বড় schema-য় “কোন নিয়ম ভাঙল” খোঁজার সময় এটি অনেক সময় বাঁচায়:

CREATE TABLE price_list (
  item       TEXT NOT NULL PRIMARY KEY,
  price      INTEGER NOT NULL CONSTRAINT chk_price CHECK (price > 0),
  sale_price INTEGER,
  CONSTRAINT chk_sale CHECK (sale_price <= price)    -- table-level
);
INSERT INTO price_list VALUES ('Panjabi', 2200, 1990);
INSERT INTO price_list VALUES ('Saree', 4500, NULL);    -- ছাড় নেই
-- INSERT INTO price_list VALUES ('Lungi', 0, NULL);
INSERT INTO price_list VALUES ('Shawl', 1500, 1800);   -- ছাড়ে দাম বেশি!

Panjabi আর Saree দুটিই “✓ INSERT — ১টি সারি বদলেছে”। Saree-র sale_price NULL, তাই NULL <= 4500-এর ফল UNKNOWN — আর CHECK শুধু FALSE হলে আটকায়, UNKNOWN হলে নয় (পর্ব ১৬-এর তিন-মানের যুক্তি)। Shawl-এ ত্রুটি: CHECK constraint failed: chk_sale — বার্তায় নিয়মের নাম। মন্তব্য করা Lungi লাইনটি চালু করলে বার্তা হবে CHECK constraint failed: chk_price. নাম না দিলে SQLite শর্তটিই দেখায় (db-shop-এ যেমন CHECK constraint failed: stock >= 0), আর PostgreSQL নিজে একটি নাম বানায়, যেমন price_list_price_check.

FOREIGN KEY — কলামে বা টেবিলের শেষে

পর্ব ৬-এ foreign key-র অর্থ আর ON DELETE / ON UPDATE-এর referential action (CASCADE, SET NULL, RESTRICT) দেখেছেন। লেখার দুটি রূপ: কলামের পাশে customer_id INTEGER REFERENCES customers (customer_id), অথবা টেবিলের শেষে FOREIGN KEY (customer_id) REFERENCES customers (customer_id) ON DELETE … — composite foreign key-র জন্য দ্বিতীয়টিই একমাত্র উপায়। db-shop-এ একটি নতুন reviews টেবিল বানাই, যেখানে সব ধরনের table constraint একসাথে:

CREATE TABLE reviews (
  review_id   INTEGER PRIMARY KEY,
  product_id  INTEGER NOT NULL,
  customer_id INTEGER NOT NULL,
  stars       INTEGER NOT NULL CHECK (stars BETWEEN 1 AND 5),
  UNIQUE (product_id, customer_id),          -- এক পণ্যে একজনের একটি রিভিউ
  FOREIGN KEY (product_id) REFERENCES products (product_id) ON DELETE CASCADE,
  FOREIGN KEY (customer_id) REFERENCES customers (customer_id)
);
-- Karima Begum (2) DSA Book (104)-কে ৫ তারা দিলেন
INSERT INTO reviews VALUES (1, 104, 2, 5);
-- গ্রাহক 99 নেই
INSERT INTO reviews VALUES (2, 104, 99, 4);

“✓ CREATE TABLE”, প্রথম রিভিউ “✓ INSERT — ১টি সারি বদলেছে”, তারপর FOREIGN KEY constraint failed — customers-এ 99 নম্বর কেউ নেই। শেষ লাইনে 99-এর বদলে 2 দিন: এবার বার্তা UNIQUE constraint failed: reviews.product_id, reviews.customer_id, কারণ Karima এই বইয়ে আগেই রিভিউ দিয়েছেন। stars-এ 6 দিলে CHECK ভাঙবে। ON DELETE CASCADE রাখা হয়েছে পণ্যের দিকে (পণ্য তালিকা থেকে উঠে গেলে তার রিভিউর মানে নেই), কিন্তু গ্রাহকের দিকে নয় — ডিফল্ট NO ACTION, তাই রিভিউ আছে এমন গ্রাহক মোছা যাবে না।

একটি INSERT-এ DBMS কী কী যাচাই করে

সব নিয়ম একসাথে দেখি। নিচে db-shop-এর products টেবিলে (price REAL, stock DEFAULT 0) একটি নতুন পণ্য ঢুকছে: INSERT INTO products (product_id, name, price) VALUES (111, 'Lungi', '350'); — খেয়াল করুন, দাম দেওয়া হয়েছে লেখা হিসেবে।

  1. INSERT আসে৩টি মানবাকি কলাম?
  2. DEFAULTstock = 0বাদ পড়া কলাম
  3. টাইপ'350' → 350.0affinity
  4. NOT NULLname, priceখালি ঘর?
  5. CHECKprice > 0সারির শর্ত
  6. UNIQUE / PK111 আছে?index খোঁজা
  7. FOREIGN KEYcategory NULLparent আছে?
  8. লেখা১টি সারিগৃহীত
সারিটির অবস্থা
(id, name, category, price, stock)
ভাঙলে SQLite বলে
  1. INSERT আসে। কলামের তালিকায় শুধু product_id, name আর price. category_id আর stock-এর মান দেওয়া হয়নি — DBMS-কে আগে ঠিক করতে হবে ওই ঘরে কী বসবে।
  2. DEFAULT বসে। stock-এর DEFAULT 0, তাই 0. category_id-এর কোনো DEFAULT নেই, তাই NULL. এই ধাপ সবার আগে, কারণ পরের যাচাইগুলো পুরো সারির ওপর।
  3. টাইপ রূপান্তর। price কলাম REAL, আর '350' লেখাটি ক্ষতি ছাড়াই সংখ্যা হতে পারে — জমা হবে 350.0. STRICT টেবিল বা অন্য DBMS-এ এখানেই অমিল টাইপ ধরা পড়ত।
  4. NOT NULL. name, price আর stock তিনটিতেই মান আছে। name বাদ দিলে এখানেই থামত: NOT NULL constraint failed: products.name.
  5. CHECK. price > 0 (350 > 0) আর stock >= 0 (0 >= 0) দুটিই TRUE. দাম 0 হলে বার্তায় শর্তটিই দেখা যেত, কারণ db-shop-এর CHECK-এর নাম নেই।
  6. UNIQUE আর PRIMARY KEY. এই যাচাই index খুঁজে হয়: 111 নম্বর পণ্য আগে নেই। 101 দিলে UNIQUE constraint failed: products.product_id.
  7. FOREIGN KEY. category_id NULL, আর NULL কোনো কিছুর দিকে আঙুল তোলে না — যাচাই লাগে না। 9 দিলে categories-এ খুঁজে না পেয়ে FOREIGN KEY constraint failed.
  8. সারি লেখা হলো। সব যাচাই পার, এখন products-এ ১১টি পণ্য। যেকোনো একটি ধাপে আটকালে পুরো স্টেটমেন্ট বাতিল হতো, টেবিল যেমন ছিল তেমনই থাকত। (একটি সারি একাধিক নিয়ম ভাঙলে SQLite এই ক্রমে প্রথম ভাঙা নিয়মটি জানায় — যেমন NOT NULL আর CHECK দুটিই ভাঙলে NOT NULL. অন্য DBMS-এর ক্রম আলাদা হতে পারে; standard শুধু বলে, একটি ভাঙলেই বাতিল।)

৭ · Auto-increment id — প্রতিটি DBMS-এর নিজের নাম

Surrogate primary key-র মান সাধারণত DBMS নিজেই দেয় — ব্যাংকের টোকেন মেশিনের মতো, প্রত্যেকে পরের নম্বর পান। কিন্তু লেখার নিয়ম প্রতিটি সিস্টেমে আলাদা, আর এখানে SQL-এর উপভাষাগুলোর পার্থক্য সবচেয়ে চোখে পড়ে।

SQLite: প্রতিটি সাধারণ টেবিলে একটি লুকানো ৬৪-বিটের rowid থাকে। INTEGER PRIMARY KEY লিখলে কলামটি ঠিক এই rowid-এর আরেক নাম হয়ে যায়; মান না দিলে (বা NULL দিলে) SQLite সাধারণত বর্তমান সর্বোচ্চ rowid + 1 বসায়। বানান হুবহু INTEGER হতে হবে — INT PRIMARY KEY লিখলে এটি rowid-এর নাম হয় না, সাধারণ কলাম থাকে। আর AUTOINCREMENT যোগ করলে একটি বাড়তি নিশ্চয়তা আসে: মুছে ফেলা সারির নম্বর আর কখনো ফিরে আসে না। পার্থক্যটি দেখুন:

CREATE TABLE a (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE b (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT);
INSERT INTO a (name) VALUES ('Rahim'), ('Karima'), ('Sabbir');
INSERT INTO b (name) VALUES ('Rahim'), ('Karima'), ('Sabbir');
-- সবচেয়ে বড় id (3) মুছে নতুন একজন
DELETE FROM a WHERE id = 3;
DELETE FROM b WHERE id = 3;
INSERT INTO a (name) VALUES ('Nusrat');
INSERT INTO b (name) VALUES ('Nusrat');
SELECT 'a' AS tbl, id, name FROM a
UNION ALL SELECT 'b', id, name FROM b;
SELECT * FROM sqlite_sequence;

প্রথম ফলে ছয়টি সারি: a-তে 1 Rahim, 2 Karima, 3 Nusrat; b-তে 1 Rahim, 2 Karima, 4 Nusrat. a-তে Sabbir-এর পুরোনো নম্বর 3 Nusrat পেয়ে গেলেন, কারণ মোছার পর সর্বোচ্চ id ছিল 2. b-তে 3 আর ফেরেনি। দ্বিতীয় ফল b | 4: AUTOINCREMENT টেবিলের “এ পর্যন্ত দেওয়া সর্বোচ্চ নম্বর” SQLite রাখে sqlite_sequence নামের একটি অভ্যন্তরীণ টেবিলে। নম্বর ফিরে আসা বিপজ্জনক হতে পারে: পুরোনো কোনো log, ইমেইল বা অন্য সিস্টেমে “গ্রাহক 3” লেখা থাকলে সেটি এখন অন্য মানুষকে বোঝায়। তবে SQLite-এর ডকুমেন্টেশন বলে, AUTOINCREMENT-এ বাড়তি CPU আর ডিস্ক খরচ আছে, তাই সত্যিই দরকার হলেই ব্যবহার করুন।

অন্য DBMS-এ একই টেবিল (এগুলো চালানো যায় না, শুধু তুলনার জন্য):

CREATE TABLE customers (
  customer_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name        TEXT NOT NULL,
  joined_on   TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- পুরোনো (PostgreSQL 10-এর আগের) লেখা:
-- customer_id SERIAL PRIMARY KEY
CREATE TABLE customers (
  customer_id BIGINT AUTO_INCREMENT PRIMARY KEY,
  name        VARCHAR(100) NOT NULL,
  joined_on   DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
CREATE TABLE customers (
  customer_id BIGINT IDENTITY(1,1) PRIMARY KEY,   -- শুরু 1, প্রতিবার +1
  name        NVARCHAR(100) NOT NULL,
  joined_on   DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
);
CREATE TABLE customers (
  customer_id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,  -- 12c থেকে
  name        VARCHAR2(100 CHAR) NOT NULL,
  joined_on   TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL
);

PostgreSQL-এ SERIAL আসলে কোনো টাইপ নয়, একটি সংক্ষিপ্ত রূপ: এটি একটি sequence (নম্বর বিতরণের আলাদা বস্তু) বানায় আর কলামের DEFAULT-এ nextval(…) বসায়। এতে কেউ হাতে id দিয়ে ফেললে sequence জানতে পারে না, আর পরে একই নম্বর এসে primary key-তে ধাক্কা খায়। SQL standard-এর identity column (PostgreSQL 10, ২০১৭ থেকে) এটি পরিষ্কার করে: GENERATED ALWAYS হাতে দেওয়া মান ফিরিয়ে দেয় (জরুরি দরকারে OVERRIDING SYSTEM VALUE লিখতে হয়), আর GENERATED BY DEFAULT হাতে দেওয়া মান মেনে নেয়। নতুন প্রজেক্টে identity ব্যবহার করুন।

id-তে ফাঁক থাকবেই। প্রায় কোনো DBMS-ই “১, ২, ৩ — ফাঁকহীন” প্রতিশ্রুতি দেয় না। একটি transaction নম্বর নিয়ে ROLLBACK করলে নম্বরটি ফেরত যায় না, কারণ ফেরত দিতে হলে বাকি সবাইকে অপেক্ষা করাতে হতো। তাই চালানের নম্বরের মতো আইনত ফাঁকহীন ক্রমিক নম্বর লাগলে সেটি আলাদা নিয়মে বানাতে হয়, auto-increment id দিয়ে নয়।

৮ · CREATE TABLE AS SELECT আর schema-র নামঘর

কখনো নতুন টেবিল শূন্য থেকে নয়, একটি কোয়েরির ফল থেকে বানাতে হয় — রিপোর্টের জন্য ঢাকার গ্রাহকদের আলাদা কপি, বা বড় পরিবর্তনের আগে একটি ব্যাকআপ। এর জন্য CREATE TABLE … AS SELECT (সংক্ষেপে CTAS): SELECT-এর ফলের কলাম দিয়ে কাঠামো, আর সারিগুলো দিয়ে ডেটা। কিন্তু কী কী কপি হয় না, সেটি জানা জরুরি:

-- ঢাকার গ্রাহকদের আলাদা কপি: কাঠামো + ডেটা
CREATE TABLE dhaka_customers AS
  SELECT customer_id, name, phone, joined_on
  FROM customers WHERE city = 'Dhaka';
SELECT * FROM dhaka_customers;
PRAGMA table_info(dhaka_customers);

-- শুধু কাঠামো, ডেটা নয়: কখনো সত্য হয় না এমন শর্ত
CREATE TABLE products_empty AS SELECT * FROM products WHERE 1 = 0;
SELECT COUNT(*) AS n FROM products_empty;

dhaka_customers-এ তিনটি সারি: 1 | Rahim Uddin, 3 | Sabbir Hasan, 8 | Mitu Rani (ফোন আর তারিখসহ)। PRAGMA-র ফলে আসল শিক্ষা: 0 | customer_id | INT | 0 | NULL | 0, 1 | name | TEXT | 0 | NULL | 0, 2 | phone | TEXT | 0 | NULL | 0, 3 | joined_on | TEXT | 0 | NULL | 0. সব notnull 0, pk 0 — PRIMARY KEY, NOT NULL, UNIQUE কিছুই আসেনি! এমনকি টাইপ INTEGER থেকে হয়ে গেছে INT (SQLite শুধু affinity মিলিয়ে একটি নাম বসায়), ফলে customer_id এখন আর rowid-এর নাম নয়। শেষ SELECT-এ n = 0: WHERE 1 = 0 কোনো সারি দেয় না, তাই শুধু কলামগুলো কপি হলো। প্রায় সব DBMS-এ CTAS ডেটা আর মোটামুটি টাইপ আনে, কিন্তু key, constraint, DEFAULT আর index আনে না — ব্যাকআপ টেবিল হিসেবে চলে, কাজের টেবিল হিসেবে নয়।

অন্য সিস্টেমে: PostgreSQL আর Oracle-এ একই CREATE TABLE … AS SELECT; SQL Server-এ লেখা হয় SELECT … INTO new_table FROM …. শুধু কাঠামো নিখুঁতভাবে (constraint আর index-সহ) কপি করতে PostgreSQL-এ CREATE TABLE new (LIKE old INCLUDING ALL), MySQL-এ CREATE TABLE new LIKE old.

Schema — টেবিলের নামঘর

একটি বড় প্রতিষ্ঠানের ডেটাবেসে শত শত টেবিল। হিসাব বিভাগের reports আর বিক্রয় বিভাগের reports আলাদা হলে? সমাধান namespace, যাকে SQL-এ schema বলে — একটি নামযুক্ত ফোল্ডার, যার ভেতরে টেবিল থাকে। পুরো নাম লেখা হয় schema.table:

CREATE SCHEMA sales;
CREATE TABLE sales.reports   (report_id BIGINT PRIMARY KEY, title TEXT);
CREATE TABLE finance.reports (report_id BIGINT PRIMARY KEY, amount NUMERIC(14,2));
-- schema না লিখলে কোথায় খুঁজবে, ঠিক করে search_path (ডিফল্ট public)
SET search_path TO sales, public;

PostgreSQL-এর স্তর তিনটি: ডেটাবেস → schema → টেবিল, আর নতুন টেবিল ডিফল্টভাবে public schema-য় যায়। MySQL-এ SCHEMA আর DATABASE একই জিনিস, তাই shop.customers মানে “shop ডেটাবেসের customers”. SQL Server-এ ডিফল্ট schema dbo, Oracle-এ প্রতিটি ব্যবহারকারীর নামেই একটি schema. SQLite-এ মূল ফাইলটি main, অস্থায়ী টেবিল থাকে temp-এ, আর ATTACH DATABASE 'archive.db' AS archive করে আরেকটি ফাইলকে archive.orders নামে ব্যবহার করা যায়। Schema শুধু নাম সাজায় না, অনুমতিও ভাগ করে: “বিক্রয় দল শুধু sales schema পড়তে পারবে” (পর্ব ৫১)।

আরও গভীরে: SQLite প্রতিটি সারিকে একটি “record” হিসেবে রাখে, যার শুরুতে প্রতিটি মানের জন্য একটি ছোট “serial type” সংখ্যা থাকে — এটিই বলে মানটি NULL, কত বাইটের পূর্ণসংখ্যা (১, ২, ৩, ৪, ৬ বা ৮), REAL, লেখা না BLOB. এজন্যই একটি কলামে বিভিন্ন টাইপের মান থাকতে পারে, আর 0 ও 1-এর মতো মান রাখতে কোনো বাড়তি বাইটই লাগে না (তাদের নিজস্ব serial type আছে)। PostgreSQL-এ টাইপ কলামের সাথে বাঁধা, আর বড় TEXT বা JSONB মান (মোটামুটি ২ KB-এর বেশি হলে) TOAST নামের ব্যবস্থায় সংকুচিত হয়ে বা আলাদা টেবিলে সরিয়ে রাখা হয় — তাই বড় লেখার কলাম মূল টেবিলের স্ক্যান ধীর করে না। পর্ব ৩৯-এ page আর record-এর গঠন বিস্তারিত।

৯ · AI-এর যুগে CREATE TABLE

AI-কে “একটা দোকানের টেবিল বানিয়ে দাও” বললে চমৎকার দেখতে CREATE TABLE আসে, আর সেটি চলেও। কিন্তু টাইপ আর constraint-এর ভুল প্রথম দিনে ধরা পড়ে না — পড়ে ছয় মাস পরে, যখন হিসাব মেলে না বা ফোন নম্বরের শূন্য হারিয়ে গেছে। AI প্রায়ই যা করে:

AI-এর ভুলফলকীভাবে ধরবেন
টাকা FLOAT বা REAL-এযোগফলে পয়সার গরমিলSELECT SUM(…) হাতে করা হিসাবের সাথে মেলান
ফোন বা NID INTEGER-এশুরুর 0 উধাও, ১৭ অঙ্কের NID-ও ঝুঁকিতে'01711…' ঢুকিয়ে আবার পড়ে দেখুন
একটি DBMS-এর টাইপ অন্যটিতে (SQLite-এ SERIAL, PostgreSQL-এ AUTO_INCREMENT)ত্রুটি, বা SQLite-এ নিঃশব্দে ভুল affinityলক্ষ্য DBMS-এ চালিয়ে PRAGMA / \d দেখুন
সব কলাম NULL-যোগ্য, কোনো CHECK নেইখালি নাম, ঋণাত্মক স্টকপ্রতিটি কলামের জন্য “খালি থাকতে পারে?” প্রশ্ন
সময় time zone ছাড়াঢাকা-UTC-তে ৬ ঘণ্টার গোলমালTIMESTAMPTZ বা UTC-তে রাখার নিয়ম
নেই এমন কলাম বা টেবিলের নামে foreign keyটেবিল তৈরিতেই ত্রুটি, বা SQLite-এ পরেআসল schema পাশে রেখে মিলিয়ে নিন

AI-কে schema চাইলে লক্ষ্য DBMS, টাইপের যুক্তি আর পরীক্ষার INSERT একসাথে চেয়ে নিন:

আমি PostgreSQL 16-এ একটি অনলাইন দোকানের products আর orders টেবিল বানাতে চাই। পরে একই schema SQLite-এ পরীক্ষা করব। ১. প্রতিটি কলামের টাইপ দাও আর এক লাইনে কারণ বলো — বিশেষ করে টাকা, ফোন নম্বর আর তারিখ-সময়ের ক্ষেত্রে। ২. প্রতিটি কলাম NULL হতে পারবে কি না বলো, আর কোন কলামে DEFAULT লাগবে। ৩. সব CHECK, UNIQUE আর FOREIGN KEY-এর নাম দাও (chk_…, uq_…, fk_…). ৪. id-র জন্য identity column ব্যবহার করো, SERIAL নয়। ৫. একই schema-র SQLite STRICT সংস্করণ দাও, আর কোথায় টাইপ বদলাতে হলো তা আলাদা করে লেখো। ৬. শেষে প্রতিটি constraint ভাঙার একটি করে INSERT দাও, যাতে আমি চালিয়ে দেখতে পারি।

AI-এর দেওয়া CREATE TABLE হাতে পেলে — যাচাইয়ের চেকলিস্ট

পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:

  • CHAR আর VARCHAR-এর পার্থক্য? — CHAR(n) সবসময় n অক্ষর (ফাঁকা দিয়ে ভরে), VARCHAR(n) সর্বোচ্চ n, যতটুকু দরকার ততটুকু জায়গা।
  • টাকার জন্য FLOAT কেন নয়? — দুই-ভিত্তিক floating point 0.1-এর মতো দশমিক ঠিক রাখতে পারে না; DECIMAL নিখুঁত।
  • Column আর table constraint-এর পার্থক্য? — প্রথমটি এক কলামের পাশে; একাধিক কলামের নিয়ম (composite key, দুই কলামের CHECK) টেবিলের শেষে লিখতে হয়।
  • DROP, DELETE আর TRUNCATE? — DROP টেবিলসহ মোছে (DDL); DELETE সারি মোছে, WHERE চলে (DML); TRUNCATE সব সারি দ্রুত মোছে, কাঠামো রাখে। সারি বদলানোর আদেশগুলো পর্ব ১৪-এর বিষয়।
  • DEFAULT থাকলেও NULL কীভাবে ঢোকে? — INSERT-এ স্পষ্ট NULL দিলে DEFAULT খাটে না; আটকাতে NOT NULL লাগে।

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১SQLite কেন dynamic typing বেছে নিয়েছিল, আর ২০ বছর পর STRICT টেবিল যোগ করল কেন? এটি কি ভুল স্বীকার?

SQLite লেখেন D. Richard Hipp, ২০০০ সালে, প্রথমে একটি জাহাজের ক্ষতি-নিয়ন্ত্রণ সফটওয়্যারের জন্য — যেখানে সার্ভার ছাড়া একটি ফাইলে ডেটাবেস দরকার ছিল। Hipp নিজে Tcl ভাষার জগতের মানুষ, যেখানে “সবকিছুই লেখা” আর মানের টাইপ প্রয়োজনমতো বদলায়। SQLite-এর নকশাও সেই দর্শন নিল: টাইপ থাকবে মানের সাথে, কলামের ঘোষিত টাইপ শুধু রূপান্তরের ইঙ্গিত। যুক্তি ছিল, স্ক্রিপ্টিং ভাষা থেকে আসা ডেটা যেমন আছে তেমন রাখা যায়, আর অন্য DBMS-এর CREATE TABLE প্রায় অপরিবর্তিত চালানো যায় — কারণ যেকোনো টাইপ-নামই (VARCHAR(50), DATETIME, এমনকি অজানা শব্দ) গ্রহণযোগ্য।

এই সিদ্ধান্তের সুফল বাস্তব: SQLite আজ মোবাইল ফোন, ব্রাউজার, বিমানের সফটওয়্যার — অগণিত জায়গায়, আর প্রায় যেকোনো ডেটা ফেলে দিলে সে বিরক্ত করে না। কুফলও বাস্তব: ভুল টাইপের ডেটা ঢোকার মুহূর্তে কিছু বলে না, ধরা পড়ে পরে, রিপোর্টে। PostgreSQL থেকে আসা ডেভেলপাররা বছরের পর বছর এ নিয়ে সমালোচনা করেছেন। SQLite-এর ডকুমেন্টেশন আজও dynamic typing-কে বৈশিষ্ট্য বলে, বাগ নয়।

২০২১ সালের নভেম্বরে SQLite 3.37.0-এ STRICT টেবিল আসে। লক্ষ করুন, এটি ঐচ্ছিক — ডিফল্ট বদলানো হয়নি। কারণ SQLite-এর একটি কঠোর নীতি হলো পুরোনো ফাইল আর অ্যাপের সাথে সামঞ্জস্য: কোটি কোটি ডিভাইসে চলা অ্যাপ হঠাৎ ভেঙে দেওয়া যায় না। একই কারণে foreign key আজও ডিফল্টভাবে বন্ধ, আর non-INTEGER primary key-তে NULL চলার পুরোনো বাগটিও রেখে দেওয়া হয়েছে (পর্ব ৬)।

তাই এটিকে ভুল স্বীকার না বলে বলা ভালো চাহিদার বদল: SQLite এখন শুধু অ্যাপের ভেতরের ছোট ডেটাবেস নয়, ডেটা বিশ্লেষণ, এমনকি ওয়েবসাইটের মূল ডেটাবেস হিসেবেও ব্যবহৃত হয়, যেখানে কড়া টাইপের দাবি জোরালো। দুটি দর্শনই টিকে থাকল, বাছাইয়ের ভার ব্যবহারকারীর।

মূল উপলব্ধি: টাইপ-কঠোরতা একটি trade-off — নমনীয়তা বনাম ভুল ধরার গতি। যে সিস্টেম কোটি ডিভাইসে চলে, সে ডিফল্ট বদলায় না, বিকল্প যোগ করে; আর নতুন প্রজেক্টে সেই বিকল্প (STRICT) বেছে নেওয়া আপনার দায়িত্ব।

প্র ০২ব্যাংক আর হিসাবরক্ষণ সফটওয়্যার কেন floating point এড়িয়ে চলে? DECIMAL আর “পয়সায় পূর্ণসংখ্যা”-র মধ্যে কোনটি ভালো?

আজকের প্রায় সব কম্পিউটারের floating point চলে IEEE 754 standard মেনে, যা ১৯৮৫ সালে গৃহীত হয়। এটি বিজ্ঞান আর প্রকৌশলের জন্য অসাধারণ: খুব ছোট থেকে খুব বড় সংখ্যা, দ্রুত হিসাব। কিন্তু এটি সংখ্যা রাখে দুই-ভিত্তিক ভগ্নাংশে। 0.5 (১/২) বা 0.25 ঠিক রাখা যায়, কিন্তু 0.1 বা 0.01 পারে না — দুই-ভিত্তিতে এগুলো অসীম পুনরাবৃত্ত ভগ্নাংশ। ফলে টাকার প্রতিটি পয়সাই একটি ক্ষুদ্র ভুলসহ জমা হয়।

একটি সারিতে ভুলটি চোখে পড়ে না। কিন্তু একটি ব্যাংক প্রতিদিন লাখো লেনদেনের যোগফল করে, সুদ হিসাব করে, তারপর দুই হিসাব মেলায় (reconciliation). সেখানে ০.০০০০০০০০০০০০০০৪-এর ভুলও যোগ হয়ে পয়সার গরমিল বানাতে পারে, আর WHERE balance = 0.3-এর মতো সমতার তুলনা ব্যর্থ হয়। হিসাবের জগতে “প্রায় মিলেছে” মানে মেলেনি। তাই ব্যবসার সফটওয়্যার বহু দশক ধরে দশমিক হিসাব ব্যবহার করে আসছে — ১৯৫৯ সালে নকশা হওয়া COBOL ভাষাতেই দশমিক সংখ্যা মূল টাইপ ছিল, আর IBM-এর মেইনফ্রেম যন্ত্রে দশমিক হিসাবের নিজস্ব নির্দেশ ছিল। SQL-এর DECIMAL/NUMERIC সেই ঐতিহ্যেরই অংশ।

DECIMAL বনাম পয়সায় পূর্ণসংখ্যা: DECIMAL(12,2) পড়তে সহজ, SQL-এ সরাসরি টাকা দেখায়, আর ভাগ-গুণের সময় দশমিক ঘর ঠিক রাখে; কিন্তু সব DBMS-এ (যেমন SQLite) নিখুঁতভাবে নেই, আর কিছু প্রোগ্রামিং ভাষার driver এটিকে আবার floating point-এ বদলে ফেলে — সাবধান না হলে ভুলটা অ্যাপে গিয়ে ঢোকে। পয়সায় BIGINT সব জায়গায় নিখুঁত আর দ্রুত, কিন্তু দেখানোর সময় প্রতিবার ১০০ দিয়ে ভাগ করতে হয়, আর মুদ্রা বদলালে (যেখানে ভগ্নাংশ ৩ ঘরের) নিয়মও বদলায়। অনেক পেমেন্ট সিস্টেম পূর্ণসংখ্যার “ক্ষুদ্রতম একক” ব্যবহার করে, আর বেশিরভাগ ERP ও ব্যাংকিং ডেটাবেস DECIMAL.

আরেকটি জরুরি বিষয় rounding: সুদ বা ভ্যাট হিসাবে ভগ্নাংশ পয়সা আসে, কখন আর কোন নিয়মে (half-up, half-even) round করবেন, তা ব্যবসার নিয়ম — টাইপ একা সেটি ঠিক করে না।

মূল উপলব্ধি: টাকা গণনা করা হয় দশমিকে, তাই দশমিকেই রাখুন। DECIMAL বা পূর্ণসংখ্যা পয়সা — দুটিই ঠিক; ভুল শুধু FLOAT/REAL.

প্র ০৩MySQL-এর “utf8” আসলে পুরো UTF-8 নয় — এই ইতিহাস কী, আর বাংলা অ্যাপ বানানোর সময় এর শিক্ষা কী?

MySQL-এ Unicode সমর্থন আসে ২০০০-এর দশকের শুরুতে (4.1 সংস্করণে), তখন utf8 নামে একটি character set যোগ হয়, যা প্রতি অক্ষরে সর্বোচ্চ ৩ বাইট নেয়। সেই সময় Unicode-এর প্রায় সব প্রচলিত অক্ষর — ইংরেজি, বাংলা, আরবি, চীনা — ৩ বাইটে ধরত (Basic Multilingual Plane বা BMP). কিন্তু UTF-8-এর আসল সংজ্ঞায় এক অক্ষর ৪ বাইটও হতে পারে, আর পরে সেই ৪ বাইটের এলাকায় এল emoji, অনেক দুর্লভ চীনা অক্ষর, গাণিতিক চিহ্ন। মোবাইল যুগে emoji সর্বত্র ছড়িয়ে পড়লে MySQL-এর “utf8” কলামে 😀 ঢোকাতে গেলে ত্রুটি, বা পুরোনো non-strict সেটিংয়ে লেখাটি সেখানেই কেটে যাওয়া শুরু হলো।

সমাধান হিসেবে MySQL 5.5.3 (২০১০)-এ আসে utf8mb4 — “most bytes 4”, অর্থাৎ আসল UTF-8. কিন্তু utf8 নামটি পুরোনো ৩-বাইটের অর্থেই থেকে গেল, পরে তার নাম হলো utf8mb3. MySQL 8.0 (২০১৮)-এ ডিফল্ট character set হয় utf8mb4. অর্থাৎ প্রায় দেড় দশক ধরে “utf8” লেখা ডেভেলপাররা ভেবেছেন তাঁরা UTF-8 পাচ্ছেন।

বাংলার জন্য শিক্ষা তিনটি। প্রথমত, বাংলা অক্ষর U+0980–U+09FF, যা BMP-র ভেতরে, তাই utf8mb3-এ বাংলা ঠিকঠাক চলে — পরীক্ষায় বাংলা লিখে সব ঠিক দেখে নিশ্চিন্ত হবেন না; ব্যবহারকারী মন্তব্যে emoji দিলেই সমস্যা। দ্বিতীয়ত, শুধু কলাম নয়, সংযোগ (connection) আর অ্যাপের character set-ও utf8mb4 হতে হবে, নইলে পথেই লেখা নষ্ট হয়। তৃতীয়ত, “n অক্ষর” আর “n বাইট” আলাদা: বাংলা প্রতি অক্ষরে ৩ বাইট, তাই index-এর বাইট-সীমা বা Oracle-এর byte semantics-এ VARCHAR2(30)-এ মাত্র ১০টি বাংলা অক্ষর ধরে। আরও একটি সূক্ষ্মতা: Unicode-এ একই দেখতে বাংলা লেখা একাধিক code point-এর ক্রমে লেখা যায় (যেমন য়-এর দুই রকম লেখা), তাই খোঁজার আগে normalization ভাবতে হয়।

মূল উপলব্ধি: নাম দেখে বিশ্বাস করবেন না, বাইট দেখে যাচাই করুন। নতুন MySQL প্রজেক্টে সর্বত্র utf8mb4, আর বাংলা অ্যাপের পরীক্ষায় অবশ্যই emoji আর লম্বা বাংলা লেখা রাখুন।

প্র ০৪সময় রাখার সময় time zone নিয়ে এত সতর্কতা কেন? বাংলাদেশের নিজের ইতিহাস থেকে কী শেখা যায়?

“2009-12-31 23:30” — এই লেখাটি একটি নির্দিষ্ট মুহূর্ত বোঝায় না, যতক্ষণ না জানা যায় কোন ঘড়ির সময়। ঢাকার রাত সাড়ে এগারোটা লন্ডনের বিকেল সাড়ে পাঁচটা। তার ওপর ঘড়ির নিয়ম নিজেই বদলায়। বাংলাদেশ ২০০৯ সালের জুনে বিদ্যুৎ সাশ্রয়ের জন্য ঘড়ি এক ঘণ্টা এগিয়ে দিয়েছিল (daylight saving time), আর সেই বছরের শেষে আবার পিছিয়ে আনে। তখন কয়েক মাস ঢাকার সময় ছিল UTC+7, তারপর আবার UTC+6. যে সিস্টেম শুধু “স্থানীয় সময়” লিখে রেখেছিল, সেখানে ঘড়ি পেছানোর রাতে একটি ঘণ্টা দুবার এসেছিল — সেই ঘণ্টার দুটি লেনদেনের কোনটি আগে, তা লেখা দেখে বলা অসম্ভব।

এজন্য পেশাদার নিয়ম: মুহূর্ত (লেনদেন, লগইন, অর্ডার) জমা রাখুন UTC-তে, আর দেখানোর সময় ব্যবহারকারীর time zone-এ রূপান্তর করুন। PostgreSQL-এর TIMESTAMPTZ ঠিক এটিই করে। SQL Server-এর DATETIMEOFFSET মানের সাথে offset (+06:00) রেখে দেয়। MySQL-এর TIMESTAMP UTC-তে রাখে, কিন্তু Unix time-এর ৩২ বিটের সীমার কারণে ২০৩৮ সালের ১৯ জানুয়ারির পরের তারিখ রাখতে পারে না — একটি ৩০ বছরের গৃহঋণের শেষ কিস্তির তারিখ আজই সেই সীমা পেরোয়। SQLite-এ নিয়ম আপনার হাতে: সবসময় UTC-তে ISO লেখা রাখুন, যেমন CURRENT_TIMESTAMP দেয়।

তবে সব সময় মুহূর্ত নয়। জন্মতারিখ একটি “দিন”, মুহূর্ত নয় — একে UTC-তে রূপান্তর করলে কারও জন্মদিন এক দিন সরে যেতে পারে; তার জন্য DATE. দোকান খোলার সময় “সকাল ৯টা” একটি স্থানীয় ঘড়ির সময়, যেখানে time zone-এর নিয়ম বদলালেও ৯টাই থাকা উচিত। ভবিষ্যতের স্থানীয় ঘটনার (যেমন পরের বছরের একটি সভা) ক্ষেত্রে অনেক সিস্টেম স্থানীয় সময় আর time zone-এর নাম (Asia/Dhaka) আলাদা রাখে, কারণ ততদিনে নিয়ম বদলাতে পারে।

মূল উপলব্ধি: সময়ের টাইপ বাছাইয়ের আগে প্রশ্ন করুন — এটি কি একটি মুহূর্ত, একটি দিন, নাকি একটি স্থানীয় ঘড়ির সময়? মুহূর্ত মানে UTC (TIMESTAMPTZ), দিন মানে DATE.

প্র ০৫অর্ডারের status-এর মতো “নির্দিষ্ট তালিকার মান” রাখতে ENUM, CHECK নাকি আলাদা lookup টেবিল — কোনটি কখন?

তিনটি পথই “status চারটি শব্দের একটি” নিয়মটি রক্ষা করে, কিন্তু পরিবর্তনের খরচ আর বহনযোগ্যতায় আলাদা।

ENUM: MySQL-এ status ENUM('pending','shipped','delivered','cancelled') জায়গা কম নেয় (ভেতরে ১–২ বাইটের ক্রমিক নম্বর), আর ভুল শব্দ আটকায়। কিন্তু ফাঁদ আছে: ENUM দিয়ে সাজালে (ORDER BY) সাজায় তালিকার ক্রমে, বর্ণানুক্রমে নয়; নতুন মান যোগ করতে ALTER TABLE লাগে, যা পুরোনো সংস্করণে বড় টেবিলে পুরো টেবিল নতুন করে লেখাতে পারত; আর মান সরানো আরও কঠিন। PostgreSQL-এ ENUM একটি আলাদা টাইপ (CREATE TYPE order_status AS ENUM (…)), যেখানে ALTER TYPE … ADD VALUE দিয়ে মান যোগ করা সহজ, কিন্তু মান সরানোর সরাসরি কোনো আদেশ নেই। আর ENUM অন্য DBMS-এ সরানো যায় না — SQL Server বা Oracle-এ এটি নেই।

CHECK: CHECK (status IN (…)) standard SQL, পাঁচটি DBMS-এই চলে, আর db-shop এটিই ব্যবহার করে। নতুন মান যোগ মানে constraint সরিয়ে নতুন constraint বসানো (পর্ব ২৩) — নাম দেওয়া থাকলে কাজটি সহজ। কিন্তু মানগুলোর বাড়তি তথ্য (বাংলা নাম, দেখানোর ক্রম, “সক্রিয় কি না”) রাখার জায়গা নেই।

Lookup টেবিল: order_statuses(code PRIMARY KEY, label_bn, sort_order, is_active) আর orders.status → foreign key. মান যোগ করা মানে একটি INSERT — কোনো DDL নয়, অ্যাডমিন প্যানেল থেকেই করা যায়। বাড়তি তথ্যও রাখা যায়: “pending”-এর পাশে “অপেক্ষমাণ”। দাম একটি বাড়তি টেবিল আর মাঝে মাঝে একটি JOIN, যদিও code লেখা হলে JOIN ছাড়াই পড়া যায়। db-shop-এর categories ঠিক এই নকশা।

মোটামুটি নিয়ম: তালিকা ছোট আর প্রায় কখনো বদলায় না (লিঙ্গ, দিনের নাম, অর্ডারের মূল অবস্থা) — CHECK; তালিকা ব্যবসার সাথে বাড়ে বা প্রতিটি মানের নিজস্ব তথ্য আছে (শ্রেণি, জেলা, পেমেন্ট পদ্ধতি) — lookup টেবিল; ENUM — শুধু তখনই, যখন এক DBMS-এ থাকা নিশ্চিত আর জায়গা বাঁচানো সত্যিই জরুরি।

মূল উপলব্ধি: প্রশ্ন “কোনটি সুন্দর” নয়, “তালিকাটি কত ঘন ঘন বদলাবে আর কে বদলাবে”। ডেভেলপার বছরে একবার বদলালে CHECK; ব্যবসার মানুষ প্রতি মাসে বদলালে lookup টেবিল।

অনুশীলন

একটি টেবিল লেখা, দুটি ফল আগে বলা, একটি অনুবাদ। প্রথমটি উপরের যেকোনো খালি এডিটরে (যেমন অংশ ১-এর) চালিয়ে দেখতে পারেন।

  1. CREATE TABLE লিখুন: একটি লাইব্রেরির loans টেবিল: প্রতিটি ধারের নিজস্ব নম্বর; কোন সদস্য (members টেবিলের member_id) আর কোন বই (books টেবিলের book_id) — দুটিই বাধ্যতামূলক; ধারের তারিখ না বললে আজকের তারিখ; ফেরতের নির্ধারিত তারিখ ধারের তারিখের পরে হতে হবে; ফেরতের আসল তারিখ ফাঁকা থাকতে পারে; জরিমানা টাকায়, না বললে শূন্য, কখনো ঋণাত্মক নয়। SQLite-এর জন্য লিখুন।
    CREATE TABLE loans (
      loan_id     INTEGER PRIMARY KEY,
      member_id   INTEGER NOT NULL REFERENCES members (member_id),
      book_id     INTEGER NOT NULL REFERENCES books (book_id),
      loaned_on   TEXT NOT NULL DEFAULT CURRENT_DATE,
      due_on      TEXT NOT NULL,
      returned_on TEXT,
      fine_paisa  INTEGER NOT NULL DEFAULT 0
                  CONSTRAINT chk_fine CHECK (fine_paisa >= 0),
      CONSTRAINT chk_due CHECK (due_on > loaned_on)
    );

    মূল সিদ্ধান্ত: তারিখ ISO লেখায় (তাই due_on > loaned_on লেখা হিসেবে তুলনা করলেও সঠিক), জরিমানা পয়সায় INTEGER, দুই কলামের শর্ত টেবিলের শেষে। CURRENT_DATE SQLite-এ UTC অনুযায়ী আজকের তারিখ দেয় — ঢাকায় রাত ১২টা থেকে সকাল ৬টার মধ্যে এটি গতকালের তারিখ হবে। PostgreSQL-এ টাইপ হতো DATE আর জরিমানা NUMERIC(8,2).

  2. ফল আগে বলুন (trace): SQLite-এ একটি সাধারণ (non-STRICT) টেবিল: CREATE TABLE t (a INTEGER, b TEXT, c REAL, d); তারপর INSERT INTO t VALUES ('050', 050, '7', '7'); চালানো হলো। SELECT a, typeof(a), b, typeof(b), c, typeof(c), d, typeof(d) FROM t; কী দেখাবে? STRICT টেবিল হলে (d-র টাইপ ANY ধরে) কী হতো?

    ফল: 50 | integer | 50 | text | 7 | real | 7 | text (পাতার এডিটর 7.0-কে 7 দেখায়). a: '050' লেখা ক্ষতি ছাড়া পূর্ণসংখ্যা 50 হয় (শূন্য হারায়)। b: 050 উদ্ধৃতিচিহ্ন ছাড়া, তাই SQL আগেই এটিকে সংখ্যা 50 পড়েছে; TEXT affinity তাকে '50' লেখা বানায়। c: '7' → 7.0. d: টাইপ নেই (BLOB affinity), '7' যেমন ছিল তেমন লেখা।

    STRICT হলে: a-তে '050' → 50 (ক্ষতি ছাড়া রূপান্তর, তাই গৃহীত); b-তে 50 → '50'; c-তে '7' → 7.0; ANY কলামে '7' লেখাই থাকে। অর্থাৎ এই নির্দিষ্ট INSERT-এ ফল একই — STRICT শুধু তখনই আটকায়, যখন রূপান্তর সম্ভব নয় ('বারো' বা INTEGER-এ 3.5). শিক্ষা: শূন্য রাখতে চাইলে কলাম TEXT আর মান উদ্ধৃতিচিহ্নে — STRICT একা শূন্য বাঁচায় না। চাইলে অংশ ৪-এর এডিটরে মিলিয়ে নিন।

  3. id আগে বলুন: SQLite-এ CREATE TABLE x (id INTEGER PRIMARY KEY, v TEXT); তারপর ক্রমানুসারে: (ক) INSERT INTO x (v) VALUES ('p'), ('q'); (খ) INSERT INTO x VALUES (10, 'r'); (গ) INSERT INTO x (v) VALUES ('s'); (ঘ) DELETE FROM x WHERE id >= 10; (ঙ) INSERT INTO x (v) VALUES ('t'); শেষে টেবিলে কোন id-তে কোন মান? AUTOINCREMENT থাকলে (ঙ)-তে t কোন id পেত?

    (ক) p → 1, q → 2. (খ) হাতে 10 দেওয়া যায়, r → 10. (গ) সর্বোচ্চ id এখন 10, তাই s → 11. (ঘ) 10 আর 11 মুছে গেল; সর্বোচ্চ এখন 2. (ঙ) t → 3. শেষে: 1 p, 2 q, 3 t.

    AUTOINCREMENT থাকলে sqlite_sequence-এ এ পর্যন্ত দেওয়া সর্বোচ্চ 11 লেখা থাকত (হাতে দেওয়া 10-ও হিসাবে আসে), তাই t পেত 12. একই সংখ্যা কখনো দুবার নয় — এটিই AUTOINCREMENT-এর প্রতিশ্রুতি।

  4. অনুবাদ করুন (ভাবনা): নিচের SQLite টেবিলটি PostgreSQL আর MySQL-এ নিতে হবে: CREATE TABLE payments (payment_id INTEGER PRIMARY KEY, amount REAL NOT NULL, paid_at TEXT DEFAULT CURRENT_TIMESTAMP, is_refund INTEGER DEFAULT 0, meta TEXT); প্রতিটি কলামে কী বদলাবেন আর কেন?
    • payment_id: PostgreSQL-এ BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, MySQL-এ BIGINT AUTO_INCREMENT PRIMARY KEY — লেনদেনের টেবিল বড় হবে, তাই BIGINT.
    • amount: REAL একটি ভুল; দুটিতেই DECIMAL(12,2) NOT NULL, সাথে CHECK (amount > 0).
    • paid_at: PostgreSQL-এ TIMESTAMPTZ NOT NULL DEFAULT now(); MySQL-এ DATETIME বা TIMESTAMP DEFAULT CURRENT_TIMESTAMP — TIMESTAMP-এর ২০৩৮ সীমা আর time zone রূপান্তর মাথায় রেখে, আর UTC-তে রাখার নিয়ম লিখে রাখুন।
    • is_refund: PostgreSQL-এ BOOLEAN NOT NULL DEFAULT false; MySQL-এ BOOLEAN (আসলে TINYINT(1)) NOT NULL DEFAULT 0.
    • meta: JSON হলে PostgreSQL-এ JSONB, MySQL-এ JSON — দুটিই অবৈধ JSON আটকায়। MySQL-এ টেবিলে CHARSET = utf8mb4.

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমSQLite-এ একটি INSERT ঢোকার সময় ধাপগুলো যে ক্রমে ঘটে, সাজান (▲▼ চেপে সরান)

  1. বাদ পড়া কলামে DEFAULT বসানো
  2. কলামের affinity অনুযায়ী মানের টাইপ রূপান্তর
  3. NOT NULL যাচাই
  4. CHECK শর্ত যাচাই
  5. UNIQUE আর PRIMARY KEY যাচাই
  6. FOREIGN KEY যাচাই

PostgreSQL-এ একটি অনলাইন দোকানের পণ্যের দাম (টাকা ও পয়সা) রাখার জন্য সবচেয়ে উপযুক্ত টাইপ কোনটি?

  • REAL
  • DOUBLE PRECISION
  • NUMERIC(12,2)
  • VARCHAR(12)

SQLite-এ CREATE TABLE t (qty INTEGER); (STRICT নয়) বানিয়ে INSERT INTO t VALUES ('abc'); চালালে কী হয়?

  • ত্রুটি: টাইপ মেলেনি
  • সারিটি ঢোকে, আর qty-তে 'abc' লেখা (TEXT) হিসেবে জমা হয়
  • qty-তে 0 জমা হয়
  • qty-তে NULL জমা হয়

একটি কলাম status TEXT NOT NULL DEFAULT 'pending'. INSERT INTO orders (customer_id, status) VALUES (8, NULL); চালালে কী হবে?

  • status-এ 'pending' বসবে
  • status-এ NULL জমা হবে
  • status-এ খালি লেখা '' বসবে
  • NOT NULL লঙ্ঘনের ত্রুটি দিয়ে INSERT বাতিল হবে

CREATE TABLE backup_customers AS SELECT * FROM customers; — নতুন টেবিলে সাধারণত কী আসে না?

  • PRIMARY KEY, UNIQUE, NOT NULL আর অন্যান্য constraint
  • সারি (ডেটা)
  • কলামের নাম
  • কলামের ক্রম

একটি টেবিলে price INTEGER NOT NULL, sale_price INTEGER আর CHECK (sale_price <= price) আছে। INSERT … VALUES ('Saree', 4500, NULL) চালালে কী হয়?

  • sale_price-এ NOT NULL ভাঙায় বাতিল
  • CHECK ভাঙায় বাতিল, কারণ NULL কোনো সংখ্যার চেয়ে ছোট নয়
  • সারিটি ঢোকে, কারণ শর্তের ফল UNKNOWN
  • sale_price নিজে থেকে 0 হয়ে সারিটি ঢোকে

MySQL-এ পুরোনো utf8 (utf8mb3) character set-এর কলামে কোনটি ঠিকমতো রাখা যায় না?

  • বাংলা লেখা, যেমন “ঢাকা”
  • 😀-এর মতো emoji
  • ইংরেজি লেখা
  • আরবি লেখা

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. CREATE TABLE = নাম + প্রতিটি কলামের টাইপ + নিয়ম; IF NOT EXISTS আর DROP TABLE IF EXISTS স্ক্রিপ্টকে বারবার চালানোর যোগ্য করে।
  2. টাকা DECIMAL বা পয়সায় পূর্ণসংখ্যা, কখনো REAL/FLOAT নয়; ফোন-NID লেখা হিসেবে; মুহূর্ত UTC-তে (TIMESTAMPTZ); MySQL-এ utf8mb4.
  3. SQLite-এর type affinity ভুল টাইপ নিঃশব্দে মেনে নেয় — এটি শুধু SQLite-এর; কড়া যাচাইয়ের জন্য STRICT.
  4. এক কলামের নিয়ম কলামের পাশে, একাধিক কলামের নিয়ম টেবিলের শেষে; নাম দিন (CONSTRAINT chk_…); DEFAULT শুধু বাদ পড়া কলামে খাটে।
  5. Auto id: SQLite INTEGER PRIMARY KEY (+AUTOINCREMENT), PostgreSQL IDENTITY, MySQL AUTO_INCREMENT, SQL Server IDENTITY; CTAS ডেটা আনে, constraint আনে না।

পরের পর্বে: টেবিল তৈরি, এবার তাতে প্রাণ। পর্ব ১৪-এ INSERT, UPDATE আর DELETE — তৈরি টেবিলে ডেটা ঢোকানো, বদলানো আর মোছা।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান