M16 — Python kết nối Database

← 15 - Môi trường ảo và pip | Tiếp theo → 17 - Dự án tổng kết

Mục tiêu module

  • Hiểu chuẩn DB-API 2.0 — giao diện chung của mọi thư viện database Python
  • Thao tác với SQLite (có sẵn, không cần cài)
  • Biết cách kết nối SQL Server và PostgreSQL
  • Hiểu và tránh SQL Injection

Thời lượng: ~5 giờ | Tuần: 25

Module này tận dụng trực tiếp kiến thức T-SQL/PostgreSQL sẵn có của bạn.


1. DB-API 2.0 — Chuẩn chung

Python có một chuẩn thống nhất cho mọi thư viện database, gọi là DB-API 2.0 (đặc tả tại PEP 249).

Nhờ chuẩn này, code làm việc với SQLite, SQL Server, PostgreSQL, MySQL gần như giống hệt nhau — chỉ khác câu lệnh kết nối.

Luồng làm việc chuẩn

graph LR
    A[connect<br/>Kết nối] --> B[cursor<br/>Tạo con trỏ]
    B --> C[execute<br/>Chạy SQL]
    C --> D{Loại lệnh?}
    D -->|SELECT| E[fetchall / fetchone<br/>Lấy dữ liệu]
    D -->|INSERT/UPDATE/DELETE| F[commit<br/>Lưu thay đổi]
    E --> G[close<br/>Đóng]
    F --> G
BướcĐối tượngVai trò
1ConnectionKết nối tới database
2CursorCon trỏ — thực thi lệnh và giữ kết quả
3execute()Chạy câu SQL
4fetchone() / fetchall()Lấy dữ liệu về
5commit() / rollback()Xác nhận / hủy giao dịch
6close()Đóng kết nối

Cursor trong Python vs T-SQL

Từ “cursor” ở đây khác với DECLARE CURSOR trong T-SQL (thứ mà DBA thường khuyên tránh vì chậm). Cursor trong DB-API chỉ là đối tượng để gửi lệnh và nhận kết quả — nó không gây vấn đề hiệu năng như cursor T-SQL.


2. SQLite — Bắt đầu ở đây

SQLite là database nhúng, toàn bộ nằm trong một file duy nhất, và Python đã cài sẵn module sqlite3 — không cần cài gì thêm.

Đây là môi trường lý tưởng để học, trước khi đụng tới SQL Server thật.

2.1. Tạo database và bảng

import sqlite3
 
# Kết nối — file tự tạo nếu chưa có
conn = sqlite3.connect("ban_hang.db")
cur = conn.cursor()
 
cur.execute("""
    CREATE TABLE IF NOT EXISTS KhachHang (
        MaKH        TEXT PRIMARY KEY,
        TenKH       TEXT NOT NULL,
        Tinh        TEXT,
        CongNo      REAL DEFAULT 0,
        NgayTao     TEXT DEFAULT (date('now')),
        HoatDong    INTEGER DEFAULT 1
    )
""")
 
conn.commit()
conn.close()
print("Da tao database va bang")

2.2. Dùng with để tự đóng kết nối

import sqlite3
 
with sqlite3.connect("ban_hang.db") as conn:
    cur = conn.cursor()
    cur.execute("SELECT COUNT(*) FROM KhachHang")
    print(cur.fetchone()[0])

with của sqlite3 chỉ tự COMMIT, KHÔNG tự đóng kết nối

Đây là điểm khác biệt của sqlite3 so với open(). Khi ra khỏi khối with, nó tự commit() (hoặc rollback() nếu có lỗi) nhưng không gọi close().

Cách viết an toàn nhất:

conn = sqlite3.connect("ban_hang.db")
try:
    with conn:                  # Tự commit/rollback
        cur = conn.cursor()
        cur.execute(...)
finally:
    conn.close()                # Tự đóng

2.3. Kiểu dữ liệu SQLite

SQLite chỉ có 5 kiểu lưu trữ. Bảng ánh xạ với Python:

SQLitePythonGhi chú
NULLNone
INTEGERint
REALfloat⚠️ Có sai số — không dùng cho tiền
TEXTstr
BLOBbytesDữ liệu nhị phân

SQLite không có kiểu DECIMAL, DATE, BOOLEAN

  • Tiền tệ: lưu dạng TEXT rồi chuyển sang Decimal, hoặc lưu INTEGER theo đơn vị nhỏ nhất (đồng)
  • Ngày tháng: lưu dạng TEXT định dạng ISO "2026-08-08"
  • Boolean: lưu INTEGER 0/1

Đây là điểm SQLite thua xa SQL Server/PostgreSQL. Với dự án thật về kế toán, dùng PostgreSQL hoặc SQL Server.


3. ⚠️ Parameterized Query — Chống SQL Injection

Đây là phần quan trọng nhất của module này.

3.1. Cách SAI — Nối chuỗi

# ❌ TUYỆT ĐỐI KHÔNG LÀM
ma_kh = input("Nhap ma KH: ")
cur.execute(f"SELECT * FROM KhachHang WHERE MaKH = '{ma_kh}'")

Nếu người dùng nhập:

' OR '1'='1

Câu SQL trở thành:

SELECT * FROM KhachHang WHERE MaKH = '' OR '1'='1'

→ Trả về toàn bộ khách hàng.

Tệ hơn, nếu nhập:

'; DROP TABLE KhachHang; --

→ Xóa sạch bảng.

Đây là SQL Injection — lỗ hổng bảo mật phổ biến và nguy hiểm nhất trong ứng dụng có database.

3.2. Cách ĐÚNG — Truyền tham số

# ✅ ĐÚNG — dùng placeholder ?
ma_kh = input("Nhap ma KH: ")
cur.execute("SELECT * FROM KhachHang WHERE MaKH = ?", (ma_kh,))

Thư viện database sẽ escape giá trị đúng cách. Dữ liệu người dùng nhập không bao giờ được coi là mã SQL.

Chú ý dấu phẩy trong (ma_kh,)

Tham số phải là tuple. Với một tham số duy nhất, bắt buộc có dấu phẩy:

cur.execute("... WHERE MaKH = ?", (ma_kh,))     # ✅ tuple 1 phần tử
cur.execute("... WHERE MaKH = ?", (ma_kh))      # ❌ đây là chuỗi, không phải tuple
cur.execute("... WHERE MaKH = ?", [ma_kh])      # ✅ list cũng được

Xem lại tuple 1 phần tử ở 08 - List và Tuple.

3.3. Placeholder khác nhau theo thư viện

Thư việnDatabasePlaceholderVí dụ
sqlite3SQLite?WHERE MaKH = ?
pyodbcSQL Server?WHERE MaKH = ?
psycopgPostgreSQL%sWHERE MaKH = %s
mysql-connectorMySQL%sWHERE MaKH = %s

Đọc tài liệu của thư viện để biết paramstyle của nó.

3.4. Tham số theo tên

cur.execute(
    "SELECT * FROM KhachHang WHERE Tinh = :tinh AND CongNo > :nguong",
    {"tinh": "Hue", "nguong": 1_000_000}
)

Dễ đọc hơn khi có nhiều tham số.

3.5. Điều KHÔNG truyền tham số được

Placeholder chỉ dùng cho giá trị, không dùng cho tên bảng, tên cột, từ khóa:

# ❌ Không hoạt động
cur.execute("SELECT * FROM ? WHERE ? = ?", (bang, cot, gia_tri))
 
# ✅ Với tên bảng/cột động: dùng danh sách trắng
COT_HOP_LE = {"MaKH", "TenKH", "Tinh", "CongNo"}
 
if cot not in COT_HOP_LE:
    raise ValueError(f"Cot khong hop le: {cot}")
 
cur.execute(f"SELECT * FROM KhachHang ORDER BY {cot}")   # An toàn vì đã kiểm tra

4. CRUD — Bốn thao tác cơ bản

4.1. INSERT

import sqlite3
 
conn = sqlite3.connect("ban_hang.db")
cur = conn.cursor()
 
# Thêm 1 bản ghi
cur.execute(
    "INSERT INTO KhachHang (MaKH, TenKH, Tinh, CongNo) VALUES (?, ?, ?, ?)",
    ("KH001", "Nguyễn Văn A", "Huế", 5_000_000)
)
 
# Thêm NHIỀU bản ghi — executemany
du_lieu = [
    ("KH002", "Trần Thị B", "TP HCM", 12_000_000),
    ("KH003", "Lê Văn C", "Hà Nội", 0),
    ("KH004", "Phạm Thị D", "Huế", 8_500_000),
]
cur.executemany(
    "INSERT INTO KhachHang (MaKH, TenKH, Tinh, CongNo) VALUES (?, ?, ?, ?)",
    du_lieu
)
 
conn.commit()                    # ⚠️ BẮT BUỘC — không commit là mất dữ liệu
print(f"Da them {cur.rowcount} ban ghi")
conn.close()

Quên commit() = mất dữ liệu

DB-API mặc định chạy trong transaction. Không gọi commit() thì mọi thay đổi bị hủy khi đóng kết nối. Đây là lỗi phổ biến nhất của người mới.

4.2. SELECT

conn = sqlite3.connect("ban_hang.db")
cur = conn.cursor()
 
# Lấy TẤT CẢ
cur.execute("SELECT MaKH, TenKH, CongNo FROM KhachHang ORDER BY CongNo DESC")
tat_ca = cur.fetchall()
print(tat_ca)
# [('KH002', 'Trần Thị B', 12000000.0), ('KH004', ...), ...]
 
# Lấy MỘT dòng
cur.execute("SELECT * FROM KhachHang WHERE MaKH = ?", ("KH001",))
mot_dong = cur.fetchone()
print(mot_dong)                  # ('KH001', 'Nguyễn Văn A', 'Huế', 5000000.0, ...)
# Trả về None nếu không tìm thấy
 
# Lấy N dòng
cur.execute("SELECT * FROM KhachHang")
ba_dong = cur.fetchmany(3)
 
# Duyệt trực tiếp — tiết kiệm bộ nhớ nhất
cur.execute("SELECT MaKH, TenKH, CongNo FROM KhachHang")
for ma, ten, no in cur:
    print(f"{ma:<8}{ten:<22}{no:>15,.0f}")
 
conn.close()

4.3. Truy cập theo tên cột — sqlite3.Row

Mặc định kết quả là tuple, phải nhớ thứ tự cột. Cách tốt hơn:

conn = sqlite3.connect("ban_hang.db")
conn.row_factory = sqlite3.Row          # ⭐ Bật chế độ truy cập theo tên
 
cur = conn.cursor()
cur.execute("SELECT * FROM KhachHang WHERE Tinh = ?", ("Huế",))
 
for dong in cur:
    print(dong["MaKH"], dong["TenKH"], dong["CongNo"])    # Theo TÊN cột
 
# Chuyển sang list các dict — cấu trúc quen thuộc từ M09
cur.execute("SELECT * FROM KhachHang")
kq = [dict(d) for d in cur.fetchall()]
print(kq[0])
# {'MaKH': 'KH001', 'TenKH': 'Nguyễn Văn A', 'Tinh': 'Huế', 'CongNo': 5000000.0, ...}
 
conn.close()

Đây chính là cấu trúc bạn đã học ở 09 - Dict và Set

list các dict = result set. Sau khi lấy về, mọi kỹ thuật lọc, sắp xếp, group by bằng Python bạn đã học đều áp dụng được.

4.4. UPDATE và DELETE

conn = sqlite3.connect("ban_hang.db")
cur = conn.cursor()
 
# UPDATE
cur.execute(
    "UPDATE KhachHang SET CongNo = CongNo - ? WHERE MaKH = ?",
    (2_000_000, "KH001")
)
print(f"Da cap nhat {cur.rowcount} dong")
 
# DELETE
cur.execute("DELETE FROM KhachHang WHERE CongNo = 0 AND HoatDong = 0")
print(f"Da xoa {cur.rowcount} dong")
 
conn.commit()
conn.close()

cur.rowcount cho biết số dòng bị ảnh hưởng — tương đương @@ROWCOUNT trong T-SQL.


5. Transaction — Giao dịch

import sqlite3
 
conn = sqlite3.connect("ban_hang.db")
cur = conn.cursor()
 
try:
    # Nghiệp vụ: chuyển công nợ từ KH này sang KH khác
    cur.execute("UPDATE KhachHang SET CongNo = CongNo - ? WHERE MaKH = ?",
                (1_000_000, "KH001"))
    cur.execute("UPDATE KhachHang SET CongNo = CongNo + ? WHERE MaKH = ?",
                (1_000_000, "KH002"))
 
    conn.commit()                # Cả 2 lệnh cùng thành công
    print("Giao dich thanh cong")
 
except sqlite3.Error as e:
    conn.rollback()              # Có lỗi -> hủy TOÀN BỘ
    print(f"Giao dich that bai, da rollback: {e}")
 
finally:
    conn.close()

Khái niệm transaction giống hệt BEGIN TRAN / COMMIT / ROLLBACK trong T-SQL — bạn đã quen.


6. Hàm tiện ích tự viết

"""Lớp bọc đơn giản cho thao tác database SQLite."""
 
import sqlite3
from contextlib import contextmanager
from pathlib import Path
 
DUONG_DAN_DB = Path("ban_hang.db")
 
 
@contextmanager
def ket_noi(duong_dan=DUONG_DAN_DB):
    """
    Context manager quản lý kết nối database.
 
    Tự động commit khi thành công, rollback khi lỗi, và luôn đóng kết nối.
 
    Sử dụng:
        with ket_noi() as conn:
            cur = conn.cursor()
            cur.execute(...)
    """
    conn = sqlite3.connect(duong_dan)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA foreign_keys = ON")     # Bật ràng buộc khóa ngoại
    try:
        yield conn
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()
 
 
def truy_van(sql, tham_so=()):
    """
    Chạy câu SELECT, trả về list các dict.
 
    Tham số:
        sql (str): Câu SQL với placeholder ?.
        tham_so (tuple): Các giá trị thay cho placeholder.
 
    Trả về:
        list[dict]: Kết quả truy vấn.
    """
    with ket_noi() as conn:
        cur = conn.cursor()
        cur.execute(sql, tham_so)
        return [dict(d) for d in cur.fetchall()]
 
 
def truy_van_mot(sql, tham_so=()):
    """Chạy SELECT, trả về một dict hoặc None."""
    with ket_noi() as conn:
        cur = conn.cursor()
        cur.execute(sql, tham_so)
        dong = cur.fetchone()
        return dict(dong) if dong else None
 
 
def thuc_thi(sql, tham_so=()):
    """
    Chạy INSERT/UPDATE/DELETE.
 
    Trả về:
        int: Số dòng bị ảnh hưởng.
    """
    with ket_noi() as conn:
        cur = conn.cursor()
        cur.execute(sql, tham_so)
        return cur.rowcount
 
 
def thuc_thi_nhieu(sql, danh_sach_tham_so):
    """Chạy một câu lệnh với nhiều bộ tham số."""
    with ket_noi() as conn:
        cur = conn.cursor()
        cur.executemany(sql, danh_sach_tham_so)
        return cur.rowcount
 
 
# ==================== SỬ DỤNG ====================
if __name__ == "__main__":
    # Truy vấn
    kh_hue = truy_van(
        "SELECT MaKH, TenKH, CongNo FROM KhachHang WHERE Tinh = ? ORDER BY CongNo DESC",
        ("Huế",)
    )
    for kh in kh_hue:
        print(f"{kh['MaKH']:<8}{kh['TenKH']:<22}{kh['CongNo']:>15,.0f}")
 
    # Một dòng
    kh = truy_van_mot("SELECT * FROM KhachHang WHERE MaKH = ?", ("KH001",))
    if kh:
        print(f"\nTim thay: {kh['TenKH']}")
    else:
        print("\nKhong tim thay")
 
    # Cập nhật
    so_dong = thuc_thi(
        "UPDATE KhachHang SET CongNo = ? WHERE MaKH = ?",
        (0, "KH003")
    )
    print(f"Da cap nhat {so_dong} dong")

7. Kết nối SQL Server

7.1. Cài đặt

pip install pyodbc

Cần cài thêm ODBC Driver for SQL Server trên máy (tải từ trang Microsoft).

7.2. Kết nối

import pyodbc
 
chuoi_ket_noi = (
    "DRIVER={ODBC Driver 18 for SQL Server};"
    "SERVER=localhost;"
    "DATABASE=QuanLyBanHang;"
    "UID=sa;"
    "PWD=mat_khau;"
    "TrustServerCertificate=yes;"
)
 
conn = pyodbc.connect(chuoi_ket_noi)
cur = conn.cursor()
 
cur.execute("SELECT TOP 10 MaKH, TenKH, CongNo FROM KhachHang ORDER BY CongNo DESC")
for dong in cur:
    print(dong.MaKH, dong.TenKH, dong.CongNo)     # Truy cập theo tên cột
 
conn.close()

Windows Authentication (không cần user/password):

chuoi_ket_noi = (
    "DRIVER={ODBC Driver 18 for SQL Server};"
    "SERVER=localhost;"
    "DATABASE=QuanLyBanHang;"
    "Trusted_Connection=yes;"
    "TrustServerCertificate=yes;"
)

7.3. Gọi Stored Procedure

cur.execute("{CALL sp_BaoCaoCongNo (?, ?)}", ("2026-08-01", "2026-08-31"))
for dong in cur.fetchall():
    print(dong)

8. Kết nối PostgreSQL

8.1. Cài đặt

pip install "psycopg[binary]"

8.2. Kết nối

import psycopg
from psycopg.rows import dict_row
 
with psycopg.connect(
    host="localhost",
    port=5432,
    dbname="quanlybanhang",
    user="postgres",
    password="mat_khau",
    row_factory=dict_row,           # Trả về dict thay vì tuple
) as conn:
    with conn.cursor() as cur:
        cur.execute(
            "SELECT ma_kh, ten_kh, cong_no FROM khach_hang "
            "WHERE tinh = %s ORDER BY cong_no DESC LIMIT 10",
            ("Huế",)
        )
        for dong in cur:
            print(dong["ma_kh"], dong["ten_kh"], dong["cong_no"])

Chú ý placeholder của psycopg là %s, không phải ?.

psycopg xử lý Decimal đúng

PostgreSQL có kiểu NUMERIC, và psycopg tự chuyển thành Decimal của Python — không mất chính xác. Đây là ưu điểm lớn so với SQLite khi làm phần mềm tài chính.


9. Bảo mật thông tin kết nối

KHÔNG viết mật khẩu trực tiếp trong code

# ❌ TUYỆT ĐỐI KHÔNG
conn = psycopg.connect(password="MatKhau123!")

Code này khi đưa lên Git là mật khẩu lộ vĩnh viễn trong lịch sử commit.

Cách đúng — dùng biến môi trường:

Tạo file .env (và thêm vào .gitignore):

DB_HOST=localhost
DB_PORT=5432
DB_NAME=quanlybanhang
DB_USER=postgres
DB_PASSWORD=MatKhauThat123!
pip install python-dotenv
import os
from dotenv import load_dotenv
import psycopg
 
load_dotenv()          # Nạp file .env vào biến môi trường
 
conn = psycopg.connect(
    host=os.getenv("DB_HOST"),
    port=os.getenv("DB_PORT"),
    dbname=os.getenv("DB_NAME"),
    user=os.getenv("DB_USER"),
    password=os.getenv("DB_PASSWORD"),
)

Kèm theo file mẫu .env.example (được commit lên Git):

DB_HOST=localhost
DB_PORT=5432
DB_NAME=ten_database
DB_USER=ten_user
DB_PASSWORD=

10. Chương trình mẫu hoàn chỉnh

"""
Hệ thống quản lý khách hàng với SQLite.
Tổng hợp toàn bộ kiến thức từ M02 đến M16.
"""
 
import sqlite3
from contextlib import contextmanager
from datetime import date
from decimal import Decimal
from pathlib import Path
 
DB = Path("khach_hang.db")
 
 
# ==================== TẦNG DATABASE ====================
 
@contextmanager
def ket_noi():
    """Context manager quản lý kết nối."""
    conn = sqlite3.connect(DB)
    conn.row_factory = sqlite3.Row
    try:
        yield conn
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()
 
 
def khoi_tao_db():
    """Tạo bảng nếu chưa tồn tại."""
    with ket_noi() as conn:
        conn.execute("""
            CREATE TABLE IF NOT EXISTS KhachHang (
                MaKH     TEXT PRIMARY KEY,
                TenKH    TEXT NOT NULL,
                Tinh     TEXT,
                CongNo   TEXT DEFAULT '0',
                NgayTao  TEXT NOT NULL,
                HoatDong INTEGER DEFAULT 1
            )
        """)
        conn.execute("""
            CREATE INDEX IF NOT EXISTS idx_kh_tinh ON KhachHang(Tinh)
        """)
 
 
# ==================== TẦNG NGHIỆP VỤ ====================
 
def them_khach_hang(ma, ten, tinh, cong_no=Decimal("0")):
    """Thêm khách hàng mới. Ném ValueError nếu mã đã tồn tại."""
    if not ma or not ma.strip():
        raise ValueError("Ma khach hang khong duoc de trong")
    if not ten or not ten.strip():
        raise ValueError("Ten khach hang khong duoc de trong")
 
    try:
        with ket_noi() as conn:
            conn.execute(
                "INSERT INTO KhachHang (MaKH, TenKH, Tinh, CongNo, NgayTao) "
                "VALUES (?, ?, ?, ?, ?)",
                (ma.strip().upper(), ten.strip(), tinh.strip(),
                 str(cong_no), date.today().isoformat())
            )
    except sqlite3.IntegrityError:
        raise ValueError(f"Ma khach hang '{ma}' da ton tai")
 
 
def lay_tat_ca(chi_hoat_dong=True):
    """Lấy danh sách khách hàng."""
    sql = "SELECT * FROM KhachHang"
    tham_so = ()
    if chi_hoat_dong:
        sql += " WHERE HoatDong = ?"
        tham_so = (1,)
    sql += " ORDER BY MaKH"
 
    with ket_noi() as conn:
        return [dict(d) for d in conn.execute(sql, tham_so).fetchall()]
 
 
def tim_theo_ma(ma):
    """Tìm khách hàng theo mã, trả về None nếu không có."""
    with ket_noi() as conn:
        dong = conn.execute(
            "SELECT * FROM KhachHang WHERE MaKH = ?", (ma.strip().upper(),)
        ).fetchone()
        return dict(dong) if dong else None
 
 
def tim_theo_ten(tu_khoa):
    """Tìm khách hàng theo tên (tương đương LIKE '%...%')."""
    with ket_noi() as conn:
        rows = conn.execute(
            "SELECT * FROM KhachHang WHERE TenKH LIKE ? ORDER BY TenKH",
            (f"%{tu_khoa.strip()}%",)
        ).fetchall()
        return [dict(r) for r in rows]
 
 
def cap_nhat_cong_no(ma, so_tien_thay_doi):
    """Cộng/trừ công nợ. Ném ValueError nếu KH không tồn tại."""
    kh = tim_theo_ma(ma)
    if kh is None:
        raise ValueError(f"Khong tim thay khach hang '{ma}'")
 
    moi = Decimal(kh["CongNo"]) + Decimal(str(so_tien_thay_doi))
    if moi < 0:
        raise ValueError(f"Cong no khong the am (se thanh {moi:,})")
 
    with ket_noi() as conn:
        conn.execute("UPDATE KhachHang SET CongNo = ? WHERE MaKH = ?",
                     (str(moi), ma.strip().upper()))
    return moi
 
 
def thong_ke_theo_tinh():
    """Tổng hợp số lượng và công nợ theo tỉnh."""
    with ket_noi() as conn:
        rows = conn.execute("""
            SELECT Tinh,
                   COUNT(*)                      AS SoLuong,
                   SUM(CAST(CongNo AS REAL))     AS TongNo
            FROM KhachHang
            WHERE HoatDong = 1
            GROUP BY Tinh
            ORDER BY TongNo DESC
        """).fetchall()
        return [dict(r) for r in rows]
 
 
# ==================== TẦNG HIỂN THỊ ====================
 
def in_danh_sach(ds):
    """In bảng danh sách khách hàng."""
    if not ds:
        print("  (Khong co du lieu)")
        return
 
    print(f"{'MA':<10}{'TEN KHACH HANG':<28}{'TINH':<12}{'CONG NO':>16}{'NGAY TAO':>13}")
    print("-" * 79)
    tong = Decimal("0")
    for kh in ds:
        no = Decimal(kh["CongNo"])
        tong += no
        print(f"{kh['MaKH']:<10}{kh['TenKH']:<28}{kh['Tinh'] or '':<12}"
              f"{no:>16,.0f}{kh['NgayTao']:>13}")
    print("-" * 79)
    print(f"{'TONG CONG':<50}{tong:>16,.0f}")
 
 
def in_thong_ke():
    """In bảng thống kê theo tỉnh."""
    tk = thong_ke_theo_tinh()
    print(f"{'TINH':<14}{'SO KH':>8}{'TONG CONG NO':>20}")
    print("-" * 42)
    for d in tk:
        print(f"{d['Tinh'] or '(Khong ro)':<14}{d['SoLuong']:>8}{d['TongNo'] or 0:>20,.0f}")
 
 
# ==================== CHƯƠNG TRÌNH CHÍNH ====================
 
def nap_du_lieu_mau():
    """Nạp dữ liệu mẫu nếu database đang rỗng."""
    if lay_tat_ca(chi_hoat_dong=False):
        return
    mau = [
        ("KH001", "Cong ty TNHH An Phat", "Hue", Decimal("45800000")),
        ("KH002", "Cong ty CP Binh Minh", "TP HCM", Decimal("12500000")),
        ("KH003", "DNTN Cuong Thinh", "Hue", Decimal("0")),
        ("KH004", "Cong ty TNHH Dai Loi", "Ha Noi", Decimal("89200000")),
        ("KH005", "HTX Nong nghiep E", "Hue", Decimal("3200000")),
    ]
    for ma, ten, tinh, no in mau:
        them_khach_hang(ma, ten, tinh, no)
    print("Da nap du lieu mau\n")
 
 
def menu():
    """Vòng lặp menu chính."""
    while True:
        print()
        print("=" * 45)
        print("  QUAN LY KHACH HANG")
        print("=" * 45)
        print("  1. Xem danh sach")
        print("  2. Tim theo ma")
        print("  3. Tim theo ten")
        print("  4. Them khach hang")
        print("  5. Cap nhat cong no")
        print("  6. Thong ke theo tinh")
        print("  0. Thoat")
        print("=" * 45)
 
        chon = input("Chon chuc nang: ").strip()
 
        try:
            if chon == "1":
                in_danh_sach(lay_tat_ca())
 
            elif chon == "2":
                ma = input("Nhap ma KH: ")
                kh = tim_theo_ma(ma)
                if kh:
                    in_danh_sach([kh])
                else:
                    print("Khong tim thay khach hang nay")
 
            elif chon == "3":
                tu = input("Nhap tu khoa ten: ")
                in_danh_sach(tim_theo_ten(tu))
 
            elif chon == "4":
                ma = input("Ma KH: ")
                ten = input("Ten KH: ")
                tinh = input("Tinh: ")
                no = input("Cong no ban dau (Enter = 0): ").strip() or "0"
                them_khach_hang(ma, ten, tinh, Decimal(no))
                print("Da them khach hang")
 
            elif chon == "5":
                ma = input("Ma KH: ")
                tien = input("So tien thay doi (am = giam): ")
                moi = cap_nhat_cong_no(ma, tien)
                print(f"Cong no moi: {moi:,.0f}")
 
            elif chon == "6":
                in_thong_ke()
 
            elif chon == "0":
                print("Tam biet!")
                break
 
            else:
                print("Lua chon khong hop le")
 
        except ValueError as e:
            print(f"Loi: {e}")
        except sqlite3.Error as e:
            print(f"Loi database: {e}")
 
 
def main():
    khoi_tao_db()
    nap_du_lieu_mau()
    menu()
 
 
if __name__ == "__main__":
    main()

11. Thực hành bắt buộc

Bài 1 — Tạo database

Tạo file hoc_sql.db với bảng SanPham (MaSP, TenSP, DonViTinh, DonGia, SoLuongTon, NgayCapNhat). Chèn 10 sản phẩm.

Bài 2 — CRUD đầy đủ

Với bảng ở bài 1, viết các hàm:

  • them_san_pham(...)
  • sua_san_pham(ma, **thay_doi)
  • xoa_san_pham(ma)
  • lay_tat_ca()
  • tim_theo_ma(ma)

Tất cả dùng parameterized query.

Bài 3 — Truy vấn tổng hợp

Viết các hàm truy vấn trả về:

  1. Tổng giá trị tồn kho
  2. 5 sản phẩm giá trị tồn cao nhất
  3. Sản phẩm sắp hết hàng (tồn < 10)
  4. Số sản phẩm theo từng đơn vị tính (GROUP BY)
  5. Giá trị tồn trung bình

Bài 4 — SQL Injection

  1. Viết một hàm tìm kiếm dùng nối chuỗi (cách sai)
  2. Thử nhập ' OR '1'='1 để lấy toàn bộ dữ liệu
  3. Thử nhập '; DROP TABLE SanPham; -- (làm trên database sao lưu!)
  4. Viết lại bằng parameterized query
  5. Thử lại các input trên, xác nhận đã an toàn
  6. Ghi kết luận vào nhật ký

Bài 5 — Transaction

Viết hàm chuyen_kho(ma_sp, tu_kho, den_kho, so_luong) thực hiện 2 lệnh UPDATE trong một transaction. Cố tình gây lỗi ở lệnh thứ 2, xác nhận rollback hoạt động.

Bài 6 — Nhập dữ liệu từ CSV vào database

Kết hợp 13 - Làm việc với File và module này:

  1. Đọc file CSV sản phẩm
  2. Kiểm tra dữ liệu hợp lệ
  3. Chèn vào database bằng executemany
  4. Báo cáo: số dòng thành công, số dòng lỗi và lý do

Bài 7 — Xuất từ database ra CSV

Truy vấn dữ liệu, xuất ra file CSV mở được bằng Excel tiếng Việt.

Bài 8 — Kết nối database thật (nếu có điều kiện)

Nếu bạn có SQL Server hoặc PostgreSQL sẵn ở công ty:

  1. Cài pyodbc hoặc psycopg trong môi trường ảo
  2. Kết nối tới database chỉ đọc (tuyệt đối không thao tác trên production)
  3. Chạy một câu SELECT TOP 10 đơn giản
  4. So sánh code với phiên bản SQLite — khác nhau chỗ nào?

Cảnh báo an toàn

Chỉ thực hành trên database DEV/TEST. Không bao giờ chạy script đang học trên hệ thống production của khách hàng.


12. Tự kiểm tra

  • Tôi hiểu luồng connect → cursor → execute → fetch → commit → close
  • Tôi biết vì sao bắt buộc dùng parameterized query
  • Tôi giải thích được SQL Injection và cách phòng chống
  • Tôi nhớ phải gọi commit() sau INSERT/UPDATE/DELETE
  • Tôi biết dùng sqlite3.Row để truy cập theo tên cột
  • Tôi biết chuyển result set thành list các dict
  • Tôi biết dùng transaction với try/except/rollback
  • Tôi biết không được viết mật khẩu trong code
  • Tôi biết placeholder khác nhau giữa các thư viện (? vs %s)

13. Tài liệu chính thức

NguồnNội dungLink
PEP 249 — DB-API 2.0Đặc tả chuẩn database Pythonhttps://peps.python.org/pep-0249/
sqlite3 moduleTài liệu SQLite trong Pythonhttps://docs.python.org/3/library/sqlite3.html
sqlite3 — PlaceholdersHướng dẫn parameterized queryhttps://docs.python.org/3/library/sqlite3.html#sqlite3-placeholders
SQLite DocumentationTài liệu SQLite gốchttps://www.sqlite.org/docs.html
psycopg 3Thư viện PostgreSQLhttps://www.psycopg.org/psycopg3/docs/
pyodbc wikiThư viện ODBC (SQL Server)https://github.com/mkleehammer/pyodbc/wiki
contextlibTạo context managerhttps://docs.python.org/3/library/contextlib.html

Tiếp theo → 17 - Dự án tổng kết