Series này không dạy SQL theo kiểu chép cú pháp — mọi khái niệm đều đi kèm 1 engine SQLite thật (biên dịch sang WebAssembly) chạy ngay trong trình duyệt của bạn, và 1 bộ dữ liệu kinh doanh xuyên suốt gọi là TechMart (cửa hàng bán đồ điện tử/phụ kiện/gia dụng/văn phòng online) mà bạn sẽ dùng lại ở toàn bộ 17 bài. Bài đầu tiên xây nền tảng bắt buộc: hiểu đúng dữ liệu quan hệ được tổ chức thế nào, vì sao SQLite có hệ kiểu khác hẳn phần lớn database khác, và cách lọc/sắp xếp/phân trang dữ liệu bằng SELECT.

1. Mô Hình Quan Hệ: Bảng, Hàng, Cột

Mô hình quan hệ (relational model) tổ chức dữ liệu thành các bảng (table) — mỗi bảng có 1 tập cột (column) cố định, mỗi cột mang 1 ý nghĩa và kiểu dữ liệu riêng. Mỗi hàng (row) trong bảng đại diện cho 1 thực thể cụ thể — 1 khách hàng, 1 sản phẩm, 1 đơn hàng. Khác với việc nhét dữ liệu tuỳ tiện vào 1 file text hay JSON lồng nhau không giới hạn, mô hình quan hệ buộc dữ liệu phải có cấu trúc nhất quán: mọi hàng trong bảng products đều có đúng các cột product_id, product_name, category, unit_price, stock_quantity — không hàng nào thiếu cột, không hàng nào có thêm cột lạ.

Bộ dữ liệu TechMart dùng xuyên suốt series gồm 4 bảng (2 bảng đầu dùng ngay ở bài này, 2 bảng sau dành cho Bài 3 khi học JOIN):

Bảng Mô tả Cột chính
customers Khách hàng đã đăng ký tài khoản customer_id, full_name, email, country, signup_date, is_active
products Danh mục sản phẩm đang bán product_id, product_name, category, unit_price, stock_quantity
orders Đơn hàng (có thể không gắn khách hàng — đơn khách vãng lai) order_id, customer_id, order_date, status, total_amount
order_items Từng dòng sản phẩm cụ thể trong 1 đơn hàng order_item_id, order_id, product_id, quantity, unit_price

Câu lệnh cơ bản nhất để đọc dữ liệu là SELECT. Dấu * nghĩa là "lấy tất cả các cột", theo đúng thứ tự đã khai báo khi tạo bảng:

SELECT * FROM products;

Thực tế hiếm khi dùng SELECT * trong code sản xuất (production) — liệt kê đúng cột cần dùng giúp query rõ ràng hơn và tránh vỡ code khi bảng có thêm cột mới sau này:

SELECT product_name, unit_price FROM products;

2. Hệ Kiểu Động Của SQLite: Type Affinity

Đây là điểm khác biệt lớn nhất giữa SQLite và phần lớn database khác (PostgreSQL, MySQL) mà người mới thường không biết: hầu hết database dùng kiểu tĩnh nghiêm ngặt — 1 cột khai báo INTEGER chỉ được phép chứa số nguyên, chèn chuỗi vào sẽ báo lỗi ngay. SQLite hoạt động hoàn toàn khác: nó dùng cơ chế gọi là type affinity (ái lực kiểu) — kiểu khai báo của 1 cột chỉ là "gợi ý ưu tiên", SQLite vẫn chấp nhận lưu hầu hết mọi kiểu dữ liệu vào bất kỳ cột nào.

Cụ thể, SQLite có đúng 5 storage class (kiểu lưu trữ thực tế của 1 giá trị): NULL, INTEGER, REAL, TEXT, BLOB. Và có 5 type affinity (ái lực kiểu của 1 cột, suy ra từ chuỗi kiểu khai báo lúc CREATE TABLE):

Type Affinity Suy ra khi chuỗi kiểu khai báo chứa Ví dụ kiểu khai báo
INTEGER chứa "INT" INTEGER, INT, BIGINT
TEXT chứa "CHAR", "CLOB", hoặc "TEXT" TEXT, VARCHAR(255), NCHAR
REAL chứa "REAL", "FLOA", hoặc "DOUB" REAL, FLOAT, DOUBLE PRECISION
BLOB không khai báo kiểu, hoặc chứa "BLOB" BLOB, để trống
NUMERIC mọi trường hợp còn lại NUMERIC, DECIMAL(10,2), BOOLEAN, DATE
🔬 Đào sâu: dùng typeof() để nhìn thấy storage class thật
Hàm typeof(x) trả về đúng storage class SQLite thực sự dùng để lưu giá trị x — không phải kiểu bạn khai báo lúc tạo bảng. Chạy SELECT product_id, typeof(product_id) FROM products LIMIT 1; trong sân chơi bên dưới, bạn sẽ thấy INTEGER — đúng như kỳ vọng vì cột này chỉ chứa số nguyên. Nhưng vì total_amount trong bảng orders khai báo REAL, SQLite vẫn hoàn toàn cho phép bạn INSERT 1 chuỗi văn bản vào đó — typeof() lúc này sẽ trả về TEXT thay vì REAL, và mọi phép toán số học trên giá trị đó có thể cho kết quả bất ngờ.
ℹ️ Vì sao signup_dateorder_date khai báo TEXT?
SQLite không có storage class riêng cho ngày/giờ. Quy ước phổ biến nhất (và cũng là cách TechMart dùng) là lưu ngày dạng chuỗi ISO-8601 'YYYY-MM-DD' với affinity TEXT — vì chuỗi ISO-8601 vừa đọc được bằng mắt thường, vừa so sánh/sắp xếp đúng thứ tự thời gian bằng phép so sánh chuỗi thông thường ('2026-03-01' > '2026-01-01' đúng như so sánh ngày thật). PostgreSQL, ngược lại, có kiểu DATE/TIMESTAMP thật — đây là 1 khác biệt dialect quan trọng bạn sẽ gặp lại khi thực hành Docker ở Bài 2.
🕳️ Cạm bẫy thường gặp: gõ nhầm số điện thoại có số 0 đầu vào cột kiểu số
Nếu khai báo cột số điện thoại là INTEGER (nghĩ rằng số điện thoại "là số"), giá trị 0912345678 sẽ mất số 0 ở đầu — vì INTEGER affinity buộc SQLite chuyển chuỗi số hợp lệ thành số nguyên thật trước khi lưu, và số 0 đầu không có ý nghĩa toán học. Số điện thoại, mã bưu điện, hay mọi "chuỗi trông giống số" nhưng không dùng để tính toán nên luôn khai báo TEXT.

3. WHERE: Lọc Dữ Liệu & Logic 3 Trị

WHERE giữ lại đúng những hàng thoả điều kiện. SQLite hỗ trợ đầy đủ toán tử so sánh (=, !=/<>, <, >, <=, >=) và toán tử logic (AND, OR, NOT). Dưới đây là 4 tình huống thực tế minh hoạ các toán tử khác nhau trên cùng bộ dữ liệu TechMart:

Tình huống 1 — Báo cáo doanh thu: tìm đơn hàng đã giao thành công với giá trị lớn.

SELECT order_id, order_date, total_amount
FROM orders
WHERE status = 'delivered' AND total_amount > 200;

Tình huống 2 — Quản lý tồn kho: lọc theo nhiều danh mục cùng lúc bằng IN.

SELECT product_name, category, stock_quantity
FROM products
WHERE category IN ('Electronics', 'Office');

Tình huống 3 — Marketing/phân khúc khách hàng: tìm khách dùng Gmail bằng LIKE.

SELECT full_name, email
FROM customers
WHERE email LIKE '%@gmail.com';

% khớp bất kỳ chuỗi con nào (kể cả rỗng), _ khớp đúng 1 ký tự. Muốn tìm đúng ký tự % hoặc _ theo nghĩa đen, dùng mệnh đề ESCAPE, ví dụ LIKE '50\%%' ESCAPE '\' tìm chuỗi bắt đầu bằng "50%".

Tình huống 4 — Phát hiện dữ liệu bất thường: tìm đơn hàng của khách vãng lai (không gắn tài khoản).

SELECT order_id, order_date, total_amount
FROM orders
WHERE customer_id IS NULL;
🔬 Đào sâu: vì sao phải viết IS NULL, không viết = NULL?
SQL dùng logic 3 trị (three-valued logic): mỗi điều kiện có thể là TRUE, FALSE, hoặc UNKNOWN — thay vì chỉ đúng/sai như logic thông thường. NULL đại diện cho "giá trị không xác định", nên so sánh bất kỳ thứ gì với NULL — kể cả NULL với chính nó — đều cho kết quả UNKNOWN, không phải TRUE. Viết WHERE customer_id = NULL sẽ luôn cho ra 0 hàng, vì mọi so sánh đều là UNKNOWN (và WHERE chỉ giữ lại hàng có kết quả đúng TRUE, loại cả FALSE lẫn UNKNOWN). Đây là lý do SQL có hẳn 2 toán tử riêng IS NULL/IS NOT NULL để kiểm tra NULL đúng cách — không dùng được toán tử so sánh thông thường.

Tình huống 5 — Báo cáo theo kỳ: lọc đơn hàng trong 1 khoảng thời gian bằng BETWEEN.

SELECT order_id, order_date, total_amount
FROM orders
WHERE order_date BETWEEN '2026-01-01' AND '2026-03-31'
ORDER BY order_date;

BETWEEN a AND b tương đương >= a AND <= b — bao gồm cả 2 đầu mút. Vì order_date lưu dạng chuỗi ISO-8601, so sánh chuỗi ở đây cho đúng kết quả so sánh ngày tháng thật (xem lại callout mục 2).

4. ORDER BY, LIMIT, OFFSET: Sắp Xếp & Phân Trang

ORDER BY sắp xếp kết quả theo 1 hoặc nhiều cột, ASC (tăng dần, mặc định) hoặc DESC (giảm dần). LIMIT n chỉ lấy n hàng đầu tiên, OFFSET m bỏ qua m hàng đầu — kết hợp cả 2 tạo ra cơ chế phân trang kinh điển: trang N (kích thước p) → LIMIT p OFFSET (N-1)*p.

Tình huống 6 — Bảng xếp hạng: top 5 đơn hàng giá trị cao nhất.

SELECT order_id, total_amount
FROM orders
ORDER BY total_amount DESC
LIMIT 5;

Tình huống 7 — Phân trang danh sách sản phẩm: trang 2, 5 sản phẩm/trang.

SELECT product_id, product_name
FROM products
ORDER BY product_id
LIMIT 5 OFFSET 5;
💡 Mẹo: sắp xếp theo nhiều cột để phá thế hoà (tie-breaking)
ORDER BY status, total_amount DESC sắp xếp trước theo status (alphabet), trong mỗi nhóm status giống nhau mới sắp tiếp theo total_amount giảm dần. Luôn thêm 1 cột "duy nhất" (như khoá chính) làm tiêu chí cuối cùng nếu cần thứ tự ổn định, lặp lại giống hệt mỗi lần chạy — nếu không, khi nhiều hàng có cùng giá trị sắp xếp, SQLite không đảm bảo thứ tự giữa chúng sẽ giống nhau qua các lần chạy khác nhau.

Về vị trí NULL khi sắp xếp: SQLite coi NULL là giá trị nhỏ nhất — mặc định ORDER BY col ASC đưa hàng có NULL lên đầu, DESC đưa xuống cuối. Muốn kiểm soát rõ ràng, dùng thêm NULLS FIRST/NULLS LAST (hỗ trợ từ SQLite 3.30+).

Sân chơi tương tác: SQL Workbench Trên Dataset TechMart

Engine bên dưới là SQLite thật (biên dịch WebAssembly qua sql.js), không phải mô phỏng — mọi câu query chạy đúng như trên máy bạn cài SQLite thật. Bấm 1 nút tình huống để nạp sẵn query vào ô soạn thảo, chỉnh sửa tuỳ ý rồi bấm "Chạy" (hoặc Ctrl+Enter).

🗄️ Sân chơi tương tác: SQL Workbench (TechMart)

customers

Cột Kiểu
customer_id INTEGER PRIMARY KEY
full_name TEXT NOT NULL
email TEXT
country TEXT
signup_date TEXT
is_active INTEGER

products

Cột Kiểu
product_id INTEGER PRIMARY KEY
product_name TEXT NOT NULL
category TEXT
unit_price REAL
stock_quantity INTEGER

orders

Cột Kiểu
order_id INTEGER PRIMARY KEY
customer_id INTEGER (NULL = khách vãng lai)
order_date TEXT
status TEXT
total_amount REAL

order_items (dùng từ Bài 3)

Cột Kiểu
order_item_id INTEGER PRIMARY KEY
order_id INTEGER
product_id INTEGER
quantity INTEGER
unit_price REAL
Kết quả sẽ hiện ở đây sau khi chạy query...
Đang tải SQLite-WASM engine...
sql-techmart-seed.sql
Đang tải...
sql-relational-model-select.js

⬇ Tải file schema + dữ liệu mẫu TechMart (.sql) — chạy được trực tiếp bằng sqlite3 CLI hoặc DB Browser for SQLite trên máy bạn.

Trắc nghiệm ôn tập

Câu 1: Khác biệt cốt lõi giữa "type affinity" của SQLite và kiểu tĩnh nghiêm ngặt của PostgreSQL là gì?

Trắc nghiệm ôn tập

Câu 2: Vì sao WHERE customer_id = NULL luôn trả về 0 hàng, kể cả khi có hàng customer_id thực sự là NULL?

Trắc nghiệm ôn tập

Câu 3: Muốn lấy đúng 10 sản phẩm cho trang 3 (mỗi trang 10 sản phẩm), câu lệnh nào đúng?

Trắc nghiệm ôn tập

Câu 4: Vì sao TechMart lưu order_date dạng chuỗi 'YYYY-MM-DD' (ISO-8601) thay vì định dạng khác như 'DD/MM/YYYY'?

Trắc nghiệm ôn tập

Câu 5: Tại sao nên tránh lưu số điện thoại như 0912345678 vào 1 cột có type affinity INTEGER?

📖 Tài liệu tham khảo / References

Bài viết liên quan trong series

Bài 2: Môi Trường Thực Hành Kép — Browser & Docker Quay lại Lộ trình Series SQL