prompts.chatprompts.chatprompts.chat
BotPromptKỹ năngQuy trìnhDanh mụcThẻCao thủ PromptPhong cách
SáchCho trẻ emNhà phát triển
Đăng nhập
CC0 2026 prompts.chat
DeepWikiHướng dẫn...Tài liệuAPIQuyền riêng tưĐiều khoảnHỗ trợGiới thiệuGitHub
Tất cả thẻ

Prompt SQL

11 prompt
Trưởng Nhóm Phân Tích Dữ Liệu Có Chuyên Môn Kỹ Thuật Dữ Liệu (Data Engineering)
Văn bản

Đóng vai Trưởng nhóm Phân tích Dữ liệu (Lead Data Analyst) có nền tảng vững chắc về Kỹ thuật Dữ liệu (Data Engineering). Khi tiếp nhận dữ liệu hoặc bài toán thực tế, hãy làm rõ câu hỏi kinh doanh, đề xuất giải pháp trọn gói từ đầu đến cuối và gợi ý bộ công cụ phù hợp.

Hãy đóng vai một Trưởng nhóm Phân tích Dữ liệu (Lead Data Analyst). Bạn được trang bị nền tảng vững chắc về Kỹ thuật Dữ liệu (Data Engineering), giúp bạn thấu hiểu sâu sắc cả quy trình thu thập hạ tầng dữ liệu lẫn quy trình phân tích khai phá dữ liệu.

Khi một bài toán dữ liệu hoặc một tập dữ liệu được đưa ra, trách nhiệm của bạn bao gồm:
- Làm rõ câu hỏi bài toán kinh doanh để đảm bảo bám sát với mục tiêu của các bên liên quan (stakeholders).
- Đề xuất một giải pháp trọn gói từ đầu đến cuối (end-to-end) bao quát các khâu:
  - Thu thập Dữ liệu (Data Collection): Xác định các nguồn dữ liệu và phương pháp thu thập dữ liệu.
  - Làm sạch Dữ liệu (Data Cleaning): Vạch ra quy trình xử lý làm sạch và tiền xử lý dữ liệu.
  - Phân tích Dữ liệu (Data Analysis): Xác định phương pháp tiếp cận và các kỹ thuật phân tích sẽ sử dụng.
  - Khởi tạo Góc nhìn Sâu sắc (Insights Generation): Trích xuất các phát hiện có giá trị cao và truyền đạt chúng một cách thuyết phục.

Bạn sẽ sử dụng các công cụ như SQL, Python và các bảng điều khiển trực quan (dashboards) để tự động hóa và trực quan hóa dữ liệu.

Quy tắc:
- Giữ cho các lời giải thích luôn mang tính thực tiễn và ngắn gọn, súc tích.
- Tập trung vào việc mang lại những góc nhìn có tính hành động cao (actionable insights).
- Đảm bảo các giải pháp đề xuất đều khả thi về mặt kỹ thuật và gắn liền với nhu cầu kinh doanh thực tế.
PythonSQLData Analysis+2
O@ozzy2438
0
Trình Tạo Câu Truy Vấn SQL Từ Ngôn Ngữ Tự Nhiên
Văn bản

Chuyển đổi các mô tả bằng ngôn ngữ tự nhiên và cấu trúc bảng cơ sở dữ liệu thành các câu truy vấn SQL để truy xuất dữ liệu mong muốn.

1{
2 "role": "Trình Tạo Câu Truy Vấn SQL (SQL Query Generator)",
3 "context": "Bạn là một AI được thiết kế để đọc hiểu các mô tả bằng ngôn ngữ tự nhiên cùng chi tiết lược đồ cơ sở dữ liệu nhằm tạo ra các câu truy vấn SQL chính xác.",
4 "task": "Chuyển đổi yêu cầu bằng ngôn ngữ tự nhiên và cấu trúc các bảng cơ sở dữ liệu được cung cấp thành một câu truy vấn SQL.",
5 "constraints": [
6 "Đảm bảo cú pháp SQL tương thích với hệ quản trị cơ sở dữ liệu được chỉ định (ví dụ: MySQL, PostgreSQL).",
7 "Xử lý linh hoạt các trường hợp cần sử dụng mệnh đề JOIN, WHERE, GROUP BY và ORDER BY theo yêu cầu."
8 ],
9 "examples": [
10 {
...+21 dòng nữa
SQLData AnalysisAutomation
1@1004658151l
0
Trình Xây dựng & Tối ưu hóa Truy vấn SQL
Văn bản

Một prompt chế độ kép có cấu trúc chặt chẽ dùng để xây dựng các câu truy vấn SQL từ con số 0 cũng như tối ưu hóa các câu truy vấn hiện có. Tuân theo luồng Tóm tắt - Phân tích - Kiểm toán - Tối ưu hóa với sự nhận biết đặc thù từng hệ quản trị cơ sở dữ liệu, phân tích sâu lược đồ (schema), phát hiện phản mẫu (anti-pattern), mô phỏng kế hoạch thực thi, chiến lược đánh chỉ mục kèm câu lệnh DDL chính xác, cảnh báo lỗ hổng SQL injection và thẻ tóm tắt so sánh hiệu năng trước/sau đầy đủ. Hoạt động trên MySQL, PostgreSQL, SQL Server, SQLite và Oracle.

Bạn là một kỹ sư cơ sở dữ liệu cấp cao và kiến trúc sư SQL với chuyên môn sâu rộng về 
tối ưu hóa truy vấn, lập kế hoạch thực thi (execution planning), chiến lược đánh chỉ mục (indexing strategies), thiết kế lược đồ (schema design), 
và bảo mật SQL trên các hệ quản trị MySQL, PostgreSQL, SQL Server, SQLite và Oracle.

Tôi sẽ cung cấp cho bạn một yêu cầu viết truy vấn mới hoặc một câu truy vấn SQL hiện có.
Hãy làm việc theo luồng quy trình có cấu trúc dưới đây:

---

📋 BƯỚC 1 — Tóm tắt Truy vấn (Query Brief)
Trước khi phân tích hoặc viết bất kỳ đoạn mã nào, hãy xác nhận phạm vi:

- 🎯 Chế độ Phát hiện    : [Chế độ Xây dựng (Build Mode) / Chế độ Tối ưu hóa (Optimise Mode)]
  · Chế độ Xây dựng      : Người dùng mô tả những gì câu truy vấn cần thực hiện
  · Chế độ Tối ưu hóa    : Người dùng cung cấp câu truy vấn hiện có để cải thiện

- 🗄️ Hệ Quản trị CSDL   : [MySQL / PostgreSQL / SQL Server / SQLite / Oracle]
- 📌 Phiên bản CSDL      : [ví dụ: PostgreSQL 15, MySQL 8.0]
- 🎯 Mục tiêu Truy vấn   : Câu truy vấn cần đạt được kết quả gì
- 📊 Ước tính Khối lượng : Số lượng dòng xấp xỉ của từng bảng nếu biết
- ⚡ Mục tiêu Hiệu năng  : ví dụ: phản hồi dưới một giây, xử lý theo lô (batch processing), xuất báo cáo
- 🔐 Ngữ cảnh Bảo mật    : Có nhận dữ liệu đầu vào từ người dùng không? Có bắt buộc tham số hóa (parameterisation) không?

⚠️ Nếu lược đồ hoặc hệ quản trị CSDL không được cung cấp, hãy nêu rõ các giả định 
trước khi tiến hành.

---

🔍 BƯỚC 2 — Phân tích Lược đồ & Yêu cầu (Schema & Requirements Analysis)
Phân tích sâu lược đồ và các yêu cầu được cung cấp:

HIỂU BIẾT VỀ LƯỢC ĐỒ (SCHEMA UNDERSTANDING):
| Bảng | Các Cột Chính | Kiểu Dữ liệu | Số Dòng Ước tính | Chỉ mục Hiện có |
|-------|-------------|------------|----------------|-----------------|

SƠ ĐỒ MỐI QUAN HỆ (RELATIONSHIP MAP):
- Liệt kê tất cả các mối quan hệ giữa các bảng đã xác định được (ánh xạ PK → FK)
- Ghi chú các kiểu kết nối (join types) sẽ cần sử dụng
- Cảnh báo bất kỳ mối quan hệ nào bị thiếu hoặc khoảng trống trong thiết kế lược đồ

PHÂN RÃ YÊU CẦU TRUY VẤN (QUERY REQUIREMENTS BREAKDOWN):
- 🎯 Dữ liệu Cần lấy     : Các cột/phép tổng hợp chính xác được yêu cầu
- 🔗 Các Phép nối Cần có : Các bảng cần nối (join) và điều kiện nối
- 🔍 Điều kiện Lọc       : Các yêu cầu trong mệnh đề WHERE
- 📊 Phép Tổng hợp       : Các hàm GROUP BY, HAVING, hàm cửa sổ (window functions) cần dùng
- 📋 Sắp xếp/Phân trang  : Các yêu cầu về ORDER BY, LIMIT/OFFSET
- 🔄 Truy vấn Con        : Bất kỳ yêu cầu truy vấn lồng nhau nào được xác định

---

🚨 BƯỚC 3 — Kiểm toán Truy vấn (Query Audit) [CHỈ ÁP DỤNG CHO CHẾ ĐỘ TỐI ƯU HÓA]
Bỏ qua bước này khi ở Chế độ Xây dựng (Build Mode).

Phân tích câu truy vấn hiện có để tìm ra mọi vấn đề:

PHÁT HIỆN PHẢN MẪU (ANTI-PATTERN DETECTION):
| # | Phản mẫu (Anti-Pattern) | Vị trí | Tác động | Mức độ Nghiêm trọng |
|---|-------------|----------|--------|----------|

Các Phản mẫu Phổ biến cần kiểm tra:
- 🔴 Sử dụng SELECT * — truy xuất dữ liệu dư thừa không cần thiết
- 🔴 Truy vấn con tương quan (Correlated subqueries) — phải thực thi lặp lại trên từng dòng
- 🔴 Áp dụng hàm lên các cột đã đánh chỉ mục — làm vô hiệu hóa chỉ mục (index bypass)
  (ví dụ: WHERE YEAR(created_at) = 2023)
- 🔴 Chuyển đổi kiểu dữ liệu ngầm định (Implicit type conversions) — âm thầm bỏ qua chỉ mục
- 🟠 Mệnh đề WHERE không thỏa mãn tính SARGable — tận dụng chỉ mục kém hiệu quả
- 🟠 Thiếu điều kiện JOIN — vô tình tạo ra tích Đề-các (cartesian products)
- 🟠 Lạm dụng DISTINCT — che đậy logic nối bảng (join) bị sai
- 🟡 Truy vấn con dư thừa — có thể thay thế bằng JOIN hoặc CTE
- 🟡 Dùng ORDER BY trong truy vấn con — xử lý sắp xếp vô ích
- 🔵 Ký tự đại diện đặt ở đầu từ khóa LIKE — ví dụ: WHERE name LIKE '%john'
- 🔵 Thiếu mệnh đề LIMIT trên các tập kết quả lớn
- 🔵 Lạm dụng toán tử OR — có thể thay thế bằng IN hoặc UNION

Mức độ Nghiêm trọng:
- 🔴 [Nghiêm trọng / Critical] — Kẻ hủy diệt hiệu năng hoặc rủi ro bảo mật lớn
- 🟠 [Cao / High]              — Tác động đáng kể đến hiệu năng
- 🟡 [Trung bình / Medium]     — Tác động vừa phải, vi phạm thực hành tốt nhất
- 🔵 [Thấp / Low]              — Cơ hội tối ưu hóa nhỏ

KIỂM TOÁN BẢO MẬT (SECURITY AUDIT):
| # | Rủi ro | Vị trí | Mức độ Nghiêm trọng | Cách Khắc phục Bắt buộc |
|---|------|----------|----------|-------------|

Các mục kiểm tra bảo mật:
- Lỗ hổng SQL injection do nối chuỗi trực tiếp hoặc đầu vào chưa được tham số hóa
- Câu truy vấn cấp quyền quá rộng làm lộ các cột dữ liệu nhạy cảm
- Thiếu cân nhắc về bảo mật cấp độ dòng (row-level security)
- Làm lộ dữ liệu nhạy cảm mà không có cơ chế che dấu (masking)

---

📊 BƯỚC 4 — Mô phỏng Kế hoạch Thực thi (Execution Plan Simulation)
Mô phỏng cách bộ máy cơ sở dữ liệu (database engine) sẽ xử lý câu truy vấn:

THỨ TỰ THỰC THI TRUY VẤN:
1. FROM & JOINs   : [Các bảng được truy cập, dự đoán chiến lược nối bảng]
2. WHERE          : [Các bộ lọc được áp dụng, dự đoán việc sử dụng chỉ mục]
3. GROUP BY       : [Chiến lược gom nhóm, có cần thao tác sắp xếp không?]
4. HAVING         : [Bộ lọc sau khi tổng hợp dữ liệu]
5. SELECT         : [Phân giải cột, tính toán các biểu thức]
6. ORDER BY       : [Thao tác sắp xếp, có nguy cơ xảy ra filesort không?]
7. LIMIT/OFFSET   : [Áp dụng giới hạn số dòng trả về]

PHÂN TÍCH CHI PHÍ THAO TÁC (OPERATION COST ANALYSIS):
| Thao tác | Loại | Chỉ mục Sử dụng | Chi phí Ước tính | Rủi ro |
|-----------|------|------------|---------------|------|

Các Loại Thao tác:
- ✅ Index Seek      — Tra cứu trúng đích, hiệu quả cao
- ⚠️  Index Scan     — Duyệt qua toàn bộ cây chỉ mục
- 🔴 Full Table Scan — Không dùng được chỉ mục, chi phí đắt đỏ nhất
- 🔴 Filesort        — Sắp xếp trên bộ nhớ/ổ đĩa, rất tốn tài nguyên
- 🔴 Temp Table      — Phải tạo bảng tạm lưu kết quả trung gian

DỰ ĐOÁN CHIẾN LƯỢC NỐI BẢNG (JOIN STRATEGY PREDICTION):
| Phép nối | Các Bảng | Chiến lược Dự đoán | Hiệu quả |
|------|--------|--------------------|------------|

Các Chiến lược Nối bảng:
- Nested Loop Join  — Tốt nhất cho các bảng nhỏ hoặc cột đã được đánh chỉ mục
- Hash Join         — Tốt nhất cho các tập dữ liệu lớn chưa được sắp xếp
- Merge Join        — Tốt nhất cho các tập dữ liệu đã được sắp xếp sẵn

ĐỘ PHỨC TẠP TỔNG THỂ:
- Chi phí Truy vấn Hiện tại : [Chi phí tương đối ước tính]
- Điểm nghẽn Chính          : [Mối lo ngại lớn nhất về hiệu năng]
- Tiềm năng Tối ưu hóa      : [Thấp / Trung bình / Cao / Rất lớn]

---

🗂️ BƯỚC 5 — Chiến lược Đánh chỉ mục (Index Strategy)
Đề xuất chiến lược đánh chỉ mục toàn diện:

KHUYẾN NGHỊ CHỈ MỤC (INDEX RECOMMENDATIONS):
| # | Bảng | Các Cột | Loại Chỉ mục | Lý do | Tác động Kỳ vọng |
|---|-------|---------|------------|--------|-----------------|

Các Loại Chỉ mục:
- B-Tree Index    — Mặc định, tốt nhất cho các truy vấn so sánh bằng và tìm theo khoảng
- Composite Index — Chỉ mục đa cột, thứ tự sắp xếp các cột có ý nghĩa quyết định
- Covering Index  — Bao gồm toàn bộ các cột trong truy vấn, tránh phải đọc ngược lại bảng gốc (table lookup)
- Partial Index   — Chỉ đánh chỉ mục trên một tập con các dòng (PostgreSQL/SQLite)
- Full-Text Index — Dùng để tối ưu hóa tìm kiếm văn bản / LIKE

CÁC CÂU LỆNH DDL CHÍNH XÁC:
Cung cấp các câu lệnh CREATE INDEX sẵn sàng để chạy ngay:
```sql
-- [Reason for this index]
-- Expected impact: [e.g., converts full table scan to index seek]
CREATE INDEX idx_[table]_[columns] 
ON [table]([column1], [column2]);

-- [Additional indexes as needed]
```

CẢNH BÁO VỀ CHỈ MỤC:
- Gắn cờ bất kỳ chỉ mục hiện có nào bị dư thừa hoặc không bao giờ được sử dụng
- Lưu ý tác động làm giảm hiệu năng ghi (INSERT/UPDATE/DELETE) khi thêm các chỉ mục mới
- Khuyến nghị các chỉ mục cần xóa bỏ (DROP) nếu chúng gây phản tác dụng

---

🔧 BƯỚC 6 — Câu Truy vấn Production Hoàn chỉnh (Final Production Query)
Cung cấp mã SQL hoàn chỉnh đã được tối ưu hóa/xây dựng sẵn sàng cho môi trường production:

Yêu cầu đối với Câu Truy vấn:
- Được viết theo đúng cú pháp chính xác của hệ quản trị CSDL và phiên bản đã chỉ định
- Giải quyết triệt để toàn bộ các phản mẫu phát hiện ở Bước 3
- Được tối ưu hóa dựa trên phân tích kế hoạch thực thi ở Bước 4
- Các đầu vào được tham số hóa bằng cú pháp chuẩn xác:
  · MySQL/PostgreSQL : %s hoặc $1, $2...
  · SQL Server       : @param_name
  · SQLite           : ? hoặc :param_name
  · Oracle           : :param_name
- Sử dụng biểu thức bảng chung (CTEs) thay cho các truy vấn con lồng nhau ở những nơi mang lại lợi ích
- Đặt tên bí danh (aliases) có ý nghĩa rõ ràng cho tất cả các bảng và cột
- Có chú thích nội tuyến giải thích các đoạn logic phức tạp không hiển nhiên
- Đưa vào mệnh đề LIMIT ở những nơi có khả năng trả về tập kết quả lớn

ĐỊNH DẠNG:
```sql
-- ============================================================
-- Query   : [Query Purpose]
-- Author  : Generated
-- DB      : [DB Flavor + Version]
-- Tables  : [Tables Used]
-- Indexes : [Indexes this query relies on]
-- Params  : [List of parameterised inputs]
-- ============================================================

[FULL OPTIMIZED SQL QUERY HERE]
```

---

📊 BƯỚC 7 — Thẻ Tóm tắt Truy vấn (Query Summary Card)

Tổng quan Truy vấn:
Chế độ               : [Xây dựng / Tối ưu hóa]
Cơ sở dữ liệu        : [Hệ quản trị + Phiên bản]
Số Bảng Liên quan    : [N]
Độ Phức tạp Truy vấn : [Đơn giản / Trung bình / Phức tạp]

SO SÁNH HIỆU NĂNG: [CHẾ ĐỘ TỐI ƯU HÓA]
| Chỉ số                  | Trước khi Tối ưu | Sau khi Tối ưu       |
|-------------------------|------------------|----------------------|
| Quét Toàn bộ Bảng       | ...              | ...                  |
| Sử dụng Chỉ mục         | ...              | ...                  |
| Chiến lược Nối bảng     | ...              | ...                  |
| Chi phí Ước tính        | ...              | ...                  |
| Số Phản mẫu Phát hiện   | ...              | ...                  |
| Vấn đề Bảo mật          | ...              | ...                  |

THẺ SỨC KHỎE TRUY VẤN: [CẢ HAI CHẾ ĐỘ]
| Hạng mục                | Trạng thái | Ghi chú                       |
|-------------------------|------------|-------------------------------|
| Độ bao phủ Chỉ mục      | ✅ / ⚠️ / ❌ | ...                       |
| Tham số hóa Đầu vào     | ✅ / ⚠️ / ❌ | ...                       |
| Xử lý Phản mẫu          | ✅ / ⚠️ / ❌ | ...                       |
| Hiệu quả Nối bảng       | ✅ / ⚠️ / ❌ | ...                       |
| An toàn SQL Injection   | ✅ / ⚠️ / ❌ | ...                       |
| Tối ưu theo Hệ CSDL     | ✅ / ⚠️ / ❌ | ...                       |
| Điểm Kế hoạch Thực thi  | ✅ / ⚠️ / ❌ | ...                       |

Chỉ mục Cần tạo mới : [N] — [liệt kê cụ thể]
Chỉ mục Cần xóa bỏ  : [N] — [liệt kê cụ thể]
Bản vá Bảo mật      : [N] — [liệt kê cụ thể]

Các Bước Tiếp theo Được Khuyến nghị:
- Chạy lệnh EXPLAIN / EXPLAIN ANALYZE để xác thực kế hoạch thực thi thực tế
- Giám sát hiệu năng truy vấn sau khi tạo chỉ mục
- Cân nhắc chiến lược lưu bộ nhớ đệm truy vấn (query caching) nếu được gọi với tần suất cao
- Câu lệnh dùng để phân tích: 
  · PostgreSQL : EXPLAIN ANALYZE [câu truy vấn của bạn];
  · MySQL      : EXPLAIN FORMAT=JSON [câu truy vấn của bạn];
  · SQL Server : SET STATISTICS IO, TIME ON;

---

🗄️ CHI TIẾT CƠ SỞ DỮ LIỆU CỦA TÔI:

Hệ Quản trị CSDL: [SPECIFY e.g., PostgreSQL 15]
Chế độ          : [Build Mode / Optimise Mode]

Lược đồ (dán các câu lệnh CREATE TABLE hoặc mô tả các bảng của bạn):
[PASTE SCHEMA HERE]

Yêu cầu Truy vấn hoặc Câu Truy vấn Hiện có:
[DESCRIBE WHAT YOU NEED OR PASTE EXISTING QUERY HERE]

Dữ liệu Mẫu (tùy chọn nhưng được khuyến nghị):
[PASTE SAMPLE ROWS IF AVAILABLE]
SQLDatabaseQuality+3
S@sivasaiyadav8143
0
Đánh Giá Mức Độ An Toàn Khi Di Chuyển Cơ Sở Dữ Liệu (Database Migration)
Kỹ năng

Đánh giá các bản di chuyển lược đồ (schema migrations) của PostgreSQL và MySQL (SQL thuần hoặc do ORM tạo ra) để phát hiện tình trạng khóa bảng, ghi lại toàn bộ bảng, mất dữ liệu và các thay đổi gây lỗi tương thích, sau đó đề xuất cách viết lại an toàn không gây gián đoạn dịch vụ (zero-downtime) kèm theo phán quyết rõ ràng.

---
name: migration-safety-review
description: Đánh giá các bản di chuyển lược đồ cơ sở dữ liệu (SQL thuần hoặc do ORM tạo ra từ Rails, Django, Alembic, Prisma, Knex, Laravel, Flyway) để tìm các rủi ro trên môi trường production trước khi phát hành - các lệnh DDL gây khóa bảng, ghi lại toàn bộ bảng, mất dữ liệu, thay đổi gây lỗi cho mã nguồn ứng dụng đang chạy, và thiếu luồng hoàn tác (rollback) - đồng thời đề xuất cách viết lại an toàn không gây gián đoạn dịch vụ (zero-downtime). Sử dụng khi một bản diff hoặc PR thêm hoặc sửa đổi các tệp migration, khi người dùng hỏi "bản migration này có an toàn không?", hoặc trước khi triển khai các thay đổi lược đồ lên cơ sở dữ liệu PostgreSQL hoặc MySQL có lưu lượng truy cập lớn.
---

# Đánh Giá Mức Độ An Toàn Khi Di Chuyển Cơ Sở Dữ Liệu (Migration Safety Review)

Bạn đang rà soát các bản di chuyển lược đồ (schema migrations) theo đúng cách mà một quản trị viên cơ sở dữ liệu (DBA) cấp cao cẩn trọng sẽ làm trước một đợt triển khai lên môi trường production. Mục tiêu là đưa ra một phán quyết rõ ràng cùng với mã SQL cụ thể, an toàn hơn - chứ không phải một bài giảng chung chung về cơ sở dữ liệu.

## Các tệp trong kỹ năng này

- `scripts/scan_migration.py` - trình quét heuristic nhanh để tìm các câu lệnh SQL tiềm ẩn rủi ro
- `references/risk-catalog.md` - danh mục các mối nguy hiểm theo từng thao tác và các mẫu xử lý an toàn
- `references/expand-contract.md` - giữ cho mã nguồn ứng dụng cũ và mới cùng hoạt động tốt trong suốt quá trình triển khai
- `templates/review-report.md` - định dạng báo cáo mà bạn bắt buộc phải tạo ra
- `examples/example-review.md` - một bản đánh giá mẫu hoàn chỉnh để căn chỉnh giọng điệu và độ sâu phân tích

## Quy trình thực hiện

### 1. Tìm các bản migration nằm trong phạm vi đánh giá
- Nếu đang đánh giá một nhánh (branch) hoặc PR: chạy `git diff --name-only origin/main...HEAD` và giữ lại
  các tệp nằm dưới các thư mục migration (`migrations/`, `db/migrate/`, `alembic/versions/`,
  `prisma/migrations/`, `database/migrations/`, `db/migration/`).
- Nếu không, hãy sử dụng các tệp hoặc câu lệnh SQL mà người dùng đã chỉ định.
- Lưu ý rõ những bản migration nào là mới so với những bản đã được áp dụng trên bất kỳ môi trường nào.
  Tuyệt đối không bao giờ đề xuất chỉnh sửa một bản migration đã được chạy; hãy đề xuất tạo một bản migration tiếp nối (follow-up migration) mới.

### 2. Thiết lập ngữ cảnh
Xác định từ các tệp cấu hình, docker-compose, hoặc bằng cách hỏi người dùng:
- Hệ quản trị cơ sở dữ liệu và phiên bản chính (ví dụ: PostgreSQL 15, MySQL 8.0). Hành vi khóa bảng (lock behavior) phụ thuộc vào phiên bản này.
- Kích thước ước tính và lưu lượng ghi (write traffic) của từng bảng bị tác động.
- Cách thức hoạt động của quy trình triển khai (deploy): các bản migration được chạy trước, trong, hay sau khi mã nguồn mới được tung ra?

Nếu không rõ kích thước bảng hoặc lưu lượng truy cập, hãy giả định rằng bảng đó rất lớn và đang có lưu lượng truy cập cao (hot), đồng thời nêu rõ giả định đó.

### 3. Lấy câu lệnh SQL thực tế
Mã nguồn ORM thường che giấu những gì thực sự chạy bên dưới. Trước tiên hãy kết xuất (render) ra câu lệnh SQL thuần:

| Framework | Câu lệnh |
|-----------|---------|
| Django | `python manage.py sqlmigrate <app> <migration>` |
| Rails | chạy `rails db:migrate` trên một DB nháp, sau đó kiểm tra bản diff của `db/structure.sql` |
| Alembic | `alembic upgrade <from>:<to> --sql` |
| Prisma | đọc tệp `prisma/migrations/<name>/migration.sql` |
| Laravel | `php artisan migrate --pretend` |
| Knex | chạy trên một DB nháp với cờ `DEBUG=knex:query` và sao chép các câu lệnh SQL được ghi ra log |
| Flyway / Liquibase | tệp `.sql` hoặc chạy `liquibase update-sql` |

Lưu mã SQL đã kết xuất vào một tệp tạm nếu nó chưa phải là tệp `.sql`.

### 4. Chạy trình quét tự động
```bash
python3 scripts/scan_migration.py --dialect postgres path/to/migration.sql
python3 scripts/scan_migration.py --dialect mysql db/*.sql
```
Trình quét sẽ in ra `file:line [SEVERITY] RULE message` và trả về mã thoát 1 nếu có bất kỳ phát hiện nào ở mức HIGH.
Hãy coi kết quả đầu ra của công cụ như những manh mối dẫn đường chứ không phải phán quyết cuối cùng: công cụ này sử dụng biểu thức chính quy (regex), có thể bỏ sót SQL động, và không thể biết được kích thước thực tế của các bảng.

### 5. Rà soát thủ công từng câu lệnh
Đối với mỗi câu lệnh, hãy sử dụng `references/risk-catalog.md` để trả lời:
1. Câu lệnh này chiếm giữ loại khóa (lock) nào, và trong bao lâu (tức thì, quét toàn bộ bảng, hay ghi lại toàn bộ bảng)?
2. Nó có thể làm mất hoặc làm hỏng dữ liệu không? Điều đó có chủ đích và đã được sao lưu (backup) chưa?
3. Liệu nó có phải xếp hàng chờ phía sau các giao dịch (transactions) chạy lâu không? Tham số `lock_timeout` (Postgres) hoặc
   `lock_wait_timeout` (MySQL) đã được thiết lập để dừng nhanh khi lỗi (fail fast) thay vì làm tắc nghẽn toàn bộ lưu lượng truy cập chưa?
4. Nó có đang chạy bên trong một giao dịch (transaction) mà lẽ ra không được phép hay không (ví dụ: `CREATE INDEX CONCURRENTLY`)?
5. Các tác vụ cập nhật bù dữ liệu lớn (backfills) đã được chia theo từng lô (batched) và tách rời khỏi các lệnh DDL chưa?

### 6. Kiểm tra khả năng tương thích với ứng dụng
Trong quá trình triển khai cuốn chiếu (rolling deploy), mã nguồn cũ và mã nguồn mới sẽ chạy đồng thời trên lược đồ cơ sở dữ liệu mới.
Hãy tuân theo `references/expand-contract.md`:
- Tìm kiếm trong toàn bộ kho mã nguồn (`rg -n '<column_or_table_name>'`) đối với mọi đối tượng bị đổi tên, bị xóa bỏ,
  hoặc bị thay đổi kiểu dữ liệu, bao gồm cả SQL thuần, các bộ tuần tự hóa (serializers) và các truy vấn phân tích dữ liệu.
- Đánh dấu bất kỳ thay đổi nào mà phiên bản mã nguồn đang chạy hiện tại không thể chấp nhận được.

### 7. Xác minh luồng hoàn tác (rollback path)
- Bản migration chiều ngược lại (down migration) có tồn tại không, và nó có thực sự khôi phục lại trạng thái trước đó không?
- Các thao tác xóa bỏ (drops) và đổi kiểu dữ liệu gây mất mát là thao tác một chiều: bắt buộc phải có bản sao lưu hoặc kế hoạch triển khai theo nhiều giai đoạn.

### 8. Viết báo cáo
Điền chính xác vào mẫu `templates/review-report.md`. Đảm bảo độ sâu phân tích tương đương với
`examples/example-review.md`. Đối với mọi phát hiện ở mức HIGH hoặc MEDIUM, hãy cung cấp câu lệnh SQL thay thế
hoặc mã nguồn migration đạt được cùng trạng thái đích một cách an toàn, được chia thành các bước triển khai theo đúng thứ tự khi cần thiết.

## Các mức phán quyết (Verdicts)
- **SAFE** - không gây khóa chặn (blocking locks) trên các bảng lớn, không mất dữ liệu, tương thích ngược hoàn toàn.
- **SAFE WITH CHANGES** - có thể phát hành sau khi áp dụng các phần viết lại đã liệt kê.
- **UNSAFE** - sẽ gây gián đoạn dịch vụ (downtime), mất dữ liệu, hoặc phát sinh lỗi trên mã nguồn đang chạy nếu giữ nguyên như hiện tại.

## Quy tắc
- Tuyệt đối không bao giờ tự mình chạy các bản migration trên cơ sở dữ liệu production hoặc cơ sở dữ liệu dùng chung.
- Không sửa đổi trực tiếp các tệp migration trừ khi người dùng yêu cầu; hãy đề xuất các thay đổi ở trong bản báo cáo.
- Hãy cụ thể: nêu đích danh tên bảng, loại khóa (lock), và kịch bản gây lỗi (failure mode). Bỏ qua những lời khuyên chung chung.
- Nếu bạn không chắc chắn về một hành vi phụ thuộc vào phiên bản cụ thể, hãy nói rõ điều đó và đề xuất kiểm thử trên
  một bản sao có kích thước tương đương môi trường production bằng `\timing` / `EXPLAIN` và theo dõi khóa (lock monitoring).
FILE:references/risk-catalog.md
# Danh Mục Rủi Ro: Các Thao Tác Migration Phổ Biến

Tên các loại khóa dưới đây là của PostgreSQL. Khóa ACCESS EXCLUSIVE chặn toàn bộ thao tác đọc và ghi;
khóa SHARE chặn thao tác ghi; khóa SHARE UPDATE EXCLUSIVE không chặn cả đọc lẫn ghi.

## Vấn đề hàng đợi khóa - lock queue (áp dụng cho mọi mục bên dưới)
Ngay cả một lệnh ALTER TABLE "tức thì" cũng cần chiếm khóa ACCESS EXCLUSIVE trong chốc lát. Nếu một truy vấn chạy lâu hoặc
một phiên kết nối ở trạng thái nghỉ trong giao dịch (idle-in-transaction) đang giữ bảng đó, lệnh ALTER sẽ phải chờ - và mọi truy vấn mới đến sau
sẽ phải xếp hàng chờ nối đuôi phía sau nó. Một thay đổi chỉ mất 1 ms cũng có thể gây ra sự cố sập hệ thống kéo dài nhiều phút.
Hãy luôn bắt đầu các bản migration tiềm ẩn rủi ro bằng:
```sql
SET lock_timeout = '5s';        -- fail fast, retry later
SET statement_timeout = '15min'; -- optional upper bound
```
Tương đương trong MySQL: `SET SESSION lock_wait_timeout = 5;` (khóa siêu dữ liệu - metadata locks).

## Các thao tác trên PostgreSQL

| Thao tác | Rủi ro | Mẫu xử lý an toàn |
|-----------|------|--------------|
| `CREATE INDEX` | Khóa SHARE: chặn mọi thao tác ghi trong suốt thời gian tạo chỉ mục | Dùng `CREATE INDEX CONCURRENTLY`, bên ngoài giao dịch (transaction); nếu thất bại hãy xóa chỉ mục INVALID đi và thử lại. Rails: `disable_ddl_transaction!`; Django: `atomic = False` |
| `DROP INDEX` | Khóa ACCESS EXCLUSIVE | Dùng `DROP INDEX CONCURRENTLY` |
| `ADD COLUMN` cho phép null, không có giá trị mặc định | Tức thì | An toàn (vẫn nên đặt lock_timeout) |
| `ADD COLUMN ... DEFAULT <constant>` | Tức thì trên PG 11+, ghi lại toàn bộ bảng ở các bản trước PG 11 | An toàn trên PG 11+ |
| `ADD COLUMN ... DEFAULT now()/random()/gen_random_uuid()` | Giá trị mặc định biến thiên (volatile default): ghi lại toàn bộ bảng | Thêm cột cho phép null, cập nhật bù dữ liệu (backfill) theo từng lô, sau đó mới đặt giá trị mặc định |
| `ADD COLUMN ... NOT NULL` không có giá trị mặc định | Báo lỗi thất bại nếu bảng không rỗng | Thêm cột cho phép null, cập nhật bù dữ liệu, sau đó mới áp đặt ràng buộc NOT NULL (xem bên dưới) |
| `ALTER COLUMN ... SET NOT NULL` | Quét toàn bộ bảng dưới khóa ACCESS EXCLUSIVE | Chạy `ADD CONSTRAINT c CHECK (col IS NOT NULL) NOT VALID`; sau đó `VALIDATE CONSTRAINT c`; rồi mới `SET NOT NULL` (PG 12+ sẽ bỏ qua bước quét bảng); cuối cùng xóa ràng buộc `c` |
| `ALTER COLUMN ... TYPE` | Thường gây ghi lại toàn bộ bảng + tạo lại chỉ mục dưới khóa ACCESS EXCLUSIVE | Chỉ an toàn nếu có thể ép kiểu nhị phân tương thích (từ varchar(n) sang n lớn hơn hoặc sang text). Nếu không, hãy thêm cột mới + ghi kép (dual write) + cập nhật bù + hoán đổi |
| `ADD FOREIGN KEY` | Khóa cả hai bảng trong khi xác thực toàn bộ các dòng dữ liệu | Dùng `ADD CONSTRAINT ... NOT VALID`, sau đó chạy `VALIDATE CONSTRAINT` ở một bước tách biệt |
| `ADD CHECK` | Quét bảng dưới khóa ACCESS EXCLUSIVE | Áp dụng cùng mẫu NOT VALID + VALIDATE như trên |
| `ADD UNIQUE` / `ADD PRIMARY KEY` | Tạo chỉ mục dưới trạng thái khóa bảng | Chạy `CREATE UNIQUE INDEX CONCURRENTLY idx ...`; sau đó chạy `ADD CONSTRAINT ... UNIQUE USING INDEX idx` |
| `RENAME COLUMN` / `RENAME TO` | Tức thì, nhưng làm lỗi mã nguồn đang chạy | Áp dụng mô hình Mở rộng/Thu hẹp - Expand/contract (xem tệp expand-contract.md) |
| `DROP COLUMN` | Tức thì, nhưng không thể đảo ngược; mã nguồn cũ truy vấn cột này sẽ bị lỗi | Gỡ bỏ toàn bộ tham chiếu trong mã nguồn và triển khai trước; sau đó mới xóa cột |
| `DROP TABLE` / `TRUNCATE` | Mất dữ liệu không thể khôi phục | Xác nhận đã có bản sao lưu và không còn tiến trình nào đọc bảng; cân nhắc đổi tên thành `_deprecated` trước |
| `ALTER TYPE ... ADD VALUE` | Giá trị mới không thể sử dụng ngay trong cùng một giao dịch; thậm chí không chạy được trong giao dịch ở các bản trước PG 12 | Đặt câu lệnh này vào một tệp migration riêng biệt |
| `VACUUM FULL` / `CLUSTER` / `REINDEX` | Ghi lại toàn bộ bảng dưới khóa ACCESS EXCLUSIVE | Dùng `REINDEX CONCURRENTLY` (PG 12+), hoặc dùng `pg_repack` để xử lý phình đại dữ liệu (bloat) |
| Lệnh `UPDATE` / `DELETE` lớn | Giữ khóa dòng (row locks) lâu, làm tăng đột biến WAL, gây trễ bản sao (replica lag) | Chia nhỏ theo khóa chính (1k-10k dòng mỗi lô), commit theo từng lô, chạy tách biệt bên ngoài bản migration DDL |

## Lưu ý cho MySQL 8.0 (InnoDB)
- Luôn chỉ định rõ thuật toán (algorithm) để MySQL báo lỗi dừng lại thay vì âm thầm sao chép lại toàn bộ bảng:
  `ALTER TABLE t ADD COLUMN c INT, ALGORITHM=INSTANT;` hoặc
  `ALTER TABLE t ADD INDEX i (c), ALGORITHM=INPLACE, LOCK=NONE;`
- `ADD COLUMN` hỗ trợ INSTANT trên bản 8.0.12+ (khi thêm ở vị trí cuối cùng) và 8.0.29+ (ở bất kỳ vị trí nào).
- Các thay đổi kiểu dữ liệu bằng `MODIFY` / `CHANGE COLUMN` sử dụng ALGORITHM=COPY: chặn mọi thao tác ghi.
- Đối với các bảng lớn bắt buộc phải thay đổi theo kiểu COPY, hãy sử dụng công cụ `gh-ost` hoặc `pt-online-schema-change`.
- Các lệnh DDL trong MySQL không có tính giao dịch (not transactional): một bản migration chứa nhiều câu lệnh nếu bị lỗi giữa chừng sẽ khiến
  lược đồ rơi vào trạng thái mới áp dụng được một nửa. Hãy giữ nguyên tắc mỗi bản migration chỉ chứa một câu lệnh DDL duy nhất.
FILE:references/expand-contract.md
# Mở Rộng / Thu Hẹp (Expand / Contract): Thay Đổi Lược Đồ Tương Thích Ngược

Trong quá trình triển khai cuốn chiếu (rolling deploy), phiên bản ứng dụng cũ và mới sẽ chạy song song cùng nhau.
Nếu các bản migration chạy trước khi mã nguồn mới đi vào hoạt động, mã nguồn cũ bắt buộc phải hoạt động được với
lược đồ mới. Nếu chúng chạy sau, mã nguồn mới bắt buộc phải hoạt động được với lược đồ cũ.
Mô hình Mở rộng/Thu hẹp (Expand/contract) giúp cho mọi bước chuyển đổi đều tương thích với cả hai phiên bản.

## Ba giai đoạn thực hiện
1. **Mở rộng (Expand)** - chỉ thêm các cấu trúc mới (cột, bảng, chỉ mục). Không có bất kỳ thứ gì bị
   xóa bỏ hay đổi tên. Mã nguồn cũ sẽ tự động bỏ qua các phần mới thêm này.
2. **Di chuyển dữ liệu (Migrate)** - triển khai mã nguồn thực hiện ghi đồng thời vào cả cấu trúc cũ lẫn mới, cập nhật bù (backfill)
   các dòng dữ liệu hiện có theo từng lô, sau đó chuyển luồng đọc sang cấu trúc mới.
3. **Thu hẹp (Contract)** - một khi không còn bất kỳ mã nguồn nào đang chạy chạm tới cấu trúc cũ nữa, hãy xóa cấu trúc cũ đi trong một
   bản migration riêng biệt ở đợt phát hành sau đó.

Mỗi giai đoạn là một đợt triển khai (deploy) độc lập. Tuyệt đối không bao giờ gộp chung bước mở rộng và thu hẹp vào trong cùng một bản migration.

## Các công thức thực hiện (Recipes)

### Đổi tên một cột (`users.name` thành `users.full_name`)
1. Migration: thêm cột `full_name` cho phép nhận giá trị null.
2. Mã nguồn: ghi đồng thời vào cả `name` và `full_name`; vẫn đọc từ `name`.
3. Cập nhật bù `full_name = name` theo từng lô cho những dòng có `full_name IS NULL`.
4. Mã nguồn: chuyển sang đọc từ `full_name`; vẫn tiếp tục ghi vào cả hai cột.
5. Mã nguồn: ngừng ghi vào cột `name`. (Đối với Rails: thêm `name` vào `ignored_columns` tại bước này.)
6. Migration: xóa cột `name`.

### Thay đổi kiểu dữ liệu của cột (`orders.amount` từ kiểu int sang numeric)
Thực hiện tương tự như đổi tên cột: thêm cột `amount_numeric`, ghi kép (dual write), cập nhật bù theo lô, chuyển luồng đọc, rồi xóa cột cũ.
Có thể sử dụng trigger trong cơ sở dữ liệu để xử lý việc ghi kép nếu việc sửa đổi mã nguồn ứng dụng gặp nhiều khó khăn.

### Chuyển một cột sang bắt buộc khác rỗng (NOT NULL)
1. Mã nguồn: luôn luôn ghi một giá trị hợp lệ (không ghi NULL nữa).
2. Cập nhật bù các dòng đang có giá trị NULL theo từng lô.
3. Migration: áp dụng tuần tự CHECK ... NOT VALID, VALIDATE, rồi SET NOT NULL (xem tệp risk-catalog.md).

### Xóa một cột hoặc một bảng
1. Mã nguồn: gỡ bỏ mọi thao tác đọc và ghi tới cột/bảng đó (tìm kiếm kỹ trong các mô hình ORM, SQL thuần, các bảng ảo view,
   báo cáo, các tiến trình ETL, và các dịch vụ khác đang dùng chung cơ sở dữ liệu).
2. Triển khai mã nguồn và chờ ít nhất một chu kỳ phát hành hoàn chỉnh.
3. Migration: thực hiện xóa (drop). Hãy tạo bản sao lưu (backup) hoặc bản chụp nhanh (snapshot) dữ liệu trước nếu dữ liệu đó quan trọng.

### Tách hoặc di chuyển một bảng
Tạo bảng mới, thực hiện ghi kép (dual write), cập nhật bù dữ liệu, chuyển luồng đọc sang bảng mới, ngừng ghi vào bảng cũ, cuối cùng xóa bảng cũ.

## Các câu hỏi về khả năng tương thích cần trả lời cho mỗi thay đổi
- Có đoạn mã nguồn nào đang chạy sử dụng `SELECT *` hoặc ánh xạ toàn bộ danh sách cột hay không (các ORM thường lưu bộ nhớ đệm danh sách
  cột ngay lúc khởi động và sẽ báo lỗi khi một cột biến mất)?
- Lệnh thêm mới (insert) từ mã nguồn cũ có bị lỗi do cột mới thêm có ràng buộc NOT NULL mà không có giá trị mặc định hay không?
- Có dịch vụ nào khác, tác vụ định kỳ (cron jobs), bảng điều khiển BI, hoặc máy chủ bản sao (replicas) nào đang đọc bảng này hay không?
- Đợt triển khai này có thể hoàn tác (rollback) về phiên bản mã nguồn trước đó mà không cần phải chạy bản down migration hay không?

Nếu câu trả lời cho câu hỏi cuối cùng là "không", thì thay đổi đó không đảm bảo tính tương thích ngược.
FILE:templates/review-report.md
# Đánh Giá Mức Độ An Toàn Khi Migration: <tên bản migration hoặc tiêu đề PR>

**Phán quyết (Verdict):** SAFE | SAFE WITH CHANGES | UNSAFE
**Hệ quản trị (Engine):** <ví dụ: PostgreSQL 15>  |  **Số tệp đã đánh giá:** <số lượng>
**Giả định (Assumptions):** <kích thước bảng, lưu lượng truy cập, thứ tự triển khai - đánh dấu rõ bất kỳ thông tin nào là ước đoán>

## Tóm tắt
<2-4 câu: bản migration này làm gì, rủi ro lớn nhất là gì, và cần phải thay đổi những gì.>

## Các phát hiện chi tiết (Findings)

| # | Mức độ | Tệp:Dòng | Câu lệnh | Rủi ro |
|---|----------|-----------|-----------|------|
| 1 | HIGH | <đường_dẫn:dòng> | `<câu lệnh SQL ngắn>` | <khóa bảng / mất dữ liệu / làm lỗi mã nguồn cũ> |

### 1. <Tiêu đề ngắn gọn của phát hiện>
- **Điều gì sẽ xảy ra:** <loại khóa bị chiếm giữ, thời gian kéo dài, đối tượng nào bị chặn, hoặc thành phần nào sẽ bị lỗi>
- **Tại sao điều này lại quan trọng ở đây:** <kích thước bảng, lưu lượng truy cập, mã nguồn đang phụ thuộc vào nó>
- **Giải pháp thay thế an toàn:**
```sql
-- replacement SQL or migration code, in run order
```

<Lặp lại cho từng phát hiện ở mức HIGH và MEDIUM. Gom nhóm các phát hiện mức LOW vào chung một danh sách.>

## Khả năng tương thích với ứng dụng
- <Từng đối tượng bị đổi tên / bị xóa / bị đổi kiểu dữ liệu và vị trí trong mã nguồn vẫn đang sử dụng nó>
- <Hoặc: "Không có tham chiếu mã nguồn nào bị ảnh hưởng - đã kiểm tra bằng lệnh rg cho X, Y.">

## Kế hoạch hoàn tác (Rollback Plan)
- <Bản down migration có khôi phục lại đúng trạng thái cũ không? Điều gì là không thể đảo ngược?>
- <Có bắt buộc phải sao lưu (backup) hoặc chụp nhanh (snapshot) trước khi chạy hay không: có/không>

## Trình tự triển khai khuyến nghị
1. <Bước chạy migration hoặc triển khai mã nguồn>
2. <...>

## Kết quả từ trình quét tự động (Scanner Output)
```
<dán kết quả đầu ra của scripts/scan_migration.py, hoặc ghi chú các trường hợp cảnh báo sai (false positives)>
```
FILE:examples/example-review.md
# Ví Dụ Bản Đánh Giá Mẫu

**Đầu vào:** PR "Add order status tracking" với một bản migration của Rails dành cho PostgreSQL 15.
Bảng `orders` có khoảng ~40 triệu dòng và nhận lưu lượng ghi liên tục. Các bản migration được chạy trước khi triển khai mã nguồn mới.

```sql
-- rendered from db/migrate/20261002_add_status_to_orders.rb
ALTER TABLE orders ADD COLUMN status varchar NOT NULL DEFAULT 'pending';
ALTER TABLE orders RENAME COLUMN shipped_on TO shipped_at;
CREATE INDEX index_orders_on_status ON orders (status);
ALTER TABLE orders ADD CONSTRAINT fk_orders_carrier
  FOREIGN KEY (carrier_id) REFERENCES carriers (id);
```

**Kết quả trình quét:** 3 lỗi HIGH (đổi tên cột, tạo chỉ mục không dùng concurrent, khóa ngoại xác thực ngay), 1 lỗi MEDIUM (thiếu thiết lập lock-timeout).

---

# Đánh Giá Mức Độ An Toàn Khi Migration: Add order status tracking

**Phán quyết (Verdict):** UNSAFE
**Hệ quản trị (Engine):** PostgreSQL 15  |  **Số tệp đã đánh giá:** 1
**Giả định (Assumptions):** bảng orders có ~40 triệu dòng, lưu lượng ghi cao (do người dùng cung cấp); bảng carriers có kích thước nhỏ.

## Tóm tắt
Bản migration này thêm cột trạng thái đơn hàng (`status`), đổi tên cột `shipped_on`, đánh chỉ mục cho cột `status`, và thêm một khóa ngoại trỏ tới bảng `carriers`.
Bản thân việc thêm cột `status` là an toàn trên PG 15, nhưng lệnh đổi tên cột sẽ làm lỗi ứng dụng đang chạy, đồng thời việc tạo chỉ mục và khóa ngoại sẽ chặn mọi thao tác ghi trên bảng `orders` trong nhiều phút.
Hãy tách thành ba bản migration riêng biệt và sử dụng các biến thể `concurrent` / `NOT VALID`.

## Các phát hiện chi tiết (Findings)

| # | Mức độ | Tệp:Dòng | Câu lệnh | Rủi ro |
|---|----------|-----------|-----------|------|
| 1 | HIGH | rendered.sql:3 | `RENAME COLUMN shipped_on` | Mã nguồn cũ bị lỗi ngay khi triển khai |
| 2 | HIGH | rendered.sql:4 | `CREATE INDEX ... (status)` | Chặn mọi thao tác ghi trong suốt lúc tạo chỉ mục |
| 3 | HIGH | rendered.sql:5 | `ADD ... FOREIGN KEY` | Quét xác thực toàn bộ bảng dưới trạng thái khóa |
| 4 | MEDIUM | rendered.sql:1 | thiếu `lock_timeout` | Các lệnh ALTER có thể xếp hàng chờ và làm treo toàn bộ lưu lượng |

### 1. Đổi tên cột làm lỗi mã nguồn đang chạy
- **Điều gì sẽ xảy ra:** thao tác đổi tên diễn ra tức thì, nhưng các máy chủ ứng dụng vẫn đang chạy phiên bản cũ
  sẽ truy vấn cột `shipped_on` và gặp lỗi `column does not exist` cho đến khi quá trình triển khai hoàn tất.
- **Tại sao điều này lại quan trọng ở đây:** lệnh `rg -n shipped_on` tìm thấy 7 vị trí tham chiếu, bao gồm cả
  `app/serializers/order_serializer.rb` và báo cáo chạy hàng đêm `reports/fulfillment.sql`.
- **Giải pháp thay thế an toàn:** áp dụng mô hình mở rộng/thu hẹp (expand/contract). Thêm cột `shipped_at`, thực hiện ghi kép, cập nhật bù dữ liệu theo từng
  lô, chuyển luồng đọc sang cột mới, sau đó mới xóa cột `shipped_on` ở một đợt phát hành sau.

### 2. Quá trình tạo chỉ mục chặn các thao tác ghi
- **Giải pháp thay thế an toàn** (tách thành bản migration riêng, dùng `disable_ddl_transaction!`):
```sql
CREATE INDEX CONCURRENTLY index_orders_on_status ON orders (status);
```

### 3. Khóa ngoại xác thực 40 triệu dòng dữ liệu dưới trạng thái khóa bảng
- **Giải pháp thay thế an toàn:**
```sql
SET lock_timeout = '5s';
ALTER TABLE orders ADD CONSTRAINT fk_orders_carrier
  FOREIGN KEY (carrier_id) REFERENCES carriers (id) NOT VALID;
-- next migration (takes only SHARE UPDATE EXCLUSIVE on orders):
ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_carrier;
```

**LOW:** không có. Lưu ý rằng `ADD COLUMN ... DEFAULT 'pending'` chỉ cập nhật siêu dữ liệu (metadata-only) trên PG 11+.

## Khả năng tương thích với ứng dụng
- `shipped_on`: có 7 vị trí tham chiếu trong mã nguồn cộng với một báo cáo SQL; bắt buộc phải giữ lại cho đến giai đoạn thu hẹp (contract phase).

## Kế hoạch hoàn tác (Rollback Plan)
- Bản down migration sẽ xóa cột `status` (việc mất dữ liệu ở cột này có thể chấp nhận được vì đây là cột mới). Thao tác đổi tên có thể đảo ngược.
- Không bắt buộc phải tạo bản sao lưu (backup) cho tập thay đổi này một khi đã loại bỏ lệnh đổi tên cột trực tiếp.

## Trình tự triển khai khuyến nghị
1. Bản Migration A: đặt `SET lock_timeout`; thêm cột `status`; thêm cột `shipped_at`; thêm khóa ngoại FK với tùy chọn NOT VALID.
2. Bản Migration B (không dùng transaction): tạo chỉ mục cho cột status một cách đồng thời (concurrently).
3. Bản Migration C: xác thực khóa ngoại (validate FK). Triển khai mã nguồn thực hiện ghi kép vào cả hai cột `shipped_on`/`shipped_at`.
4. Cập nhật bù dữ liệu (backfill) cho cột `shipped_at`; chuyển luồng đọc sang cột mới; ở đợt phát hành sau đó mới xóa cột `shipped_on`.
FILE:scripts/scan_migration.py
#!/usr/bin/env python3
"""Heuristic scanner for risky SQL in migration files (PostgreSQL / MySQL).
Usage: python3 scan_migration.py [--dialect postgres|mysql] FILE [FILE ...]
Exit codes: 0 = no HIGH findings, 1 = HIGH findings, 2 = usage error."""
import re, sys

F = re.I | re.S
COLDEF = r"(?:\([^)]*\)|[^,(])*"  # one column definition, allowing numeric(10,2)
RULES = [  # (severity, rule id, dialect or None for both, regex, message)
    ("HIGH", "drop-table", None, r"^DROP\s+TABLE\b", "Irreversible data loss; confirm backup and no readers"),
    ("HIGH", "truncate", None, r"^TRUNCATE\b", "Irreversible data loss"),
    ("HIGH", "drop-column", None, r"^ALTER\s+TABLE\b.*\bDROP\s+(COLUMN\b|(?!CONSTRAINT|INDEX|KEY|PRIMARY|FOREIGN|CHECK|DEFAULT|NOT|IDENTITY|EXPRESSION)\w)", "Data loss; deployed code reading it will fail - remove code refs first"),
    ("HIGH", "rename", None, r"^ALTER\s+TABLE\b.*\bRENAME\b", "Breaks running code; use expand/contract"),
    ("HIGH", "type-change", "postgres", r"^ALTER\s+TABLE\b.*\bALTER\s+(COLUMN\s+)?\S+\s+(SET\s+DATA\s+)?TYPE\b", "Usually a full table rewrite under ACCESS EXCLUSIVE"),
    ("HIGH", "type-change", "mysql", r"^ALTER\s+TABLE\b.*\b(MODIFY|CHANGE)\s+(COLUMN\s+)?\S+", "Column redefinition usually uses ALGORITHM=COPY (writes blocked)"),
    ("HIGH", "index-not-concurrent", "postgres", r"^CREATE\s+(UNIQUE\s+)?INDEX\s+(?!CONCURRENTLY)", "Blocks writes during build; use CREATE INDEX CONCURRENTLY"),
    ("MEDIUM", "drop-index-not-concurrent", "postgres", r"^DROP\s+INDEX\s+(?!CONCURRENTLY)", "Takes ACCESS EXCLUSIVE; use DROP INDEX CONCURRENTLY"),
    ("HIGH", "fk-validated", "postgres", r"^ALTER\s+TABLE\b(?!.*\bNOT\s+VALID\b).*\b(FOREIGN\s+KEY|REFERENCES)\b", "Validates all rows while locking both tables; add NOT VALID, then VALIDATE"),
    ("MEDIUM", "check-validated", "postgres", r"^ALTER\s+TABLE\b(?!.*\bNOT\s+VALID\b).*\bADD\s+(CONSTRAINT\s+\S+\s+)?CHECK\b", "Full scan under lock; add NOT VALID, then VALIDATE"),
    ("MEDIUM", "set-not-null", "postgres", r"\bSET\s+NOT\s+NULL\b", "Full scan under ACCESS EXCLUSIVE; validate a CHECK (col IS NOT NULL) first"),
    ("HIGH", "add-not-null-no-default", None, r"^ALTER\s+TABLE\b.*\bADD\s+(COLUMN\s+)?(?!" + COLDEF + r"\bDEFAULT\b)" + COLDEF + r"\bNOT\s+NULL\b", "Fails on non-empty tables (or old code inserts fail); add nullable, backfill, then enforce"),
    ("MEDIUM", "volatile-default", "postgres", r"^ALTER\s+TABLE\b.*\bADD\b.*\bDEFAULT\s+(now|random|clock_timestamp|gen_random_uuid|uuid_generate_v\d)\s*\(", "Volatile default rewrites the table; add nullable, backfill, then set default"),
    ("MEDIUM", "unique-without-index", "postgres", r"^ALTER\s+TABLE\b(?!.*\bUSING\s+INDEX\b).*\bADD\s+(CONSTRAINT\s+\S+\s+)?(UNIQUE|PRIMARY\s+KEY)\b", "Builds index under lock; create it CONCURRENTLY, then ADD CONSTRAINT ... USING INDEX"),
    ("MEDIUM", "mysql-no-algorithm", "mysql", r"^(ALTER\s+TABLE|CREATE\s+(UNIQUE\s+)?INDEX)\b(?!.*\bALGORITHM\s*=)", "State ALGORITHM=INSTANT|INPLACE, LOCK=NONE so MySQL refuses a blocking copy"),
    ("HIGH", "dml-no-where", None, r"^(UPDATE|DELETE)\b(?!.*\bWHERE\b)", "Touches every row in one transaction; batch it"),
    ("LOW", "dml-in-migration", None, r"^(UPDATE|DELETE|INSERT)\b.*\bWHERE\b", "Data change in migration; batch it if the table is large"),
    ("MEDIUM", "table-rewrite", "postgres", r"^(VACUUM\s+FULL|CLUSTER|REINDEX\s+(?!.*CONCURRENTLY))", "Rewrites under ACCESS EXCLUSIVE; use REINDEX CONCURRENTLY or pg_repack"),
    ("LOW", "enum-add-value", "postgres", r"^ALTER\s+TYPE\b.*\bADD\s+VALUE\b", "New value unusable in same transaction; keep in its own migration"),
]

def statements(sql):
    """Yield (line_number, statement) after stripping comments. Naive ';' split."""
    sql = re.sub(r"/\*.*?\*/", lambda m: re.sub(r"[^\n]", " ", m.group()), sql, flags=re.S)
    sql = re.sub(r"--[^\n]*", "", sql)
    pos = 0
    for part in sql.split(";"):
        stripped = part.lstrip()
        line = sql.count("\n", 0, pos + len(part) - len(stripped)) + 1
        pos += len(part) + 1
        if stripped.strip():
            yield line, " ".join(stripped.split())

def scan(path, dialect):
    text = open(path, encoding="utf-8", errors="replace").read()
    stmts, out = list(statements(text)), []
    for line, st in stmts:
        for sev, rid, dia, rx, msg in RULES:
            if (dia is None or dia == dialect) and re.search(rx, st, F):
                out.append((sev, f"{path}:{line} [{sev}] {rid}: {msg}\n    > {st[:110]}"))
    has_ddl = any(re.match(r"(ALTER|CREATE\s+(UNIQUE\s+)?INDEX|DROP)\b", s, re.I) for _, s in stmts)
    timeout = "lock_timeout" if dialect == "postgres" else "lock_wait_timeout"
    if has_ddl and timeout not in text.lower():
        out.append(("MEDIUM", f"{path}:1 [MEDIUM] no-lock-timeout: DDL without {timeout}; it may queue and block all traffic"))
    if re.search(r"\bCONCURRENTLY\b", text, re.I) and re.search(r"^\s*(BEGIN|START\s+TRANSACTION)\b", text, re.I | re.M):
        out.append(("HIGH", f"{path}:1 [HIGH] concurrently-in-transaction: CONCURRENTLY cannot run inside a transaction block"))
    return out

def main(argv):
    dialect = "postgres"
    if len(argv) >= 2 and argv[0] == "--dialect":
        dialect, argv = argv[1].lower(), argv[2:]
    if dialect not in ("postgres", "mysql") or not argv:
        print(__doc__, file=sys.stderr)
        return 2
    try:
        findings = [f for p in argv for f in scan(p, dialect)]
    except OSError as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    for _, text in findings:
        print(text)
    counts = {s: sum(1 for f in findings if f[0] == s) for s in ("HIGH", "MEDIUM", "LOW")}
    print(f"\n{len(argv)} file(s) scanned: {counts['HIGH']} HIGH, {counts['MEDIUM']} MEDIUM, {counts['LOW']} LOW")
    print("Heuristic only: confirm each finding against references/risk-catalog.md.")
    return 1 if counts["HIGH"] else 0

if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
BackendBest PracticesDevOps+2
F@f
0
Trình Giải thích Câu truy vấn SQL cho Người không chuyên Kỹ thuật
Văn bản

Giải thích câu truy vấn SQL bằng ngôn ngữ đời thường dễ hiểu và chỉ ra các rủi ro.

Hãy giải thích câu truy vấn SQL này cho một bên liên quan không làm về kỹ thuật.

SQL:
sql

Đồng thời cung cấp:
- Câu truy vấn này trả lời cho câu hỏi nghiệp vụ gì
- Các bảng dữ liệu và phép nối (joins) được diễn giải bằng ngôn từ đời thường
- Các bộ lọc và khoảng thời gian áp dụng
- Các rủi ro tiềm ẩn (phép nối tích Đề-các gây nhân bản dòng, thiếu bộ lọc, làm lộ dữ liệu cá nhân PII)
- Một đoạn văn ngắn tóm tắt tổng quan dành cho cấp quản lý

Giả định rằng người đọc biết sử dụng bảng tính (spreadsheets) nhưng không biết SQL. Không viết lại câu truy vấn trừ khi được yêu cầu.
SQLDocumentationData Analysis+1
F@f
0
Trình Đánh Giá và Khuyến Nghị Chỉ Mục SQL (SQL Index)
Kỹ năng

Đánh giá lược đồ (schema) và các truy vấn SQL chạy chậm, đề xuất các chỉ mục phức hợp (composite indexes) theo đúng thứ tự cột (so sánh bằng, sắp xếp, khoảng giá trị) kèm các cột bao phủ (covering columns) và sự đánh đổi hiệu năng, gắn cờ các bộ lọc không thể tận dụng chỉ mục (non-sargable) như dùng hàm trên cột hoặc LIKE có ký tự đại diện ở đầu, đồng thời cảnh báo về các chỉ mục trùng lặp, dư thừa và có độ chọn lọc thấp. Sử dụng khi người dùng chia sẻ một truy vấn chậm, kết quả EXPLAIN, hoặc schema và hỏi "tôi cần chỉ mục nào?" hoặc "có quá nhiều chỉ mục không?". Bao gồm công cụ hỗ trợ bằng thư viện chuẩn Python đã được kiểm thử.

---
name: sql-index-recommendation-reviewer
description: Đánh giá lược đồ (schema) và các truy vấn SQL chạy chậm, đề xuất các chỉ mục phức hợp (composite indexes) theo đúng thứ tự cột (so sánh bằng, sắp xếp, khoảng giá trị) kèm các cột bao phủ (covering columns) và sự đánh đổi hiệu năng, gắn cờ các bộ lọc không thể tận dụng chỉ mục (non-sargable) như dùng hàm trên cột hoặc LIKE có ký tự đại diện ở đầu, đồng thời cảnh báo về các chỉ mục trùng lặp, dư thừa và có độ chọn lọc thấp. Sử dụng khi người dùng chia sẻ một truy vấn chậm, kết quả EXPLAIN, hoặc schema và hỏi "tôi cần chỉ mục nào?" hoặc "có quá nhiều chỉ mục không?". Bao gồm công cụ hỗ trợ bằng thư viện chuẩn Python đã được kiểm thử.
---

# Trình Đánh Giá và Khuyến Nghị Chỉ Mục SQL (SQL Index)

Bạn giúp các lập trình viên tăng tốc các truy vấn chậm mà không làm bảng dữ liệu bị quá tải bởi hàng tá chỉ mục (index). Bạn đọc schema và truy vấn theo đúng tư duy của một kỹ sư cơ sở dữ liệu: cột nào được so sánh bằng (equality), cột nào lọc theo khoảng giá trị (range), truy vấn sắp xếp theo cột nào, kết nối (join) trên cột nào, và những chỉ mục nào đã tồn tại sẵn. Bạn đề xuất tập hợp chỉ mục nhỏ gọn nhất đáp ứng được tải công việc (workload) theo đúng thứ tự cột, và luôn thẳng thắn rằng chỉ có lệnh EXPLAIN chạy trên dữ liệu thực tế mới có thể kiểm chứng chắc chắn kết quả.

## Các tệp trong kỹ năng này

- `scripts/suggest_indexes.py` - phân tích cú pháp các câu lệnh CREATE TABLE / CREATE INDEX và các truy vấn SELECT, UPDATE, DELETE (tách riêng các truy vấn con subquery và phần thân CTE), sau đó in ra các chỉ mục ứng viên kèm mã DDL, các truy vấn mà mỗi chỉ mục phục vụ, các cột bao phủ (covering columns), sự chồng chéo và các phát hiện trên schema (chỉ dùng thư viện chuẩn Python 3; không kết nối cơ sở dữ liệu)
- `references/indexing-rules.md` - quy tắc về thứ tự cột, chỉ mục bao phủ (covering indexes), chỉ mục từng phần (partial) và chỉ mục biểu thức (expression), tính chất sargable (khả năng tận dụng chỉ mục), cùng các quy tắc tránh đánh chỉ mục quá mức
- `references/explain-reading-guide.md` - hướng dẫn cách đọc kết quả EXPLAIN trong PostgreSQL, MySQL và SQLite để xác nhận hoặc bác bỏ một đề xuất
- `templates/index-review.md` - mẫu báo cáo đánh giá trả về cho người dùng
- `examples/example-shop-orders-review.md` - ví dụ thực tế đánh giá các truy vấn chậm của một cửa hàng trực tuyến

## Quy trình làm việc

### 1. Thu thập thông tin
Hỏi người dùng, hoặc tự giả định và nêu rõ điều đó:
- Hệ quản trị cơ sở dữ liệu và phiên bản (PostgreSQL, MySQL/MariaDB, SQLite, SQL Server), cùng với schema và các chỉ mục hiện có (`\d table`, `SHOW CREATE TABLE`, hoặc các tệp migration).
- Các truy vấn chậm nguyên bản như khi ứng dụng gửi đi, kèm theo các giá trị tham số điển hình, tần suất chạy của mỗi truy vấn và mức độ chậm thực tế.
- Kích thước bảng (số dòng), tần suất ghi (write-heavy) của mỗi bảng, và bất kỳ kết quả EXPLAIN hoặc EXPLAIN ANALYZE nào hiện có.
- Các ràng buộc vận hành: nhóm có thể tạo chỉ mục trực tuyến (online) không, dung lượng ổ cứng còn trống và những lo ngại về độ trễ sao chép dữ liệu (replication lag).

### 2. Chạy công cụ hỗ trợ
```bash
python3 scripts/suggest_indexes.py --schema schema.sql slow-queries.sql
python3 scripts/suggest_indexes.py all-in-one.sql --dialect mysql
python3 scripts/suggest_indexes.py --schema schema.sql - --json < query.sql
python3 scripts/suggest_indexes.py --schema schema.sql queries.sql --strict   # exit 1 on non-sargable filters or duplicate indexes
```
Công cụ hỗ trợ này là một trình phân tích cú pháp dựa trên quy tắc ước lượng (heuristic parser), không phải bộ lập kế hoạch truy vấn (query planner): nó không biết số lượng dòng, phân phối dữ liệu hay kế hoạch thực thi mà cơ sở dữ liệu sẽ chọn. Nó có thể bỏ sót các điều kiện lọc (predicates) trong câu SQL phức tạp (và sẽ liệt kê chúng vào mục "not understood" - chưa hiểu được). Hãy tự mình đọc kỹ từng truy vấn và coi mỗi gợi ý từ công cụ là một ứng viên cần kiểm chứng. Nếu bạn không thể chạy script, hãy áp dụng thủ công các quy tắc trong `references/indexing-rules.md` theo đúng trình tự tương tự.

### 3. Ra quyết định
1. Sửa lại câu truy vấn trước khi thêm chỉ mục: viết lại các bộ lọc không thể tận dụng chỉ mục (non-sargable filters — như gọi hàm trên cột, phép tính số học trên cột, toán tử LIKE có ký tự đại diện `%` ở đầu, phép OR giữa nhiều cột khác nhau) để cơ sở dữ liệu có thể sử dụng được chỉ mục.
2. Đối với mỗi truy vấn, hãy chọn thứ tự cột: các cột so sánh bằng (ưu tiên cột có độ chọn lọc cao nhất lên trước khi bạn biết rõ điều đó), tiếp theo là các cột trong ORDER BY hoặc GROUP BY nếu truy vấn có sắp xếp đi kèm LIMIT, sau cùng là một cột lọc theo khoảng giá trị (range).
3. Hợp nhất chỉ mục: một chỉ mục phức hợp (composite index) có thể phục vụ nhiều truy vấn cùng lúc khi chúng dùng chung các cột ngoài cùng bên trái (leftmost columns). Ưu tiên mở rộng một chỉ mục sẵn có hơn là tạo thêm chỉ mục mới.
4. Chỉ thêm các cột bao phủ (INCLUDE / covering columns) cho các truy vấn chạy cực kỳ thường xuyên (hot) và thiên về đọc dữ liệu (read-heavy), đồng thời giữ số lượng cột này ở mức tối thiểu.
5. Dọn dẹp trước khi thêm mới: xóa bỏ các chỉ mục bị trùng lặp hoàn toàn và các chỉ mục tiền tố dư thừa (redundant-prefix indexes) sau khi đã kiểm tra thống kê tần suất sử dụng (`pg_stat_user_indexes`, `sys.schema_unused_indexes`).
6. Tính toán chi phí đánh đổi: mỗi chỉ mục thêm vào đều làm chậm thao tác INSERT và UPDATE trên các cột của nó, đồng thời tiêu tốn dung lượng ổ cứng và bộ nhớ RAM. Những bảng có tần suất ghi cao (write-heavy) nên có ít chỉ mục hơn và ưu tiên các chỉ mục đa dụng.

### 4. Bàn giao kết quả
Điền đầy đủ vào mẫu `templates/index-review.md`: chẩn đoán từng truy vấn, mã lệnh DDL khuyến nghị theo đúng thứ tự khởi tạo kèm danh sách các truy vấn mà mỗi chỉ mục phục vụ, các chỉ mục cần xóa bỏ, cách viết lại truy vấn, những sự đánh đổi (tradeoffs), và hướng dẫn cách kiểm chứng bằng lệnh EXPLAIN trước và sau khi áp dụng (xem `references/explain-reading-guide.md`). Tuân theo văn phong trình bày của `examples/example-shop-orders-review.md`.

## Quy tắc
- Không bao giờ tuyên bố truy vấn sẽ nhanh hơn bao nhiêu lần mà không chỉ rõ cách đo lường; hãy nêu những thay đổi dự kiến trong kế hoạch thực thi ("chuyển sang quét chỉ mục index scan thay vì quét tuần tự sequential scan và sắp xếp sort"), tuyệt đối không tự bịa ra các con số thời gian chạy.
- Sử dụng các lệnh tạo chỉ mục an toàn cho môi trường production: `CREATE INDEX CONCURRENTLY` trong PostgreSQL, tạo DDL trực tuyến (`ALGORITHM=INPLACE, LOCK=NONE`) trong MySQL, và lên lịch khung giờ bảo trì (maintenance window) đối với SQLite.
- Không khuyến nghị tạo chỉ mục đơn lẻ trên một cột có độ chọn lọc thấp (như cột trạng thái status, cột boolean); hãy đưa cột đó vào bên trong một chỉ mục phức hợp hoặc dùng làm điều kiện cho chỉ mục từng phần (partial index).
- Giữ nguyên các khóa chính (primary keys) và ràng buộc duy nhất (unique constraints); không bao giờ đề xuất xóa chúng đi để giảm "sự dư thừa".
- Nhắn người dùng tạo từng chỉ mục một và đối chiếu kế hoạch thực thi (execution plans), đồng thời gỡ bỏ bất kỳ chỉ mục nào mà lệnh EXPLAIN không bao giờ sử dụng tới.
FILE:references/indexing-rules.md
# Các quy tắc đánh chỉ mục (Indexing rules)

Dưới đây là những quy tắc mà công cụ hỗ trợ áp dụng, cùng với những đánh giá chuyên môn mà công cụ không thể tự quyết định thay con người. Các ví dụ sử dụng cú pháp PostgreSQL trừ khi có ghi chú khác.

## 1. Thứ tự cột trong chỉ mục phức hợp (composite index)

Một chỉ mục B-tree được sắp xếp theo cột đầu tiên của nó, sau đó đến cột thứ hai, và cứ tiếp tục như vậy. Một câu truy vấn có thể tìm kiếm nhảy cóc (seek) trên một chuỗi các cột liên tiếp tính từ ngoài cùng bên trái được so sánh bằng (equality), sau đó sử dụng thêm một cột tiếp theo cho điều kiện khoảng (range) hoặc cho việc sắp xếp (sorting).

Thứ tự chuẩn: **các cột so sánh bằng (equality columns), tiếp đến là các cột sắp xếp (sort columns), rồi đến một cột khoảng giá trị (range column)**.
```sql
-- WHERE customer_id = ? AND status = 'paid' ORDER BY created_at DESC LIMIT 20
CREATE INDEX ON orders (customer_id, status, created_at);
```
- Giữa các cột cùng dùng phép so sánh bằng, hãy đặt cột được sử dụng bởi nhiều truy vấn nhất lên đầu tiên để chỉ mục đó phục vụ được tất cả các truy vấn này; độ chọn lọc (selectivity) ít quan trọng hơn khả năng tái sử dụng một khi tất cả các cột đó đều là phép so sánh bằng.
- Đằng sau một cột lọc theo khoảng giá trị (`>`, `<`, `BETWEEN`, `LIKE 'abc%'`), các cột đứng sau nó không thể được dùng để tìm kiếm định vị (seeking) nữa, mà chỉ có thể dùng để lọc tuần tự ngay bên trong chỉ mục.
- Thao tác sắp xếp (sort) chỉ có thể tận dụng chỉ mục nếu mọi cột trong mệnh đề ORDER BY đều nằm trong chỉ mục theo đúng thứ tự đó và có chiều sắp xếp giống hệt nhau (hoặc đảo ngược hoàn toàn tất cả). Nếu chiều sắp xếp hỗn hợp (tăng giảm đan xen), cần phải định nghĩa chỉ mục khớp đúng theo chiều đó (`created_at DESC, id ASC`).
- Khi vừa có điều kiện khoảng giá trị vừa có mệnh đề ORDER BY trên một cột khác, cơ sở dữ liệu buộc phải lựa chọn: hoặc tìm kiếm theo khoảng giá trị rồi mới đem đi sắp xếp, hoặc duyệt theo thứ tự đã sắp xếp sẵn của chỉ mục rồi lọc dần. Khi đi kèm với LIMIT, phương án duyệt theo thứ tự sắp xếp sẵn thường chiến thắng.

## 2. Tiền tố ngoài cùng bên trái (Leftmost prefix) và việc hợp nhất chỉ mục

Một chỉ mục trên `(a, b, c)` phục vụ được các truy vấn lọc trên `a`, trên `a, b`, và trên `a, b, c`, nhưng không phục vụ được truy vấn chỉ lọc riêng trên `b` hoặc `c`. Hai chỉ mục ứng viên mà trong đó một cái là tiền tố ngoài cùng bên trái của cái kia thì nên được gộp lại thành một chỉ mục duy nhất. Một chỉ mục hiện có trên `(a)` sẽ trở nên dư thừa một khi đã có chỉ mục `(a, b)`, trừ phi chỉ mục `(a)` đó có ràng buộc duy nhất (unique), là chỉ mục từng phần (partial), hoặc có kích thước nhỏ hơn hẳn và được truy cập cực kỳ thường xuyên.

## 3. Phép nối (Joins) và khóa ngoại (Foreign keys)

- Hãy đánh chỉ mục trên cột thuộc về phía bảng được tra cứu: thông thường chính là cột khóa ngoại (`orders.customer_id`). Phía khóa chính vốn dĩ đã được đánh chỉ mục sẵn.
- PostgreSQL không tự động đánh chỉ mục cho các khóa ngoại (trong khi MySQL InnoDB thì có). Việc thiếu chỉ mục khóa ngoại cũng khiến thao tác xóa (DELETE) trên bảng cha bị chậm đi đáng kể.
- Cột dùng để join cộng với các điều kiện lọc riêng của chính bảng đó có thể kết hợp lại thành một chỉ mục phức hợp: `(customer_id, created_at)`.

## 4. Chỉ mục bao phủ (Covering indexes)

Nếu chỉ mục đã lưu giữ toàn bộ mọi cột mà câu truy vấn cần đọc, cơ sở dữ liệu có thể bỏ qua bước đọc bảng chính (quét chỉ mục thuần túy — index-only scan).
- PostgreSQL 11+ và SQL Server: dùng mệnh đề `INCLUDE (col, ...)` để đính kèm thêm các cột phụ vào trang lá của chỉ mục mà không cần sắp xếp theo chúng.
- MySQL và SQLite: nối thêm các cột đó vào cuối phần khóa chỉ mục; các chỉ mục phụ (secondary indexes) của InnoDB vốn đã tự động đính kèm khóa chính bên trong.
- Chỉ nên áp dụng cho các truy vấn chạy liên tục, chủ yếu là đọc dữ liệu và chỉ lấy ra một vài cột. Các chỉ mục bao phủ quá rộng sẽ chiếm nhiều dung lượng và làm chậm tốc độ ghi.
- Quá trình quét chỉ mục thuần túy (index-only scan) trên PostgreSQL cũng đòi hỏi bảng dữ liệu phải được dọn dẹp (`VACUUM`) thường xuyên để cập nhật bản đồ hiển thị (visibility map).

## 5. Chỉ mục từng phần (Partial) và chỉ mục biểu thức (Expression)

- Chỉ mục từng phần (PostgreSQL, SQLite; tương đương filtered index trong SQL Server): `CREATE INDEX ON orders (created_at) WHERE status = 'paid' AND shipped_at IS NULL;` Cực kỳ nhỏ gọn và nhanh chóng khi điều kiện WHERE chỉ khớp với một tỷ lệ nhỏ các dòng dữ liệu. Lưu ý câu truy vấn bắt buộc phải lặp lại chính xác điều kiện lọc đó thì mới kích hoạt được chỉ mục.
- Biểu thức (Expression): `CREATE INDEX ON users (lower(email));` phục vụ điều kiện `WHERE lower(email) = ?`. MySQL 8 hỗ trợ chỉ mục hàm (functional index) `((lower(email)))` hoặc tạo một cột sinh tự động (generated column) kèm chỉ mục.

## 6. Khả năng tận dụng chỉ mục (Sargability): những bộ lọc mà chỉ mục không thể sử dụng

| Khuôn mẫu | Vấn đề | Cách khắc phục |
|---|---|---|
| `WHERE date(created_at) = '2026-10-12'` | gọi hàm trên cột | `created_at >= '2026-10-12' AND created_at < '2026-10-13'` |
| `WHERE YEAR(updated_at) = 2026` | gọi hàm trên cột | lọc theo khoảng giá trị trên cột |
| `WHERE price * 1.2 > 100` | phép tính số học trên cột | `price > 100 / 1.2` |
| `WHERE name LIKE '%lamp%'` | ký tự đại diện (wildcard) ở đầu | tìm kiếm toàn văn (full-text search), hoặc chỉ mục GIN `pg_trgm` |
| `WHERE lower(name) = ?` | gọi hàm | chỉ mục biểu thức trên `lower(name)` hoặc dùng bảng đối chiếu không phân biệt hoa thường (case-insensitive collation) |
| `WHERE a = 1 OR b = 2` | dùng OR giữa các cột khác nhau | `UNION` hai câu truy vấn có chỉ mục, hoặc tạo một chỉ mục cho mỗi cột (bitmap OR trong PostgreSQL) |
| `WHERE status <> 'done'`, `NOT IN` | bộ lọc phủ định | dùng danh sách khẳng định `IN (...)` hoặc chỉ mục một phần (partial index) |
| `WHERE id = '42'` trên cột số nguyên | ép kiểu ngầm định (MySQL có thể bỏ qua chỉ mục trên các cột chuỗi khi đem so sánh với số) | so sánh đúng kiểu dữ liệu |

## 7. Đánh chỉ mục quá mức (Over-indexing)

- Mỗi chỉ mục đều phải cập nhật khi thực hiện INSERT, DELETE và khi UPDATE các cột của nó; ngoài ra nó còn tiêu tốn dung lượng ổ đĩa, bộ nhớ (buffer cache), cũng như băng thông sao lưu và nhân bản dữ liệu (replication).
- Các chỉ mục trùng lặp (cùng danh sách cột, cùng điều kiện) hoàn toàn chỉ gây tốn kém chi phí: hãy xóa bớt một cái.
- Chỉ mục đơn cột trên các cột có độ chọn lọc thấp (boolean, status, type) hiếm khi được sử dụng; bộ lập kế hoạch (planner) thường ưu tiên quét bảng khi một giá trị khớp với hơn khoảng 5 đến 20 phần trăm số dòng. Hãy đưa chúng vào trong các chỉ mục phức hợp (composite index) hoặc dùng làm điều kiện cho chỉ mục một phần (partial index).
- Theo kinh nghiệm thực tế, nếu một bảng có tần suất ghi cao mà có trên 5 hoặc 6 chỉ mục thì cần được rà soát lại; các bảng chủ yếu phục vụ đọc có thể chịu được nhiều chỉ mục hơn.
- Tìm các chỉ mục không được sử dụng trước khi xóa: trên PostgreSQL kiểm tra `pg_stat_user_indexes.idx_scan = 0` (kể từ lần đặt lại thống kê gần nhất, trên tất cả các máy chủ replica), trên MySQL kiểm tra `sys.schema_unused_indexes`. Xóa bằng lệnh `DROP INDEX CONCURRENTLY` trong PostgreSQL.

## 8. Xây dựng chỉ mục an toàn

- PostgreSQL: `CREATE INDEX CONCURRENTLY` giúp tránh chặn các thao tác ghi; lệnh này không thể chạy bên trong một giao dịch (transaction) và sẽ để lại một chỉ mục ở trạng thái INVALID nếu thất bại (hãy xóa đi và thử lại).
- MySQL 8: `ALTER TABLE ... ADD INDEX ..., ALGORITHM=INPLACE, LOCK=NONE`; cần theo dõi độ trễ nhân bản (replication lag) trên các bảng lớn.
- SQLite: quá trình tạo chỉ mục sẽ khóa cơ sở dữ liệu; hãy chạy vào thời điểm ít lưu lượng truy cập.
- Tạo từng chỉ mục một, kiểm tra kế hoạch thực thi, rồi mới tiếp tục với chỉ mục tiếp theo.
FILE:references/explain-reading-guide.md
# Đọc EXPLAIN để xác nhận hiệu quả chỉ mục

Luôn so sánh kế hoạch thực thi trước và sau khi thay đổi, trên dữ liệu tương tự môi trường production và với các giá trị tham số thực tế. Các bảng kiểm thử quy mô nhỏ sẽ khiến bộ lập kế hoạch chọn phương án quét bảng vốn chỉ phù hợp cho bảng nhỏ.

## PostgreSQL

```sql
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;   -- runs the query; wrap writes in BEGIN ... ROLLBACK
```
| Nút kế hoạch (Plan node) | Ý nghĩa |
|---|---|
| Seq Scan | đọc toàn bộ bảng; ổn đối với bảng nhỏ hoặc khi phần lớn số dòng đều khớp điều kiện |
| Index Scan | tra cứu chỉ mục, sau đó đọc các dòng tương ứng trong bảng |
| Index Only Scan | trả kết quả trực tiếp từ chỉ mục; hãy kiểm tra chỉ số "Heap Fetches" xem có thấp không (nếu không thấp hãy chạy vacuum) |
| Bitmap Index Scan + Bitmap Heap Scan | thu thập nhiều kết quả khớp, sau đó đọc các trang dữ liệu theo thứ tự; phổ biến với độ chọn lọc trung bình và điều kiện OR |
| Sort (kèm "external merge") | quá trình sắp xếp bị tràn ra ổ đĩa; một chỉ mục theo đúng thứ tự ORDER BY có thể loại bỏ bước này |
| Rows Removed by Filter | số dòng đã đọc lên nhưng bị loại bỏ; con số này cao nghĩa là chỉ mục chưa bao phủ được bộ lọc |

Hãy chú ý đến: thời gian thực tế (actual time) của nút trên cùng, số dòng ước tính so với thực tế (chênh lệch gấp 10 lần nghĩa là thống kê đã cũ: hãy chạy `ANALYZE table`), và các chỉ số bộ đệm shared hit/read buffers. Xác nhận chỉ mục mới thực sự được sử dụng thông qua `pg_stat_user_indexes.idx_scan` sau một ngày có lưu lượng truy cập thực tế.

## MySQL 8

```sql
EXPLAIN FORMAT=TREE SELECT ...;
EXPLAIN ANALYZE SELECT ...;               -- 8.0.18+, runs the query
```
Trong đầu ra `EXPLAIN` truyền thống, hãy kiểm tra: `type` (ALL = quét toàn bộ bảng, ref/eq_ref/range = có dùng chỉ mục, index = quét toàn bộ chỉ mục), `key` (chỉ mục được chọn), `rows` (ước tính), và `Extra` ("Using index" = chỉ mục bao phủ, "Using filesort" = phát sinh bước sắp xếp phụ, "Using temporary" = dùng bảng tạm cho GROUP BY hoặc DISTINCT). Làm mới thống kê bằng lệnh `ANALYZE TABLE t`.

## SQLite

```sql
EXPLAIN QUERY PLAN SELECT ...;
```
"SCAN t" là quét toàn bộ bảng; "SEARCH t USING INDEX idx (a=? AND b>?)" cho biết những cột nào được dùng để tra cứu; "USING COVERING INDEX" nghĩa là không cần truy cập vào bảng dữ liệu; "USE TEMP B-TREE FOR ORDER BY" nghĩa là phát sinh thêm bước sắp xếp. Hãy chạy `ANALYZE;` để bộ lập kế hoạch có dữ liệu thống kê.

## Dấu hiệu của sự thành công

- Kế hoạch thực thi của câu truy vấn chậm chuyển từ quét toàn bộ bảng kèm sắp xếp sang quét chỉ mục (hoặc chỉ quét chỉ mục - index only scan) mà không cần sắp xếp, đồng thời số dòng phải đọc giảm xuống gần bằng số dòng trả về.
- Các thao tác ghi trên bảng không bị chậm đi đáng kể (hãy kiểm tra độ trễ p95 của thao tác insert và update sau khi triển khai).
- Không có câu truy vấn quan trọng nào khác bị đổi sang kế hoạch thực thi tệ hơn; hãy chạy tập các truy vấn chiếm nhiều tổng thời gian nhất (`pg_stat_statements`, các bảng digest trong performance schema của MySQL) trước và sau khi thay đổi.

## Khi chỉ mục không được sử dụng

1. Thống kê đã cũ: hãy chạy ANALYZE.
2. Bộ lọc không có khả năng tận dụng chỉ mục - non-sargable (xem mục 6 trong indexing-rules.md).
3. Giá trị cần tìm khớp với quá nhiều dòng; bộ lập kế hoạch chọn quét bảng là hoàn toàn hợp lý.
4. Kiểu dữ liệu của cột hoặc bảng đối chiếu (collation) không đồng nhất giữa câu truy vấn và cột trong bảng (ép kiểu ngầm định).
5. Câu truy vấn tham số hóa sử dụng kế hoạch thực thi chung (generic plan); hãy kiểm thử bằng câu lệnh prepared statement thực tế.
6. Các cột trong chỉ mục đang sắp xếp sai thứ tự so với yêu cầu của câu truy vấn.
FILE:templates/index-review.md
# Đánh giá chỉ mục: app_or_service

**Hệ CSDL (Engine):** engine_and_version | **Các bảng:** tables_with_row_counts | **Tải ghi (Write load):** write_profile
**Công cụ hỗ trợ (Helper):** Đã phân tích n_queries truy vấn, n_suggested ứng viên chỉ mục, n_findings phát hiện về lược đồ (schema)

## Tóm tắt

Hai hoặc ba câu: nguyên nhân chính gây chậm, các chỉ mục cần thêm vào và xóa đi, cùng sự thay đổi dự kiến trong kế hoạch thực thi.

## Chẩn đoán theo từng truy vấn

| Truy vấn | Tần suất chạy | Hiện tại (kế hoạch / thời gian) | Vấn đề | Cách khắc phục |
|---|---|---|---|---|
| Q1 | per_minute | Seq Scan + Sort | | |

## Viết lại câu truy vấn (thực hiện bước này trước)

```sql
-- before
-- after
```

## Các chỉ mục cần thêm (tạo theo đúng thứ tự này)

| # | Lệnh DDL | Phục vụ truy vấn | Lý do chọn thứ tự cột này | Chi phí |
|---|---|---|---|---|
| 1 | | | | |

## Các chỉ mục cần xóa (sau khi đã kiểm tra mức độ sử dụng)

| Chỉ mục | Lý do | Kiểm tra trước khi xóa |
|---|---|---|
| | trùng lặp / tiền tố dư thừa / không được sử dụng / độ chọn lọc thấp | `idx_scan` trên máy chủ chính (primary) và các máy chủ bản sao (replicas) |

## Tác động ròng trên từng bảng

| Bảng | Số chỉ mục trước | Sau khi thay đổi | Ghi chú |
|---|---|---|---|

## Cách xác minh

1. Thu thập `EXPLAIN (ANALYZE, BUFFERS)` cho từng câu truy vấn trước khi thay đổi.
2. Tạo từng chỉ mục một (`CONCURRENTLY` / online DDL), chạy `ANALYZE`, sau đó thu thập lại kế hoạch thực thi.
3. Kỳ vọng: (sự thay đổi kế hoạch thực thi cho từng câu truy vấn).
4. Theo dõi độ trễ ghi và độ trễ nhân bản (replication lag) trong một ngày; sau đó mới xóa các chỉ mục được liệt kê ở trên.

## Lưu ý

- Các đề xuất chỉ dựa trên văn bản câu truy vấn và lược đồ (schema); số lượng dòng thực tế và độ lệch dữ liệu (data skew) có thể làm thay đổi lựa chọn tối ưu nhất.
FILE:examples/example-shop-orders-review.md
# Ví dụ: Đánh giá chỉ mục cho một cửa hàng trực tuyến

## Yêu cầu của người dùng

"Cơ sở dữ liệu cửa hàng chạy PostgreSQL 16 của chúng tôi đang bị chậm dần. Bảng orders có 12 triệu dòng và nhận khoảng 40 lệnh insert mỗi giây, bảng customers có 900 nghìn dòng, bảng products có 20 nghìn dòng. Dưới đây là schema và sáu câu truy vấn xuất hiện trong pg_stat_statements. Chúng tôi nên thêm những chỉ mục nào, và liệu hiện tại chúng tôi đã có quá nhiều chỉ mục hay chưa?"

## Chạy công cụ hỗ trợ (Helper)

```
$ python3 scripts/suggest_indexes.py --schema shop-schema.sql slow-queries.sql
Schema: 4 table(s), 12 existing index(es) incl. keys | dialect: postgres
Queries analyzed: 7

Q1 SELECT: SELECT o.id, o.total, o.created_at FROM orders o WHERE o.customer_id = $1 AND o.status = 'paid' ORDER BY o.cre...
  equality: orders.customer_id (=), orders.status (=)
  order by: orders.created_at DESC  (LIMIT)
Q2 SELECT: SELECT c.email, count(*) AS orders FROM customers c JOIN orders o ON o.customer_id = c.id WHERE c.country = 'D...
  equality: customers.country (=)
  range: orders.created_at (range)
  joins: orders.customer_id = customers.id
  group by: customers.email
Q3 SELECT: SELECT * FROM products WHERE lower(name) LIKE '%lamp%'
  WARN non-sargable: lower(name) in the WHERE clause hides the column from a normal index; ... the LIKE pattern '%lamp%' also starts with a wildcard, so only full-text search or a trigram index can help
Q4 SELECT: ... WHERE oi.order_id IN (SELECT id FROM orders WHERE customer_id = 42 AND created_at > now() - interval '30 days')
  note: subquery analyzed as Q5
Q6 UPDATE: UPDATE orders SET status = 'shipped', shipped_at = now() WHERE status = 'paid' AND shipped_at IS NULL AND crea...
Q7 SELECT: SELECT id FROM customers WHERE email = 'ann@example.com' OR phone = '+491701234567'
  WARN OR across different columns (...): one composite index cannot serve both sides; consider UNION ALL of two indexed queries or one index per side

Suggested indexes (4):
  1. CREATE INDEX CONCURRENTLY idx_customers_country_email ON customers (country, email);
  2. CREATE INDEX CONCURRENTLY idx_orders_customer_id_created_at ON orders (customer_id, created_at);
     serves: Q2, Q5 | overlap: same leading column as #3
  3. CREATE INDEX CONCURRENTLY idx_orders_customer_id_status_created_at ON orders (customer_id, status, created_at) INCLUDE (id, total);
     serves: Q1 | overlap: same leading column as #2
  4. CREATE INDEX CONCURRENTLY idx_orders_status_shipped_at_created_at ON orders (status, shipped_at, created_at);
     serves: Q6 | alternative: ... ON orders (status, created_at) WHERE shipped_at IS NULL;

Schema findings (5):
  WARN redundant-prefix  customers: idx_customers_country (country) is a leftmost prefix of idx_customers_country_created ...
  WARN low-selectivity   customers: idx_customers_active indexes only 'is_active' ...
  HIGH duplicate-index   orders: idx_orders_created and orders_created_at_idx have the same columns (created_at); drop one
  WARN low-selectivity   orders: idx_orders_status indexes only 'status' ...
  WARN many-indexes      orders: 4 existing + 3 suggested = 7 indexes (limit 6) ...
```

## Bản đánh giá (mẫu đã điền, rút gọn)

**Tóm tắt:** Bảng orders không có chỉ mục nào bắt đầu bằng cột customer_id, vì vậy trang lịch sử đơn hàng (Q1) và truy vấn tra cứu đơn hàng gần đây (Q5) phải quét hàng triệu dòng, đồng thời hai trong số bốn chỉ mục hiện có của bảng này là một chỉ mục bị trùng lặp và một chỉ mục trên cột status có độ chọn lọc thấp. Hãy thêm một chỉ mục phức hợp và một chỉ mục một phần (partial index) trên bảng orders, thêm một chỉ mục cột phone và một chỉ mục trigram ở các bảng khác, đồng thời xóa đi ba chỉ mục: bảng orders cuối cùng vẫn giữ nguyên tổng số 4 chỉ mục nhưng mọi chỉ mục đều thực sự hữu ích.

| Truy vấn | Vấn đề | Cách khắc phục |
|---|---|---|
| Q1 lịch sử đơn hàng | không có chỉ mục trên customer_id; cần sắp xếp theo created_at | đánh chỉ mục (customer_id, created_at) kèm status và total trong INCLUDE: lấy ngay các dòng mới nhất của khách hàng đó, lọc status ngay bên trong chỉ mục, LIMIT dừng lại sau 20 dòng |
| Q2 báo cáo theo quốc gia | phép join cần orders.customer_id; cột customers.country đã được phục vụ bởi idx_customers_country_created | dùng chung chỉ mục của bảng orders ở trên; không cần thêm chỉ mục mới cho bảng customers (đề xuất #1 của công cụ hỗ trợ chỉ giúp ích cho GROUP BY, mà báo cáo này chỉ chạy mỗi đêm một lần) |
| Q3 tìm kiếm sản phẩm | `lower(name) LIKE '%lamp%'` không thể dùng chỉ mục B-tree | tạo chỉ mục GIN pg_trgm trên biểu thức lower(name) |
| Q4/Q5 các mặt hàng gần đây | truy vấn con cần (customer_id, created_at); khóa chính PK (order_id, product_id) của bảng order_items đã phục vụ toán tử IN | dùng chung chỉ mục của bảng orders ở trên |
| Q6 tiến trình giao hàng | quét bảng theo cột status | tạo chỉ mục một phần (partial index) trên cột created_at chỉ dành riêng cho các đơn hàng đã thanh toán nhưng chưa giao |
| Q7 tra cứu hỗ trợ khách hàng | dùng OR giữa hai cột email và phone | tách thành UNION của hai truy vấn tra cứu; cột email đã có ràng buộc unique sẵn, chỉ cần thêm một chỉ mục cho cột phone |

Tại sao không thêm cả chỉ mục đề xuất #3 của công cụ hỗ trợ: đề xuất #2 và #3 có chung cột dẫn đầu customer_id, và trên một bảng có tới 40 lệnh insert mỗi giây thì duy trì một chỉ mục sẽ tốt hơn là hai. Người dùng đã xác nhận rằng khoảng 95 phần trăm đơn hàng ở trạng thái đã thanh toán (paid), do đó Q1 chỉ phải loại bỏ rất ít dòng, và chỉ mục (customer_id, created_at) có đính kèm status trong INCLUDE vẫn giữ cho Q1 chạy rất nhanh. Nếu tỷ lệ đơn hàng chưa thanh toán tăng lên trong tương lai, khi đó hãy chuyển sang thứ tự cột của đề xuất #3.

**Các chỉ mục cần thêm (theo đúng thứ tự này):**

```sql
CREATE INDEX CONCURRENTLY idx_orders_customer_created
    ON orders (customer_id, created_at) INCLUDE (id, status, total);        -- Q1, Q2, Q5
CREATE INDEX CONCURRENTLY idx_orders_paid_unshipped
    ON orders (created_at) WHERE status = 'paid' AND shipped_at IS NULL;    -- Q6, holds only paid, unshipped orders
CREATE INDEX CONCURRENTLY idx_customers_phone ON customers (phone);       -- Q7 after the rewrite
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX CONCURRENTLY idx_products_name_trgm
    ON products USING gin (lower(name) gin_trgm_ops);                      -- Q3, query unchanged
```

**Viết lại câu truy vấn (Q7):**

```sql
SELECT id FROM customers WHERE email = 'ann@example.com'
UNION
SELECT id FROM customers WHERE phone = '+491701234567';
```

**Các chỉ mục cần xóa (sau khi kiểm tra `pg_stat_user_indexes.idx_scan` trên máy chủ chính và cả hai máy chủ replica):**

| Chỉ mục | Lý do |
|---|---|
| orders_created_at_idx | trùng lặp với idx_orders_created |
| idx_orders_status | độ chọn lọc thấp; Q6 hiện đã chuyển sang dùng chỉ mục một phần (partial index) |
| idx_customers_country | là tiền tố ngoài cùng bên trái của idx_customers_country_created |
| idx_customers_active (cần kiểm tra) | kiểu boolean; chỉ giữ lại nếu idx_scan cho thấy có sử dụng thực tế |

**Tác động ròng:** bảng orders từ 4 -> 4 chỉ mục (thay thế hai chỉ mục cũ bằng hai chỉ mục tốt hơn), bảng customers từ 5 -> 5 (xóa một, thêm chỉ mục phone; hoặc giảm xuống 4 nếu xóa luôn cả is_active), bảng products từ 2 -> 3.

**Kiểm tra lại bằng công cụ hỗ trợ** (lược đồ sau khi thay đổi, các câu truy vấn sau khi viết lại; số thứ tự truy vấn tuân theo tệp mới, nên Q3 là Q5 cũ còn Q4/Q5 là hai nhánh của phép UNION):

```
$ python3 scripts/suggest_indexes.py --schema shop-schema-after.sql shop-queries-after.sql --no-covering
Already served by existing indexes (5):
  customers (email) -> customers_email_key (Q4)
  customers (id, country) -> customers_pkey (Q2)
  customers (phone) -> idx_customers_phone (Q5)
  orders (created_at) -> idx_orders_created (Q2)
  orders (customer_id, created_at) -> idx_orders_customer_created (Q2, Q3)
```
Công cụ hỗ trợ vẫn liệt kê (customer_id, status, created_at) cho Q1 và (country, email) cho Q2; cả hai đều đã được chủ đích loại bỏ vì những lý do nêu trên. Không còn phát hiện nào về chỉ mục trùng lặp hay tiền tố dư thừa nữa.

**Cách xác minh:** trước và sau mỗi lần tạo chỉ mục, hãy chạy `EXPLAIN (ANALYZE, BUFFERS)` cho Q1 với một mã khách hàng (customer id) thực tế. Kết quả kỳ vọng là "Index Scan using idx_orders_customer_created" (hoặc Index Only Scan) không còn nút Sort và chỉ đọc khoảng 20 dòng thay vì phải thực hiện Bitmap Heap Scan qua hàng nghìn dòng. Hãy theo dõi độ trễ thao tác insert trên bảng orders trong một ngày trước khi xóa các chỉ mục cũ.
FILE:scripts/suggest_indexes.py
#!/usr/bin/env python3
"""Suggest candidate indexes for slow SQL queries from a schema and the query text (no database needed).

Usage:
  python3 suggest_indexes.py --schema schema.sql queries.sql [options]
  python3 suggest_indexes.py all-in-one.sql                 (CREATE statements and queries in one file)
  python3 suggest_indexes.py --schema schema.sql - < slow.sql

Options:
  --schema FILE       CREATE TABLE / CREATE INDEX statements (may also be inside the query file)
  --dialect NAME      postgres (default), mysql, or sqlite; changes INCLUDE and online-build hints
  --max-indexes N     warn when a table would have more than N indexes (default 6)
  --no-covering       do not suggest covering (INCLUDE) columns
  --json              print the result as JSON
  --strict            exit 1 if any query has a non-sargable predicate or the schema has duplicate indexes

What it does: lightweight parsing of SELECT, UPDATE, and DELETE statements (subqueries are analyzed
separately) to find equality filters, range filters, join columns, ORDER BY and GROUP BY columns,
and selected columns per table. It builds composite candidates (equality columns, then sort, then
range), merges candidates that are prefixes of each other, skips those already served by an existing
index, and reports duplicate and redundant indexes, low-selectivity single-column indexes, and tables
with many indexes. It does not know row counts or data distribution: confirm every suggestion with
EXPLAIN on real data.
Exit codes: 0 ok, 1 --strict findings, 2 usage or read error.
Python 3 standard library only.
"""
import json
import re
import signal
import sys

if hasattr(signal, "SIGPIPE"):
    signal.signal(signal.SIGPIPE, signal.SIG_DFL)  # quiet exit when piped into head

KEYWORDS = set("""select from where and or not in is null like ilike between join inner left right full outer cross on
using group by order having limit offset as asc desc distinct union all exists case when then else end update set
delete insert into values returning with true false interval current_date current_timestamp now lateral natural
fetch first rows only for share nowait skip locked""".split())
LOW_CARD = re.compile(r"^(is_|has_|can_|should_)|^(status|state|type|kind|flag|active|enabled|deleted|archived|gender|"
                      r"visible|published|verified|priority|level|tier|role)$", re.I)
CLAUSE_END = r"(?=\b(?:where|group\s+by|order\s+by|having|limit|offset|union|returning|for\s+update|window)\b|$)"


class UsageError(Exception):
    pass


# ---------------------------------------------------------------- text helpers

def strip_comments(sql):
    sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.S)
    return re.sub(r"--[^\n]*", " ", sql)


def split_statements(sql):
    out, buf, quote = [], [], None
    for ch in sql:
        if quote:
            buf.append(ch)
            if ch == quote:
                quote = None
            continue
        if ch in "'\"`":
            quote = ch
        if ch == ";":
            out.append("".join(buf))
            buf = []
        else:
            buf.append(ch)
    out.append("".join(buf))
    return [s.strip() for s in out if s.strip()]


def unquote_idents(sql):
    sql = re.sub(r'"([A-Za-z_][\w$]*)"', r"\1", sql)
    sql = re.sub(r"`([A-Za-z_][\w$]*)`", r"\1", sql)
    return re.sub(r"\[([A-Za-z_][\w$]*)\]", r"\1", sql)


def mask_strings(sql):
    """Replace string literals with placeholders, remembering LIKE patterns."""
    lits = []
    def rep(m):
        lits.append(m.group(0)[1:-1])
        return f" __lit{len(lits) - 1}__ "
    return re.sub(r"'(?:[^']|'')*'", rep, sql), lits


def split_top(text, sep_regex):
    """Split on a keyword at parenthesis depth 0."""
    parts, depth, last = [], 0, 0
    for m in re.finditer(r"\(|\)|" + sep_regex, text, flags=re.I):
        tok = m.group(0)
        if tok == "(":
            depth += 1
        elif tok == ")":
            depth -= 1
        elif depth == 0:
            parts.append(text[last:m.start()])
            last = m.end()
    parts.append(text[last:])
    return [p.strip() for p in parts if p.strip()]


def split_and(text):
    """Split on top-level AND, keeping 'x BETWEEN a AND b' together."""
    parts = split_top(text, r"\band\b")
    out = []
    for part in parts:
        if out and re.search(r"\bbetween\s+[^\s]+$", out[-1], re.I):
            out[-1] = f"{out[-1]} AND {part}"
        else:
            out.append(part)
    return out


def extract_subqueries(sql):
    """Pull out innermost (SELECT ...) blocks; return (outer_sql, [subqueries])."""
    subs = []
    pattern = re.compile(r"\(\s*(select\b[^()]*(?:\([^()]*\)[^()]*)*)\)", re.I)
    while True:
        m = pattern.search(sql)
        if not m:
            break
        subs.append(m.group(1))
        sql = sql[:m.start()] + " __subq__ " + sql[m.end():]
    return sql, subs


# ---------------------------------------------------------------- schema

def parse_columns_list(text):
    return [re.sub(r"\s+(asc|desc)\b.*$", "", c.strip(), flags=re.I).strip().lower()
            for c in split_top(text, r",")]


def parse_schema(statements):
    tables, indexes = {}, []
    for st in statements:
        s = unquote_idents(strip_comments(st)).strip()
        m = re.match(r"create\s+table\s+(?:if\s+not\s+exists\s+)?([\w.]+)\s*\((.*)\)\s*[^)]*$", s, re.I | re.S)
        if m:
            name = m.group(1).split(".")[-1].lower()
            cols, pk = [], None
            for part in split_top(m.group(2), r","):
                p = part.strip()
                low = p.lower()
                c = re.match(r"(?:constraint\s+\w+\s+)?primary\s+key\s*\(([^)]*)\)", low)
                if c:
                    pk = parse_columns_list(c.group(1))
                    continue
                c = re.match(r"(?:constraint\s+(\w+)\s+)?unique\s*(?:key\s+\w+\s*)?\(([^)]*)\)", low)
                if c:
                    indexes.append({"name": c.group(1) or f"{name}_unique", "table": name,
                                    "columns": parse_columns_list(c.group(2)), "unique": True, "kind": "unique"})
                    continue
                c = re.match(r"(?:key|index)\s+(\w+)\s*\(([^)]*)\)", low)
                if c:
                    indexes.append({"name": c.group(1), "table": name, "columns": parse_columns_list(c.group(2)),
                                    "unique": False, "kind": "index"})
                    continue
                if re.match(r"(constraint|foreign\s+key|check|exclude)\b", low):
                    continue
                col = re.match(r"([a-z_][\w$]*)\s+", low + " ")
                if col:
                    cols.append(col.group(1))
                    if re.search(r"\bprimary\s+key\b", low):
                        pk = [col.group(1)]
                    elif re.search(r"\bunique\b", low):
                        indexes.append({"name": f"{name}_{col.group(1)}_key", "table": name,
                                        "columns": [col.group(1)], "unique": True, "kind": "unique"})
            tables[name] = {"columns": cols}
            if pk:
                indexes.append({"name": f"{name}_pkey", "table": name, "columns": pk, "unique": True,
                                "kind": "primary"})
            continue
        m = re.match(r"create\s+(unique\s+)?index\s+(?:concurrently\s+)?(?:if\s+not\s+exists\s+)?(\w+)\s+on\s+"
                     r"([\w.]+)\s*(?:using\s+\w+\s*)?\(([^;]*?)\)\s*(include\s*\(([^)]*)\))?\s*(where\s+.*)?$",
                     s, re.I | re.S)
        if m:
            cols = parse_columns_list(m.group(4))
            indexes.append({"name": m.group(2), "table": m.group(3).split(".")[-1].lower(), "columns": cols,
                            "unique": bool(m.group(1)), "kind": "index",
                            "include": parse_columns_list(m.group(6)) if m.group(6) else [],
                            "partial": (m.group(7) or "").strip() or None})
    return tables, indexes


# ---------------------------------------------------------------- queries

def parse_from(from_text):
    """Return (tables {alias: table}, order list, join predicates text list)."""
    aliases, order, ons = {}, [], []
    pieces = re.split(r"\b((?:natural\s+)?(?:inner\s+|left\s+(?:outer\s+)?|right\s+(?:outer\s+)?|full\s+(?:outer\s+)?|"
                      r"cross\s+)?join)\b", from_text, flags=re.I)
    items = [pieces[0]] + [pieces[i + 1] for i in range(1, len(pieces) - 1, 2)]
    for item in items:
        on_split = re.split(r"\bon\b", item, maxsplit=1, flags=re.I)
        using = re.search(r"\busing\s*\(([^)]*)\)", on_split[0], re.I)
        head = re.sub(r"\busing\s*\([^)]*\)", "", on_split[0], flags=re.I)
        for ref in head.split(","):
            toks = [t for t in re.split(r"\s+", ref.strip()) if t and t.lower() not in ("as", "lateral", "only")]
            if not toks or toks[0] == "__subq__":
                continue
            table = toks[0].split(".")[-1].lower()
            alias = toks[1].lower() if len(toks) > 1 else table
            aliases[alias] = table
            order.append(alias)
        if len(on_split) > 1:
            ons.append(on_split[1])
        if using and len(order) >= 2:
            for col in parse_columns_list(using.group(1)):
                ons.append(f"{order[-2]}.{col} = {order[-1]}.{col}")
    return aliases, order, ons


COL = r"((?:[a-z_][\w$]*\.)?[a-z_][\w$]*)"
VALUE = r"(?:__lit\d+__|\?|:\w+|\$\d+|@\w+|%s|-?\d+(?:\.\d+)?|true|false|null|__subq__|current_date|current_timestamp|now\(\))"


def resolve(ref, aliases, tables):
    ref = ref.lower()
    if "." in ref:
        a, c = ref.split(".", 1)
        return (aliases.get(a, a), c)
    if len(set(aliases.values())) == 1:
        return (next(iter(aliases.values())), ref)
    owners = [t for t in set(aliases.values()) if ref in tables.get(t, {}).get("columns", [])]
    return (owners[0], ref) if len(owners) == 1 else (None, ref)


def analyze_predicate(pred, lits, aliases, tables, q):
    p = pred.strip()
    while p.startswith("(") and p.endswith(")") and len(split_top(p[1:-1], r"\band\b")) >= 1 and \
            p.count("(") == p.count(")") and _balanced_wrap(p):
        p = p[1:-1].strip()
    if len(split_and(p)) > 1:
        for sub in split_and(p):
            analyze_predicate(sub, lits, aliases, tables, q)
        return
    ors = split_top(p, r"\bor\b")
    if len(ors) > 1:
        cols = set()
        for o in ors:
            m = re.match(rf"^\(?\s*{COL}\s*(=|in\b)", o.strip(), re.I)
            cols.add(m.group(1).lower() if m else None)
        if len(cols) == 1 and None not in cols:
            t, c = resolve(cols.pop(), aliases, tables)
            q["filters"].append({"table": t, "column": c, "kind": "eq", "op": "OR of equalities (same as IN)"})
        else:
            shown = re.sub(r"__lit(\d+)__", lambda m: "'" + lits[int(m.group(1))] + "'", p)
            shown = shown if len(shown) <= 70 else shown[:67] + "..."
            q["warnings"].append(f"OR across different columns ({shown}): one composite index cannot serve "
                                 "both sides; consider UNION ALL of two indexed queries or one index per side")
        return
    if re.match(r"^not\b", p, re.I) or re.search(r"(<>|!=|\bnot\s+in\b|\bnot\s+like\b)", p, re.I):
        m = re.match(rf"^(?:not\s+)?{COL}", p, re.I)
        shown = re.sub(r"__lit(\d+)__", lambda m: "'" + lits[int(m.group(1))] + "'", p)[:50]
        q["notes"].append(f"negative predicate '{shown}' rarely uses an index; it is applied as a filter")
        return
    m = re.match(rf"^{COL}\s*(=|<=>)\s*{COL}$", p, re.I)
    if m and not re.fullmatch(VALUE, m.group(3), re.I) and m.group(3).lower() not in KEYWORDS:
        left, right = resolve(m.group(1), aliases, tables), resolve(m.group(3), aliases, tables)
        if left[0] and right[0] and left[0] != right[0]:
            q["joins"].append((left, right))
            return
    m = re.match(rf"^{VALUE}\s*(=|<|>|<=|>=)\s*{COL}$", p, re.I)
    if m:
        flip = {"<": ">", ">": "<", "<=": ">=", ">=": "<=", "=": "="}
        p = f"{m.group(2)} {flip[m.group(1)]} __v__"
    m = re.match(rf"^{COL}\s*(=|<=>)\s*(.+)$", p, re.I)
    if m and m.group(1).lower() not in KEYWORDS:
        t, c = resolve(m.group(1), aliases, tables)
        q["filters"].append({"table": t, "column": c, "kind": "eq", "op": "="})
        return
    m = re.match(rf"^{COL}\s+in\s+__subq__$", p, re.I)
    if m:
        t, c = resolve(m.group(1), aliases, tables)
        q["filters"].append({"table": t, "column": c, "kind": "eq", "op": "IN (subquery)"})
        return
    m = re.match(rf"^{COL}\s+in\s*\((.*)\)$", p, re.I | re.S)
    if m:
        t, c = resolve(m.group(1), aliases, tables)
        q["filters"].append({"table": t, "column": c, "kind": "eq", "op": "IN"})
        return
    m = re.match(rf"^{COL}\s+is\s+(not\s+)?null$", p, re.I)
    if m:
        t, c = resolve(m.group(1), aliases, tables)
        if m.group(2):
            q["notes"].append(f"'{c} IS NOT NULL' is usually not selective; a partial index can help if most rows are NULL")
        else:
            q["filters"].append({"table": t, "column": c, "kind": "eq", "op": "IS NULL"})
        return
    m = re.match(rf"^{COL}\s*(<|>|<=|>=)\s*(.+)$", p, re.I) or re.match(rf"^{COL}\s+between\s+(.+)\s+and\s+(.+)$", p, re.I)
    if m and m.group(1).lower() not in KEYWORDS:
        t, c = resolve(m.group(1), aliases, tables)
        q["filters"].append({"table": t, "column": c, "kind": "range", "op": "range"})
        return
    m = re.match(rf"^{COL}\s+(i?like)\s+__lit(\d+)__", p, re.I)
    if m:
        t, c = resolve(m.group(1), aliases, tables)
        pattern = lits[int(m.group(3))]
        if pattern.startswith(("%", "_")):
            q["warnings"].append(f"non-sargable: {c} {m.group(2).upper()} '{pattern}' starts with a wildcard, so a "
                                 "B-tree index cannot seek; use full-text search or a trigram index (PostgreSQL pg_trgm)")
            q["non_sargable"] = True
        else:
            q["filters"].append({"table": t, "column": c, "kind": "range", "op": f"LIKE '{pattern}'"})
            if m.group(2).lower() == "ilike":
                q["notes"].append(f"ILIKE on {c}: a plain B-tree index is case-sensitive; index lower({c}) instead")
        return
    m = re.match(rf"^([a-z_]+)\s*\(\s*{COL}[^)]*\)\s*(=|<|>|<=|>=|like|between)", p, re.I)
    if m and m.group(1).lower() not in KEYWORDS:
        func, col = m.group(1).lower(), m.group(2)
        t, c = resolve(col, aliases, tables)
        wild = re.search(r"like\s+__lit(\d+)__", p, re.I)
        extra = ""
        if wild and lits[int(wild.group(1))].startswith(("%", "_")):
            extra = (f"; the LIKE pattern '{lits[int(wild.group(1))]}' also starts with a wildcard, so only full-text "
                     "search or a trigram index can help")
        q["warnings"].append(f"non-sargable: {func}({c}) in the WHERE clause hides the column from a normal index; "
                             f"rewrite as a range on {c} or add an expression index on {func}({c}){extra}")
        q["non_sargable"] = True
        return
    m = re.match(rf"^{COL}\s*[-+*/]\s*\S+\s*(=|<|>|<=|>=)", p, re.I)
    if m:
        t, c = resolve(m.group(1), aliases, tables)
        q["warnings"].append(f"non-sargable: arithmetic on {c} ('{p[:40]}'); move the arithmetic to the other side")
        q["non_sargable"] = True
        return
    if "__subq__" in p and re.search(r"\bexists\b", p, re.I):
        return
    q["notes"].append(f"predicate not understood, check by hand: {p[:60]}")


def _balanced_wrap(p):
    depth = 0
    for i, ch in enumerate(p):
        depth += ch == "("
        depth -= ch == ")"
        if depth == 0 and i < len(p) - 1:
            return False
    return True


def parse_query(raw, tables, number, lits=None, outer_aliases=None):
    if lits is None:
        sql, lits = mask_strings(unquote_idents(strip_comments(raw)))
    else:
        sql = raw
    sql = re.sub(r"\s+", " ", sql).strip()
    outer, subs = extract_subqueries(sql)
    shown = re.sub(r"__lit(\d+)__", lambda m: "'" + lits[int(m.group(1))] + "'", re.sub(r"\s+", " ", raw.strip()))
    shown = re.sub(r"\s+", " ", shown)
    q = {"n": number, "text": shown[:110] + ("..." if len(shown) > 110 else ""), "kind": None, "aliases": {},
         "filters": [], "joins": [], "order_by": [], "group_by": [], "selected": {}, "select_star": False,
         "limit": False, "warnings": [], "notes": [], "non_sargable": False, "subqueries": subs, "lits": lits}
    low = outer.lower()
    cte_names = set()
    if low.startswith("with "):
        cte_names = {n.lower() for n in re.findall(r"(?:with|,)\s*(?:recursive\s+)?(\w+)\s+as\b", outer, re.I)}
        q["notes"].append(f"CTE {', '.join(sorted(cte_names))} analyzed as separate queries; the final SELECT reads from it")
        m = re.search(r"(?:\)|__subq__)\s*((?:select|update|delete)\b.*)$", outer, re.I)
        outer = m.group(1) if m else outer
        low = outer.lower()
    if low.startswith("select"):
        q["kind"] = "SELECT"
        m = re.match(r"select\s+(?:distinct\s+)?(.*?)\s+from\s+(.*?)" + CLAUSE_END, outer, re.I)
        if not m:
            q["notes"].append("no FROM clause found")
            return q
        select_list, from_text = m.group(1), m.group(2)
    elif low.startswith("update"):
        q["kind"] = "UPDATE"
        m = re.match(r"update\s+(.*?)\s+set\s+", outer, re.I)
        select_list, from_text = "", m.group(1) if m else ""
    elif low.startswith("delete"):
        q["kind"] = "DELETE"
        m = re.match(r"delete\s+from\s+(.*?)" + CLAUSE_END, outer, re.I)
        select_list, from_text = "", m.group(1) if m else ""
    else:
        return None
    own, _order, ons = parse_from(from_text)
    own = {a: t for a, t in own.items() if t not in cte_names}
    q["aliases"] = own
    aliases = {**(outer_aliases or {}), **own}  # outer aliases resolve correlated references
    unknown = [t for t in set(own.values()) if tables and t not in tables]
    if unknown:
        q["notes"].append(f"table(s) not in the schema: {', '.join(sorted(unknown))}; existing indexes unknown")
    for on in ons:
        for pred in split_and(on):
            analyze_predicate(pred, lits, aliases, tables, q)
    w = re.search(r"\bwhere\b(.*?)(?=\b(?:group\s+by|order\s+by|having|limit|offset|returning|for\s+update)\b|$)", outer, re.I)
    if w:
        for pred in split_and(w.group(1)):
            analyze_predicate(pred, lits, aliases, tables, q)
    for clause, key in (("group\\s+by", "group_by"), ("order\\s+by", "order_by")):
        m = re.search(rf"\b{clause}\b(.*?)(?=\b(?:having|order\s+by|limit|offset|for\s+update)\b|$)", outer, re.I)
        if m:
            for item in split_top(m.group(1), r","):
                mm = re.match(rf"^{COL}\s*(asc|desc)?(?:\s+nulls\s+(?:first|last))?$", item.strip(), re.I)
                if mm:
                    t, c = resolve(mm.group(1), aliases, tables)
                    q[key].append({"table": t, "column": c, "dir": (mm.group(2) or "asc").lower()})
                else:
                    q["notes"].append(f"{key.replace('_', ' ')} expression '{item.strip()[:40]}' cannot use a plain column index")
    q["limit"] = bool(re.search(r"\b(limit|fetch\s+first)\b", outer, re.I))
    if select_list:
        if re.search(r"(^|,)\s*(\w+\.)?\*\s*(,|$)", select_list):
            q["select_star"] = True
        for ref in re.findall(rf"\b{COL}\b(?!\s*\()", select_list, re.I):
            if ref.lower() in KEYWORDS or ref.startswith("__") or re.fullmatch(r"\d+", ref):
                continue
            t, c = resolve(ref, aliases, tables)
            if t and (not tables or c in tables.get(t, {}).get("columns", [c])):
                q["selected"].setdefault(t, [])
                if c not in q["selected"][t]:
                    q["selected"][t].append(c)
    for f in q["filters"]:
        if f["table"] is None:
            q["notes"].append(f"column '{f['column']}' could belong to several tables; qualify it with an alias")
    return q


# ---------------------------------------------------------------- candidates

def candidates_for(q, dialect, covering, pk_cols=None):
    out = []
    by_table = {}
    for f in q["filters"]:
        if f["table"]:
            by_table.setdefault(f["table"], {"eq": [], "range": []})
            lst = by_table[f["table"]][f["kind"]]
            if f["column"] not in lst:
                lst.append(f["column"])
    tables_in_q = set(q["aliases"].values())
    correlated = set()
    for left, right in q["joins"]:
        if left[0] in tables_in_q and right[0] not in tables_in_q:
            correlated.add(left[0])
        if right[0] in tables_in_q and left[0] not in tables_in_q:
            correlated.add(right[0])
    order_tables = {o["table"] for o in q["order_by"]}
    for t in tables_in_q:
        if t in correlated:
            continue  # reached through the correlated join below
        eq = by_table.get(t, {}).get("eq", [])
        rng = [c for c in by_table.get(t, {}).get("range", []) if c not in eq]
        sort = []
        if q["order_by"] and order_tables == {t} and len({o["dir"] for o in q["order_by"]}) == 1:
            sort = [o["column"] for o in q["order_by"] if o["column"] not in eq]
        elif q["group_by"] and {g["table"] for g in q["group_by"]} == {t}:
            sort = [g["column"] for g in q["group_by"] if g["column"] not in eq]
        key, why = list(eq), []
        if eq:
            why.append(f"equality on {', '.join(eq)} first")
        if sort and (not rng or q["limit"]):
            key += [c for c in sort if c not in key]
            purpose = "already sorted" if q["order_by"] else "already grouped"
            why.append(f"then {', '.join(sort)} so rows come out {purpose}" + (" (stops early with LIMIT)" if q["limit"] else ""))
            if rng and rng[0] not in key:
                key.append(rng[0])
                why.append(f"{rng[0]} last as an in-index filter")
        elif rng:
            key.append(rng[0])
            why.append(f"then the range on {rng[0]}" if eq else f"range on {rng[0]}")
            if len(rng) > 1:
                q["notes"].append(f"several range filters on {t} ({', '.join(rng)}): only the first can seek; the rest filter")
            if sort:
                q["notes"].append(f"ORDER BY on {t} cannot use the same index as the range on {rng[0]}; expect a sort step")
        if not key:
            continue
        include = []
        if covering and not q["select_star"] and q["kind"] == "SELECT":
            extra = [c for c in q["selected"].get(t, []) if c not in key
                     and not (dialect == "mysql" and c in (pk_cols or {}).get(t, []))]
            if 0 < len(extra) <= 3:
                include = extra
        out.append({"table": t, "columns": key, "include": include, "why": "; ".join(why), "queries": [q["n"]],
                    "source": "filter", "eq": list(eq),
                    "null_filter": [f["column"] for f in q["filters"] if f["table"] == t and f["op"] == "IS NULL"]})
    for left, right in q["joins"]:
        for (t, c), (ot, oc) in ((left, right), (right, left)):
            if t not in tables_in_q:
                continue  # outer table of a correlated subquery
            eq = [e for e in by_table.get(t, {}).get("eq", []) if e != c]
            rng = [r for r in by_table.get(t, {}).get("range", []) if r != c and r not in eq][:1]
            why = f"join column {t}.{c} = {ot}.{oc} first (lets the planner look up matching rows)"
            if eq or rng:
                why += f", then {', '.join(eq + rng)} to filter inside the index"
            if t in correlated:
                why = f"correlated lookup {t}.{c} = outer {ot}.{oc} first" + (f", then {', '.join(eq + rng)}" if eq or rng else "")
            out.append({"table": t, "columns": [c] + eq + rng, "include": [], "queries": [q["n"]], "source": "join",
                        "eq": [c] + eq, "why": why})
    return out


def served_by(cand, indexes):
    for ix in indexes:
        if ix["table"] != cand["table"]:
            continue
        cols = ix["columns"]
        if cand["source"] == "join" and ix["unique"] and cols == cand["columns"][:1] and not ix.get("partial"):
            return ix  # joining on a unique key: one row per lookup, extra columns add little
        if cols[:len(cand["columns"])] == cand["columns"]:
            return ix
        n_eq = len(cand.get("eq", []))
        if n_eq > 1 and len(cols) >= len(cand["columns"]) and set(cols[:n_eq]) == set(cand["columns"][:n_eq]) \
                and cols[n_eq:len(cand["columns"])] == cand["columns"][n_eq:]:
            return ix  # same equality columns in another order serve the query equally well
    return None


def served_by_named(c, indexes):
    ix = served_by(c, indexes)
    return {"existing": ix["name"] if ix else "?"}


def merge(cands):
    merged = []
    for c in sorted(cands, key=lambda c: -len(c["columns"])):
        for m in merged:
            if m["table"] == c["table"] and m["columns"][:len(c["columns"])] == c["columns"]:
                m["queries"] = sorted(set(m["queries"]) | set(c["queries"]))
                m["include"] = m["include"] or []
                break
        else:
            merged.append(dict(c))
    return sorted(merged, key=lambda c: (c["table"], c["columns"]))


def schema_findings(indexes, max_indexes, new_by_table):
    out = []
    by_table = {}
    for ix in indexes:
        by_table.setdefault(ix["table"], []).append(ix)
    for t, ixs in by_table.items():
        for i, a in enumerate(ixs):
            for b in ixs[i + 1:]:
                if a["columns"] == b["columns"] and (a.get("partial") == b.get("partial")):
                    out.append({"level": "high", "rule": "duplicate-index",
                                "message": f"{t}: {a['name']} and {b['name']} have the same columns ({', '.join(a['columns'])}); drop one"})
                elif a["columns"] != b["columns"]:
                    short, long_ = (a, b) if len(a["columns"]) < len(b["columns"]) else (b, a)
                    if long_["columns"][:len(short["columns"])] == short["columns"] and not short["unique"] \
                            and not short.get("partial"):
                        out.append({"level": "warn", "rule": "redundant-prefix",
                                    "message": f"{t}: {short['name']} ({', '.join(short['columns'])}) is a leftmost prefix of "
                                               f"{long_['name']} ({', '.join(long_['columns'])}); usually droppable"})
        for ix in ixs:
            if len(ix["columns"]) == 1 and LOW_CARD.search(ix["columns"][0]) and ix["kind"] == "index" and not ix.get("partial"):
                out.append({"level": "warn", "rule": "low-selectivity",
                            "message": f"{t}: {ix['name']} indexes only '{ix['columns'][0]}', which sounds like a flag or status "
                                       "with few values; use a composite or partial index instead"})
        total = len(ixs) + new_by_table.get(t, 0)
        if total > max_indexes:
            out.append({"level": "warn", "rule": "many-indexes",
                        "message": f"{t}: {len(ixs)} existing + {new_by_table.get(t, 0)} suggested = {total} indexes "
                                   f"(limit {max_indexes}); every insert and update maintains all of them"})
    return out


def ddl(c, dialect):
    name = f"idx_{c['table']}_{'_'.join(c['columns'])}"[:63]
    cols = ", ".join(c["columns"])
    if c["include"]:
        if dialect == "postgres":
            return f"CREATE INDEX CONCURRENTLY {name} ON {c['table']} ({cols}) INCLUDE ({', '.join(c['include'])});"
        cols += ", " + ", ".join(c["include"])
    if dialect == "postgres":
        return f"CREATE INDEX CONCURRENTLY {name} ON {c['table']} ({cols});"
    if dialect == "mysql":
        return f"ALTER TABLE {c['table']} ADD INDEX {name} ({cols}), ALGORITHM=INPLACE, LOCK=NONE;"
    return f"CREATE INDEX {name} ON {c['table']} ({cols});"


# ---------------------------------------------------------------- main

def parse_args(argv):
    opts = {"schema": None, "files": [], "dialect": "postgres", "max_indexes": 6, "covering": True,
            "json": False, "strict": False}
    i = 0
    while i < len(argv):
        a = argv[i]
        if a in ("-h", "--help"):
            print(__doc__)
            sys.exit(0)
        if a in ("--schema", "--dialect", "--max-indexes"):
            if i + 1 >= len(argv):
                raise UsageError(f"{a} needs a value")
            v = argv[i + 1]
            if a == "--schema":
                opts["schema"] = v
            elif a == "--dialect":
                if v not in ("postgres", "mysql", "sqlite"):
                    raise UsageError("--dialect must be postgres, mysql, or sqlite")
                opts["dialect"] = v
            else:
                try:
                    opts["max_indexes"] = int(v)
                except ValueError:
                    raise UsageError(f"--max-indexes needs a whole number, got {v!r}")
            i += 2
        elif a == "--no-covering":
            opts["covering"] = False; i += 1
        elif a == "--json":
            opts["json"] = True; i += 1
        elif a == "--strict":
            opts["strict"] = True; i += 1
        elif a.startswith("--"):
            raise UsageError(f"unknown option {a}")
        else:
            opts["files"].append(a); i += 1
    if not opts["files"] and not opts["schema"]:
        raise UsageError("give a query file (or - for stdin), optionally with --schema FILE")
    return opts


def read(path):
    if path == "-":
        return sys.stdin.read()
    with open(path, encoding="utf-8") as f:
        return f.read()


def main(argv):
    try:
        opts = parse_args(argv)
        schema_text = read(opts["schema"]) if opts["schema"] else ""
        query_texts = [read(p) for p in opts["files"]]
    except UsageError as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    except OSError as e:
        print(f"error: cannot read {e.filename}: {e.strerror}", file=sys.stderr)
        return 2
    statements = split_statements(strip_comments(schema_text))
    query_stmts = []
    for text in query_texts:
        clean = strip_comments(text)
        for st in split_statements(clean):
            (statements if re.match(r"\s*create\s", st, re.I) else query_stmts).append(st)
    tables, indexes = parse_schema(statements)
    queries, n = [], 0
    expanded = []
    for st in query_stmts:
        masked, _l = mask_strings(st)
        if len(split_top(masked, r"\bunion(?:\s+all)?\b")) > 1:  # a real top-level UNION, not one inside a string
            expanded.extend(split_top(st, r"\bunion(?:\s+all)?\b"))  # each branch is analyzed as its own query
        else:
            expanded.append(st)
    for st in expanded:
        pending = [(st, None, None)]
        while pending:
            cur, lits, outer = pending.pop(0)
            n += 1
            q = parse_query(cur, tables, n, lits, outer)
            if q is None:
                n -= 1
                continue
            queries.append(q)
            for sub in q["subqueries"]:
                pending.append((sub, q["lits"], {**(outer or {}), **q["aliases"]}))
                q["notes"].append(f"subquery analyzed as Q{n + len(pending)}")
    if not queries:
        print("error: no SELECT, UPDATE, or DELETE statements found", file=sys.stderr)
        return 2
    pks = {ix["table"]: ix["columns"] for ix in indexes if ix["kind"] == "primary"}
    cands = []
    for q in queries:
        cands += candidates_for(q, opts["dialect"], opts["covering"], pks)
    covered, open_cands = [], []
    for c in cands:
        ix = served_by(c, indexes)
        if ix:
            covered.append({**c, "existing": ix["name"]})
        else:
            open_cands.append(c)
    suggested = merge(open_cands)
    covered = merge(covered)
    for c in covered:
        c["existing"] = next(x["existing"] for x in [served_by_named(c, indexes)])
    new_by_table = {}
    for c in suggested:
        new_by_table[c["table"]] = new_by_table.get(c["table"], 0) + 1
        c["ddl"] = ddl(c, opts["dialect"])
        lead = c["columns"][0]
        c["tradeoff"] = ("every INSERT, UPDATE of these columns, and DELETE on " + c["table"] +
                         " also writes this index; size grows with the row count and column widths")
        if LOW_CARD.search(lead) and len(c["columns"]) == 1:
            c["tradeoff"] += f"; '{lead}' looks low-selectivity, so the planner may still prefer a scan"
    for a in suggested:
        others = [k for k, b in enumerate(suggested, 1) if b is not a and b["table"] == a["table"]
                  and b["columns"][0] == a["columns"][0]]
        a["overlaps"] = others
        a["makes_redundant"] = [ix["name"] for ix in indexes if ix["table"] == a["table"] and not ix["unique"]
                                and ix["kind"] != "primary" and len(ix["columns"]) < len(a["columns"])
                                and a["columns"][:len(ix["columns"])] == ix["columns"]]
        if a.get("null_filter") and opts["dialect"] == "postgres":
            col = a["null_filter"][0]
            rest = [c for c in a["columns"] if c != col]
            name = f"idx_{a['table']}_{'_'.join(rest)}_{col}_null"[:63]
            a["partial_hint"] = (f"CREATE INDEX CONCURRENTLY {name} ON {a['table']} ({', '.join(rest)}) "
                                 f"WHERE {col} IS NULL;  -- smaller if few rows match")
    findings = schema_findings(indexes, opts["max_indexes"], new_by_table)
    report = {"dialect": opts["dialect"], "tables": {t: len(v["columns"]) for t, v in tables.items()},
              "existing_indexes": indexes, "queries": [{k: v for k, v in q.items() if k not in ("subqueries", "lits")}
                                                       for q in queries],
              "suggested": suggested, "already_covered": covered, "schema_findings": findings}
    for q in report["queries"]:
        q["joins"] = [f"{a[0]}.{a[1]} = {b[0]}.{b[1]}" for a, b in q["joins"]]
    strict_fail = opts["strict"] and (any(q["non_sargable"] for q in queries) or
                                      any(f["rule"] == "duplicate-index" for f in findings))
    if opts["json"]:
        print(json.dumps(report, indent=2))
        return 1 if strict_fail else 0
    print(f"Schema: {len(tables)} table(s), {len(indexes)} existing index(es) incl. keys | dialect: {opts['dialect']}")
    print(f"Queries analyzed: {len(queries)}")
    for q in report["queries"]:
        print(f"\nQ{q['n']} {q['kind']}: {q['text']}")
        tabs = ", ".join(f"{t} {a}" if a != t else t for a, t in q["aliases"].items())
        print(f"  tables: {tabs or '-'}")
        eqs = [f"{f['table'] or '?'}.{f['column']} ({f['op']})" for f in q["filters"] if f["kind"] == "eq"]
        rngs = [f"{f['table'] or '?'}.{f['column']} ({f['op']})" for f in q["filters"] if f["kind"] == "range"]
        if eqs:
            print(f"  equality: {', '.join(eqs)}")
        if rngs:
            print(f"  range: {', '.join(rngs)}")
        if q["joins"]:
            print(f"  joins: {', '.join(q['joins'])}")
        if q["order_by"]:
            print("  order by: " + ", ".join(f"{o['table']}.{o['column']} {o['dir'].upper()}" for o in q["order_by"])
                  + ("  (LIMIT)" if q["limit"] else ""))
        if q["group_by"]:
            print("  group by: " + ", ".join(f"{g['table']}.{g['column']}" for g in q["group_by"]))
        for w in q["warnings"]:
            print(f"  WARN {w}")
        for nt in q["notes"]:
            print(f"  note: {nt}")
    print(f"\nSuggested indexes ({len(suggested)}):")
    if not suggested:
        print("  none: existing indexes already lead with the needed columns")
    for k, c in enumerate(suggested, 1):
        print(f"  {k}. {c['ddl']}")
        print(f"     serves: {', '.join('Q' + str(x) for x in c['queries'])} | why: {c['why']}")
        if c["include"]:
            print(f"     covering: {', '.join(c['include'])} added so the query can skip the table (optional)")
        print(f"     tradeoff: {c['tradeoff']}")
        if c.get("overlaps"):
            print(f"     overlap: same leading column as #{', #'.join(map(str, c['overlaps']))}; "
                  "check whether one index can serve both before creating two")
        if c.get("makes_redundant"):
            print(f"     makes redundant: {', '.join(c['makes_redundant'])} (leftmost prefix of this index; drop after checking usage)")
        if c.get("partial_hint"):
            print(f"     alternative: {c['partial_hint']}")
    if covered:
        print(f"\nAlready served by existing indexes ({len(covered)}):")
        for c in covered:
            print(f"  {c['table']} ({', '.join(c['columns'])}) -> {c['existing']} (Q{', Q'.join(map(str, c['queries']))})")
    print(f"\nSchema findings ({len(findings)}):")
    if not findings:
        print("  none")
    for f in findings:
        print(f"  {f['level'].upper():<4} {f['rule']:<17} {f['message']}")
    print("\nNext: run EXPLAIN (ANALYZE, BUFFERS) or EXPLAIN on real data before and after; build one index at a time.")
    return 1 if strict_fail else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
PythonSQLDatabase+2
F@f
0
Di Chuyển Cơ Sở Dữ Liệu (Database Migrations) taste
Văn bản

Những ưu tiên mang tính định hướng rõ ràng để viết các kịch bản di chuyển lược đồ cơ sở dữ liệu (database schema migrations) an toàn, không gây gián đoạn dịch vụ (zero-downtime).

# Di Chuyển Cơ Sở Dữ Liệu (Database Migrations) taste
- Giữ mọi thay đổi lược đồ trong một tệp migration được quản lý phiên bản và commit ngay cạnh đoạn mã nguồn cần đến nó. Độ tin cậy: 0.95
- Viết các tệp migration bằng ngôn ngữ SQL thuần túy thay vì dùng các đoạn mã so sánh (diffs) do ORM tự sinh ra mà bạn chưa đọc kỹ. Độ tin cậy: 0.80
- Không bao giờ chỉnh sửa một tệp migration đã được chạy trên môi trường dùng chung; thay vào đó hãy thêm một tệp migration mới. Độ tin cậy: 0.95
- Sử dụng mô hình mở rộng và thu hẹp (expand and contract): thêm cột mới, điền bù dữ liệu cũ (backfill), chuyển luồng đọc sang cột mới, sau đó mới xóa cột cũ ở một bản phát hành sau. Độ tin cậy: 0.90
- Đảm bảo mọi tệp migration đều an toàn để chạy trong khi phiên bản ứng dụng trước đó vẫn đang phục vụ lưu lượng truy cập. Độ tin cậy: 0.90
- Thêm các cột mới ở dạng cho phép giá trị rỗng (nullable) hoặc có giá trị mặc định là hằng số để tránh việc phải ghi lại toàn bộ bảng kéo dài. Độ tin cậy: 0.85
- Tạo chỉ mục (index) trên các bảng Postgres lớn bằng lệnh CREATE INDEX CONCURRENTLY nằm ngoài giao dịch (transaction). Độ tin cậy: 0.90
- Thiết lập giá trị lock_timeout và statement_timeout ngắn ngay ở đầu mỗi tệp migration. Độ tin cậy: 0.85
- Chạy các tác vụ điền bù dữ liệu lớn (large backfills) dưới dạng các tiến trình chạy ngầm theo lô (batched background jobs), không chạy ngay bên trong tệp migration. Độ tin cậy: 0.90
- Chỉ giữ một thay đổi hợp lý duy nhất trong mỗi tệp migration để khi xảy ra lỗi sẽ dễ dàng đọc hiểu và chạy thử lại. Độ tin cậy: 0.80
- Đặt tên cho các tệp migration bằng tiền tố dấu thời gian UTC kèm theo một cụm động từ ngắn gọn như 20261017_add_orders_status. Độ tin cậy: 0.75
- Ưu tiên các tệp migration chỉ tiến về phía trước (forward-only) và sửa sai bằng một tệp migration mới thay vì phụ thuộc vào các kịch bản hoàn tác (down scripts). Độ tin cậy: 0.70
- Chạy các tệp migration như một bước triển khai (deploy) tách biệt trước khi mã ứng dụng mới bắt đầu khởi chạy. Độ tin cậy: 0.85
- Kiểm thử mọi tệp migration trong quy trình CI trên một cơ sở dữ liệu hoàn toàn mới và trên một bản sao dữ liệu có quy mô tương đương môi trường thực tế (production). Độ tin cậy: 0.80
- Thêm các khóa ngoại (foreign keys) và ràng buộc kiểm tra (check constraints) với tùy chọn NOT VALID trước, sau đó mới xác thực chúng ở một bước riêng biệt. Độ tin cậy: 0.80
- Chỉ đổi tên cột và bảng thông qua một khung nhìn (view) hoặc giai đoạn ghi kép (dual-write), không bao giờ đổi tên ngay trong một bước duy nhất. Độ tin cậy: 0.75
- Lưu trữ lịch sử các bản migration đã áp dụng trong cơ sở dữ liệu và cho dừng lỗi quá trình triển khai nếu phát hiện sai lệch (drift). Độ tin cậy: 0.85
- Duyệt (review) các tệp migration cẩn thận như duyệt mã nguồn ứng dụng, với câu lệnh SQL được tạo ra hiển thị rõ ràng trong pull request. Độ tin cậy: 0.90
- Tách biệt dữ liệu khởi tạo (seed) và dữ liệu mẫu kiểm thử (fixture) ra khỏi các tệp migration lược đồ. Độ tin cậy: 0.80
BackendSQLDatabase+2
F@f
0
PostgreSQL taste
Văn bản

Các quy ước định kiến cho việc thiết kế lược đồ (schema), đánh chỉ mục (indexing), truy vấn và vận hành trong PostgreSQL.

# Phong cách (taste) sử dụng PostgreSQL của một kỹ sư Backend
- Sử dụng cột định danh kiểu bigint (GENERATED ALWAYS AS IDENTITY) cho khóa chính (primary key); chỉ dùng đến UUIDv7 khi định danh (id) bắt buộc phải được tạo bên ngoài cơ sở dữ liệu. Độ tin cậy: 0.80
- Lưu trữ dấu thời gian dưới dạng timestamptz theo múi giờ UTC, tuyệt đối không dùng timestamp without time zone. Độ tin cậy: 0.95
- Sử dụng kiểu text kết hợp với ràng buộc CHECK thay vì varchar(n) để giới hạn độ dài chuỗi. Độ tin cậy: 0.80
- Lưu trữ tiền tệ dưới dạng kiểu numeric hoặc kiểu số nguyên tính theo đơn vị tiền tệ nhỏ nhất (minor units), tuyệt đối không dùng kiểu số thực dấu phẩy động (float). Độ tin cậy: 0.95
- Đặt tên bảng ở dạng số nhiều theo quy tắc snake_case và đặt tên khóa ngoại (foreign key) theo định dạng <singular>_id. Độ tin cậy: 0.70
- Khai báo NOT NULL theo mặc định và chỉ cho phép NULL khi "chưa xác định" (unknown) là một trạng thái thực tế có thật. Độ tin cậy: 0.85
- Thực thi các quy tắc bất biến (invariants) ngay trong cơ sở dữ liệu bằng khóa ngoại (foreign keys), ràng buộc UNIQUE và CHECK, chứ không chỉ kiểm tra trong mã nguồn ứng dụng. Độ tin cậy: 0.90
- Đánh chỉ mục (index) cho mọi cột khóa ngoại được sử dụng trong các phép nối (join) hoặc xóa dây chuyền (cascading delete). Độ tin cậy: 0.85
- Đọc kết quả EXPLAIN (ANALYZE, BUFFERS) trước khi thêm một chỉ mục, và đọc lại lần nữa sau khi thêm để chứng minh rằng chỉ mục đó thực sự mang lại hiệu quả. Độ tin cậy: 0.90
- Ưu tiên chỉ mục từng phần (partial index) và chỉ mục biểu thức (expression index) hơn là các chỉ mục phức hợp (composite index) trải rộng đối với các bộ lọc có phân phối dữ liệu lệch (skewed filters). Độ tin cậy: 0.70
- Tạo chỉ mục trên các bảng lớn bằng câu lệnh CREATE INDEX CONCURRENTLY. Độ tin cậy: 0.90
- Sử dụng kiểu jsonb cho các thuộc tính thực sự không có lược đồ cố định (schemaless), nhưng hãy tách các trường mà bạn thường xuyên lọc ra thành những cột thực thụ. Độ tin cậy: 0.80
- Phân trang bằng phương pháp tập khóa keyset (WHERE id > $last ORDER BY id LIMIT n) thay vì dùng giá trị OFFSET lớn. Độ tin cậy: 0.85
- Giữ các giao dịch (transactions) diễn ra ngắn gọn và tuyệt đối không giữ một giao dịch mở trong suốt quá trình gọi mạng (network call). Độ tin cậy: 0.90
- Thiết lập tham số statement_timeout và idle_in_transaction_session_timeout cho từng vai trò (role) của ứng dụng. Độ tin cậy: 0.85
- Đặt một bộ điều phối kết nối (connection pooler) chẳng hạn như PgBouncer ở chế độ giao dịch (transaction mode) đứng trước cơ sở dữ liệu. Độ tin cậy: 0.80
- Sử dụng cú pháp SELECT ... FOR UPDATE SKIP LOCKED cho các hàng đợi công việc (job queues) đơn giản trước khi quyết định thêm một hệ thống message broker riêng biệt. Độ tin cậy: 0.70
- Sử dụng cú pháp INSERT ... ON CONFLICT cho các thao tác thêm mới hoặc cập nhật (upsert) có tính lũy đẳng (idempotent). Độ tin cậy: 0.85
- Cấp cho mỗi dịch vụ một vai trò (role) riêng với đặc quyền tối thiểu (least privilege); ứng dụng tuyệt đối không bao giờ kết nối dưới quyền siêu người dùng (superuser). Độ tin cậy: 0.90
- Bật tiện ích mở rộng pg_stat_statements và rà soát các truy vấn chậm nhất mỗi tuần. Độ tin cậy: 0.85
- Viết các biểu thức bảng chung (CTEs) để mã dễ đọc hơn, với lưu ý rằng Postgres 12+ sẽ tự động nội tuyến (inline) chúng trừ khi bạn thêm từ khóa MATERIALIZED. Độ tin cậy: 0.65
- Kiểm thử khả năng khôi phục tại một thời điểm (point-in-time recovery) từ các bản sao lưu một cách định kỳ; một bản sao lưu chưa từng được kiểm thử coi như không có giá trị. Độ tin cậy: 0.95
BackendBest PracticesSQL+2
F@f
0
Đường ống dữ liệu (Data Pipeline) taste
Văn bản

Các quy ước định hướng rõ ràng để xây dựng hệ thống đường ống dữ liệu và kho dữ liệu phân tích đáng tin cậy.

# Đường ống dữ liệu (Data Pipeline) taste của một kỹ sư dữ liệu
- Ưu tiên mô hình ELT: nạp dữ liệu thô nguyên trạng trước, sau đó mới biến đổi bên trong kho dữ liệu (warehouse). Độ tin cậy: 0.85
- Tách biệt rõ ràng các tầng raw, staging và mart, đồng thời tuyệt đối không để các bảng điều khiển (dashboard) đọc trực tiếp từ các bảng raw. Độ tin cậy: 0.90
- Mô hình hóa các phép biến đổi trong dbt (hoặc SQLMesh) dưới dạng mã SQL được quản lý phiên bản và duyệt mã (code review) giống như bất kỳ mã nguồn nào khác. Độ tin cậy: 0.90
- Đảm bảo mọi tác vụ đều có tính lũy đẳng (idempotent): chạy lại dữ liệu của một ngày phải tạo ra cùng một kết quả mà không sinh ra bản ghi trùng lặp. Độ tin cậy: 0.95
- Phân vùng (partition) các bảng lớn theo ngày diễn ra sự kiện và xử lý dữ liệu theo từng khoảng ngày giới hạn. Độ tin cậy: 0.85
- Chạy bù dữ liệu (backfill) phải là một thao tác hạng nhất có lệnh được lập tài liệu rõ ràng, chứ không phải một kịch bản chạy tay tạm bợ một lần. Độ tin cậy: 0.85
- Thêm các bài kiểm thử về tính duy nhất (uniqueness), không rỗng (not-null), giá trị hợp lệ (accepted values) và toàn vẹn tham chiếu trên mọi mô hình quan trọng. Độ tin cậy: 0.90
- Định nghĩa hợp đồng dữ liệu (data contracts) với các hệ thống cung cấp dữ liệu đầu nguồn (upstream producers) để các thay đổi cấu trúc (schema) báo lỗi ngay trong CI của họ chứ không làm gãy đường ống của bạn. Độ tin cậy: 0.80
- Theo dõi độ mới (freshness) của dữ liệu trên từng nguồn và cảnh báo khi dữ liệu về trễ, chứ không chỉ khi một tác vụ chạy thất bại. Độ tin cậy: 0.90
- Điều phối luồng công việc bằng Airflow, Dagster hoặc Prefect, và tách các tác vụ tính toán nặng ra khỏi các worker của trình điều phối. Độ tin cậy: 0.80
- Ưu tiên điều phối dựa trên tài sản dữ liệu (asset-based orchestration) có khả năng nhận biết bảng nào phụ thuộc vào bảng nào hơn là các đồ thị tác vụ thuần túy. Độ tin cậy: 0.70
- Lưu trữ dấu thời gian (timestamps) theo múi giờ UTC và chỉ chuyển đổi sang giờ địa phương ở tầng hiển thị. Độ tin cậy: 0.95
- Sử dụng Parquet hoặc một định dạng lưu trữ dạng cột khác có lược đồ (schema) tường minh cho các tệp trong hồ dữ liệu (data lake). Độ tin cậy: 0.85
- Tách biệt thời gian xảy ra sự kiện (event time) với thời gian xử lý (processing time) và xử lý rõ ràng các dữ liệu đến muộn. Độ tin cậy: 0.85
- Giữ thông tin định danh cá nhân (PII) trong ít bảng nhất có thể, che giấu (mask) hoặc băm (hash) chúng từ sớm và ghi chép quyền truy cập tập trung tại một nơi duy nhất. Độ tin cậy: 0.90
- Chỉ sử dụng các mô hình tăng trưởng (incremental models) sau khi việc làm mới toàn bộ (full refresh) trở nên quá chậm; tính chính xác là trên hết, chi phí đứng thứ hai. Độ tin cậy: 0.75
- Đặt tên các mô hình một cách nhất quán (stg_, int_, fct_, dim_) và viết tài liệu mô tả cho mọi cột mà người dùng thực sự truy vấn. Độ tin cậy: 0.80
- Sử dụng bản chụp nhanh chiều dữ liệu biến đổi chậm loại 2 (slowly changing dimension type 2 snapshots) khi lịch sử thay đổi là yếu tố quan trọng, thay vì dùng các cột kiểm toán tự phát. Độ tin cậy: 0.70
- Chạy thử đường ống dữ liệu trong CI trên một tập dữ liệu mẫu hoặc bản sao không tốn dung lượng (zero-copy clone) trước khi triển khai các thay đổi lên môi trường thực tế (production). Độ tin cậy: 0.80
- Giám sát chi phí kho dữ liệu trên từng mô hình và hủy các truy vấn quét toàn bộ bảng mà không có bộ lọc phân vùng (partition filters). Độ tin cậy: 0.75
- Mỗi chỉ số đo lường (metric) chỉ có một người sở hữu và một định nghĩa duy nhất trong tầng ngữ nghĩa (semantic layer), chứ không phải năm phiên bản khác nhau trên năm bảng điều khiển. Độ tin cậy: 0.80
Best PracticesSQLArchitecture
F@f
0
Trình Đánh giá Hiệu năng Truy vấn SQL
Kỹ năng

Đánh giá các truy vấn SQL và kết quả EXPLAIN tùy chọn nhằm phát hiện các vấn đề hiệu năng phổ biến như SELECT *, dùng hàm hoặc ép kiểu (cast) trên các cột đã đánh chỉ mục, LIKE có ký tự đại diện (wildcard) ở đầu, vòng lặp truy vấn N+1, dùng OR giữa nhiều cột so với UNION ALL, lỗi thứ tự ưu tiên AND/OR, truy vấn con NOT IN, phân trang OFFSET sâu, quét tuần tự (seq scan) và sắp xếp trên ổ đĩa (disk sort). Giải thích từng vấn đề và cung cấp câu truy vấn viết lại. Đi kèm công cụ kiểm tra bằng thư viện chuẩn Python có hỗ trợ --self-test.

---
name: sql-query-performance-reviewer
description: Đánh giá các truy vấn SQL và kết quả EXPLAIN tùy chọn nhằm phát hiện các vấn đề hiệu năng phổ biến như SELECT *, dùng hàm hoặc ép kiểu (cast) trên các cột đã đánh chỉ mục, LIKE có ký tự đại diện (wildcard) ở đầu, vòng lặp truy vấn N+1, dùng OR giữa nhiều cột so với UNION ALL, lỗi thứ tự ưu tiên AND/OR, truy vấn con NOT IN, phân trang OFFSET sâu, quét tuần tự (seq scan) và sắp xếp trên ổ đĩa (disk sort). Giải thích từng vấn đề và cung cấp câu truy vấn viết lại. Đi kèm công cụ kiểm tra bằng thư viện chuẩn Python có hỗ trợ --self-test.
---

# Trình Đánh giá Hiệu năng Truy vấn SQL

Bạn đánh giá SQL theo đúng cách một kỹ sư cơ sở dữ liệu giàu kinh nghiệm thực hiện khi duyệt mã (code review): bạn đọc câu truy vấn cùng kế hoạch thực thi (query plan) của nó, tìm ra các khuôn mẫu khiến truy vấn chạy chậm (hoặc âm thầm trả về kết quả sai), rồi trả lại câu truy vấn đã được viết lại kèm theo lý do ngắn gọn cho từng thay đổi. Bạn hỗ trợ PostgreSQL, MySQL/MariaDB, SQL Server và SQLite, đồng thời nêu rõ khi nào lời khuyên có sự khác biệt giữa các hệ quản trị cơ sở dữ liệu này.

## Các tệp trong kỹ năng này

- `scripts/sql_review.py` - quét tệp .sql (một hoặc nhiều câu lệnh) và đầu ra EXPLAIN tùy chọn, in các phát hiện theo mức độ nghiêm trọng (chỉ dùng thư viện chuẩn Python 3; không bao giờ kết nối tới cơ sở dữ liệu hay thực thi câu lệnh SQL)
- `references/anti-patterns.md` - từng phát hiện kèm lý do tại sao nó gây hại, bản viết lại trước/sau và sự khác biệt giữa các hệ cơ sở dữ liệu (engine)
- `references/reading-explain.md` - cách thu thập và đọc kết quả EXPLAIN trong PostgreSQL và MySQL, cũng như những con số nào là quan trọng
- `templates/review-report.md` - bản báo cáo đánh giá bạn trả về: tóm tắt, bảng phát hiện, các truy vấn đã viết lại, những chỉ số cần đo lường
- `examples/order-history.sql`, `examples/order-history-explain.txt`, `examples/indexes.txt` - trang lịch sử đơn hàng của một cửa hàng trực tuyến với 7 câu lệnh, một kế hoạch thực thi PostgreSQL và các chỉ mục (index) đã biết

## Quy trình làm việc

### 1. Thu thập dữ liệu đầu vào
Yêu cầu người dùng cung cấp: câu truy vấn hoặc đoạn trích nhật ký (log) chứa các câu lệnh thực tế, hệ quản trị cơ sở dữ liệu và phiên bản, kích thước ước chừng của các bảng, và nếu có thể là đầu ra của `EXPLAIN (ANALYZE, BUFFERS)` (PostgreSQL) hoặc `EXPLAIN ANALYZE` / `EXPLAIN ... \G` (MySQL 8). Các định nghĩa chỉ mục sẽ rất hữu ích (`\d table` hoặc `SHOW INDEX FROM table`); hãy ghi chúng dưới dạng các dòng `table(col1, col2)` để truyền vào `--indexes`. Yêu cầu người dùng thay thế dữ liệu khách hàng trong các giá trị tĩnh (literal) bằng giá trị giả trước khi dán vào.

### 2. Chạy trình kiểm tra
```
python3 scripts/sql_review.py examples/order-history.sql --explain examples/order-history-explain.txt --indexes examples/indexes.txt
python3 scripts/sql_review.py slow.sql --json
python3 scripts/sql_review.py --self-test
```
Mã thoát (exit code) 1 có nghĩa là có phát hiện mức HIGH, 2 có nghĩa là đầu vào không hợp lệ. Script này hoạt động dựa trên biểu thức chính quy (regex heuristic): nó có thể bỏ sót vấn đề trong các câu lệnh SQL rất phức tạp và có thể báo động giả (false positive). Hãy tự mình đọc kỹ từng câu lệnh.

### 3. Đánh giá
Sắp xếp thứ tự ưu tiên công việc: tính đúng đắn lên hàng đầu (AND_OR_PRECEDENCE, NOT_IN_SUBQUERY, NO_WHERE_WRITE), tiếp theo là chi phí lớn nhất từ kế hoạch thực thi (SEQ_SCAN_LARGE, ROWS_REMOVED, DISK_SORT), sau đó là N_PLUS_ONE (thường chiếm tổng thời gian lớn nhất trong ứng dụng), rồi mới đến các vấn đề còn lại. Với mỗi phát hiện, hãy đưa ra bản viết lại từ `references/anti-patterns.md` đã được điều chỉnh theo tên cột thực tế của người dùng. Khi giải pháp là thêm chỉ mục, hãy đề xuất nhưng giữ phần thiết kế chỉ mục ngắn gọn (thứ tự cột: so sánh bằng, rồi đến sắp xếp, rồi đến khoảng giá trị) và nhắc họ rằng mỗi chỉ mục thêm vào đều làm chậm thao tác ghi.

### 4. Bàn giao kết quả
Điền vào `templates/review-report.md`: tóm tắt một dòng, bảng các phát hiện, từng câu truy vấn đã viết lại, và kế hoạch đo lường trước/sau (chạy lại EXPLAIN ANALYZE, so sánh thời gian thực thi và số dòng phải đọc). Không bao giờ khẳng định mức tăng tốc mà bạn chưa trực tiếp đo lường; hãy dùng từ "dự kiến" (expected).

## Kết quả ví dụ
Đối với các tệp ví dụ: 7 câu lệnh, 6 phát hiện HIGH, 7 MEDIUM, 2 LOW. Mức HIGH: `status = 'paid' OR status = 'shipped'` không có dấu ngoặc đơn sẽ trả về mọi đơn hàng đã giao của tất cả khách hàng; LOWER(customer_email) và DATE(created_at) làm ẩn cột khỏi chỉ mục; 3 câu truy vấn lấy chi tiết từng đơn hàng riêng lẻ tạo thành vòng lặp N+1 (thay bằng một câu truy vấn `WHERE order_id IN (...)`); `LIKE '%wireless%'`; kế hoạch thực thi cho thấy một phép quét tuần tự (Seq Scan) đọc tới 412560 dòng. Mức MEDIUM: SELECT *, OFFSET 4000, cột sku đem so sánh với một số, cột sku chưa có chỉ mục, truy vấn con NOT IN, sắp xếp trộn ngoài trên ổ đĩa (external merge sort on disk), ước tính 19930 dòng so với thực tế 412560 dòng (cần chạy ANALYZE).

## Quy tắc

- Không bao giờ tự chạy SQL trực tiếp trên cơ sở dữ liệu của người dùng và không bao giờ yêu cầu thông tin xác thực môi trường production.
- Yêu cầu ẩn danh hóa các giá trị tĩnh (literal); không lặp lại dữ liệu cá nhân từ các câu truy vấn được dán vào trong báo cáo.
- Tính đúng đắn quan trọng hơn tốc độ: hãy chỉ ra những câu truy vấn trả về sai dữ liệu ngay cả khi chúng chạy rất nhanh.
- Ưu tiên viết lại câu truy vấn hơn là thêm chỉ mục; chỉ thêm chỉ mục cho những truy vấn chạy thường xuyên hoặc bị chậm.
- Nêu rõ tên hệ cơ sở dữ liệu khi lời khuyên mang tính đặc thù theo từng engine (ví dụ: chỉ mục biểu thức - expression index, ILIKE, LIMIT a,b).
- Kế hoạch thực thi đo đạc thực tế đáng tin cậy hơn quy tắc suy nghiệm (heuristic): nếu EXPLAIN ANALYZE mâu thuẫn với một phát hiện của công cụ, hãy tin vào kế hoạch thực thi.
FILE:references/anti-patterns.md
# Các phản mẫu (Anti-Patterns) về Hiệu năng SQL

Mỗi mục bao gồm: dấu hiệu bị trình kiểm tra gắn cờ, lý do gây hại và cách viết lại. "Sargable" có nghĩa là cơ sở dữ liệu có thể sử dụng chỉ mục để tìm ra các dòng dữ liệu (Search ARGument ABLE).

## AND_OR_PRECEDENCE (HIGH, tính đúng đắn)
Toán tử AND có độ ưu tiên kết hợp chặt hơn OR, vì vậy `customer_id = 7 AND status = 'paid' OR status = 'shipped'` đồng nghĩa với `(customer_id = 7 AND status = 'paid') OR status = 'shipped'`: trả về mọi đơn hàng đã giao của tất cả khách hàng, đồng thời dẫn đến quét toàn bộ bảng (full scan).
```
-- sau khi sửa
WHERE customer_id = 7 AND status IN ('paid', 'shipped')
```

## FUNC_ON_COLUMN (HIGH)
`WHERE LOWER(email) = ?`, `YEAR(created_at) = 2026`, `DATE(created_at) = ?`, `price * 2 > 100`: chỉ mục lưu trữ các giá trị gốc, do đó hàm sẽ phải chạy trên từng dòng một.
```
-- ngày tháng: sử dụng khoảng nửa mở (half-open range) trên cột gốc
WHERE created_at >= '2026-10-01' AND created_at < '2026-10-02'
-- phép tính số học: chuyển sang phía hằng số
WHERE price > 50
-- tìm kiếm không phân biệt hoa thường: lưu dữ liệu đã chuẩn hóa hoặc đánh chỉ mục trên biểu thức
CREATE INDEX ON customers (LOWER(email));          -- PostgreSQL, SQLite 3.9+
ALTER TABLE customers ADD email_lc VARCHAR(255) AS (LOWER(email)) STORED, ADD INDEX (email_lc);  -- MySQL 5.7+
-- MySQL 8.0.13+ cũng hỗ trợ functional index: CREATE INDEX ix ON customers ((LOWER(email)));
-- Giải pháp thay thế trên PostgreSQL: kiểu dữ liệu citext
```

## LEADING_WILDCARD (HIGH)
`LIKE '%wireless%'` không thể tận dụng chỉ mục B-tree (chỉ tiền tố như `'wireless%'` mới dùng được). Các lựa chọn thay thế: tìm kiếm toàn văn - full-text search (PostgreSQL dùng `tsvector` + GIN, MySQL dùng `FULLTEXT` + `MATCH ... AGAINST`), chỉ mục trigram (`CREATE EXTENSION pg_trgm; CREATE INDEX ... USING gin (name gin_trgm_ops)`), công cụ tìm kiếm chuyên dụng cho tìm kiếm sản phẩm, hoặc tạo cột đảo ngược chuỗi cho các truy vấn tìm kiếm theo hậu tố (`'%@example.com'`).

## N_PLUS_ONE (HIGH)
Một câu truy vấn để lấy danh sách, sau đó cứ mỗi dòng lại chạy thêm một câu truy vấn (do ORM tải trễ - lazy-loading một quan hệ). 50 đơn hàng = 51 lượt gọi qua lại (round trips); mỗi truy vấn đơn lẻ đều nhanh, nhưng tổng thể cả trang lại chậm.
```
-- trước khi sửa (x N lần)
SELECT product_id, qty FROM order_items WHERE order_id = 98211;
-- sau khi sửa (1 câu truy vấn)
SELECT order_id, product_id, qty FROM order_items WHERE order_id IN (98211, 98340, 98502);
-- hoặc thực hiện JOIN trực tiếp vào câu truy vấn lấy danh sách
```
Cách sửa trên ORM: Django dùng `prefetch_related`/`select_related`, Rails dùng `includes`, SQLAlchemy dùng `selectinload`, Hibernate dùng `JOIN FETCH` / `@BatchSize`, Prisma dùng `include`. Trình kiểm tra cần thấy các câu lệnh lặp lại trong tệp (hãy dán một đoạn trích từ log).

## ORDER_BY_RANDOM (HIGH)
`ORDER BY RAND() LIMIT 1` phải đọc và sắp xếp toàn bộ bảng. Hãy chọn một id ngẫu nhiên trong khoảng giá trị rồi lấy dòng tiếp theo (`WHERE id >= ? ORDER BY id LIMIT 1`), duy trì một cột số ngẫu nhiên có đánh chỉ mục, hoặc sử dụng `TABLESAMPLE SYSTEM (1)` trong PostgreSQL.

## NO_WHERE_WRITE (HIGH)
`UPDATE`/`DELETE` không có mệnh đề WHERE sẽ khóa và ghi lại mọi dòng trong bảng. Nếu đó thực sự là chủ đích của bạn, hãy thực hiện theo từng lô (`DELETE ... WHERE id IN (SELECT id ... LIMIT 5000)` trong một vòng lặp) hoặc sử dụng `TRUNCATE`.

## SELECT_STAR (MEDIUM)
Đọc tất cả các cột (bao gồm cả các cột văn bản/JSON dung lượng lớn), truyền tải nhiều byte hơn, ngăn cản cơ chế chỉ quét chỉ mục (index-only scan) và làm hỏng mã nguồn phía gọi khi cấu trúc cột thay đổi. Hãy liệt kê rõ các cột cần lấy. Riêng `SELECT *` bên trong `EXISTS (...)` thì hoàn toàn ổn.

## OR_ACROSS_COLUMNS (MEDIUM) và OR_SAME_COLUMN (LOW)
`WHERE email = ? OR phone = ?`: một chỉ mục đơn lẻ không thể phục vụ cả hai điều kiện. PostgreSQL có thể kết hợp hai chỉ mục bằng BitmapOr và MySQL dùng index_merge; nếu kế hoạch thực thi lại hiển thị quét bảng (scan), hãy tách truy vấn:
```
SELECT id FROM users WHERE email = ?
UNION ALL
SELECT id FROM users WHERE phone = ? AND email IS DISTINCT FROM ?;  -- tránh trùng lặp (MySQL: NOT (email <=> ?))
```
Nếu lặp lại trên cùng một cột: `status IN ('paid', 'shipped')` ngắn gọn hơn và tạo ra kế hoạch thực thi tương đương hoặc tốt hơn.

## IMPLICIT_CAST (MEDIUM) và QUOTED_NUMBER (LOW)
Một cột văn bản đem so sánh với một số (`sku = 40017` trên cột VARCHAR): MySQL phải chuyển đổi giá trị của mọi dòng sang kiểu số, dẫn đến việc bỏ qua chỉ mục và giá trị '040017' cũng khớp luôn; trong khi PostgreSQL sẽ báo lỗi toán tử. Hãy đặt giá trị trong dấu nháy. Một cột kiểu số đem so sánh với một số đặt trong dấu nháy (`customer_id = '1042'`) chỉ được chuyển đổi một lần và thường không sao, nhưng điều đó thường báo hiệu ứng dụng đang truyền (bind) id dưới dạng chuỗi; một tham số mang kiểu văn bản có thể buộc cơ sở dữ liệu phải ép kiểu ở phía cột trong một số trình điều khiển (driver) (trường hợp kinh điển là `setString` của JDBC trên SQL Server giữa NVARCHAR và VARCHAR).

## NOT_IN_SUBQUERY (MEDIUM, tính đúng đắn)
Nếu truy vấn con trả về dù chỉ một giá trị NULL, `NOT IN` sẽ không trả về bất kỳ dòng nào cả. Hãy sử dụng:
```
SELECT c.id FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
```
NOT EXISTS được lập kế hoạch thực thi dưới dạng phép kết nối loại trừ (anti-join) trong mọi hệ cơ sở dữ liệu phổ biến.

## DEEP_OFFSET (MEDIUM)
`LIMIT 20 OFFSET 4000` phải đọc 4020 dòng rồi vứt bỏ 4000 dòng; trang thứ 500 sẽ chậm hơn gấp 25 lần so với trang 1. Hãy dùng phân trang theo tập khóa - keyset (seek) pagination:
```
SELECT id, created_at, total FROM orders
WHERE customer_id = ? AND (created_at, id) < (?, ?)      -- dòng cuối cùng của trang trước đó
ORDER BY created_at DESC, id DESC
LIMIT 20;
```
Đánh chỉ mục `(customer_id, created_at, id)`. MySQL cũng hỗ trợ so sánh theo bộ dòng (row comparison); trên SQL Server: `created_at < ? OR (created_at = ? AND id < ?)`.

## COUNT_FOR_EXISTS (LOW)
`SELECT COUNT(*) ... > 0` sẽ đếm toàn bộ các dòng khớp điều kiện. Trong khi đó `SELECT EXISTS (SELECT 1 FROM ... WHERE ...)` sẽ dừng lại ngay ở dòng khớp đầu tiên.

## DISTINCT_JOIN (LOW)
`SELECT DISTINCT u.* FROM users u JOIN orders o ...` tạo ra toàn bộ các dòng tích hợp giữa user x order rồi mới loại bỏ bản ghi trùng lặp. Hãy sử dụng `WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)`.

## NO_INDEX_FOR_FILTER (MEDIUM, cần tham số --indexes)
Một cột được dùng trong mệnh đề WHERE/ON nhưng không phải là cột đầu tiên của bất kỳ chỉ mục nào được liệt kê. Không phải bộ lọc nào cũng cần chỉ mục (bảng nhỏ, cờ có độ chọn lọc thấp, truy vấn hiếm khi chạy). Thứ tự cột trong chỉ mục phức hợp (composite index): các cột so sánh bằng đứng trước, tiếp theo là cột trong ORDER BY, rồi đến các cột lọc theo khoảng giá trị; chỉ đưa thêm các cột trong mệnh đề SELECT vào để phục vụ quét chỉ mục đơn thuần (index-only scan) khi đó là câu truy vấn chạy với tần suất rất cao (hot query).
FILE:references/reading-explain.md
# Cách đọc EXPLAIN

## Thu thập kế hoạch thực thi
| Hệ CSDL (Engine) | Câu lệnh | Ghi chú |
|---|---|---|
| PostgreSQL | `EXPLAIN (ANALYZE, BUFFERS) <query>;` | ANALYZE sẽ thực thi câu truy vấn thật; hãy bọc các câu lệnh ghi trong `BEGIN; ... ROLLBACK;` |
| MySQL 8 | `EXPLAIN ANALYZE <query>;` hoặc `EXPLAIN <query>\G` | Trình kiểm tra phân tích định dạng dọc `\G` |
| MariaDB | `ANALYZE <query>;` / `EXPLAIN <query>\G` | |
| SQL Server | `SET STATISTICS IO, TIME ON;` + kế hoạch thực thi thực tế (actual execution plan) | Đọc thủ công |
| SQLite | `EXPLAIN QUERY PLAN <query>;` | Tìm dấu hiệu `SCAN table` so với `SEARCH table USING INDEX` |

Hãy chạy trên dữ liệu tương tự môi trường production. Một kế hoạch thực thi trên bảng dev 1000 dòng không nói lên được điều gì về một bảng có 50 triệu dòng.

## Những dấu hiệu trình kiểm tra tìm kiếm
| Phát hiện | Dấu hiệu trên PostgreSQL | Dấu hiệu trên MySQL |
|---|---|---|
| SEQ_SCAN_LARGE | `Seq Scan on t` với số dòng thực tế (actual rows) >= 10000 | `type: ALL`, `rows` >= 10000, `key: NULL` |
| ROWS_REMOVED | `Rows Removed by Filter` >= 9 lần số dòng được giữ lại | `filtered` <= 10% trên >= 1000 dòng |
| EST_MISMATCH | Ước tính `rows=` lệch so với thực tế `actual ... rows=` từ 10 lần trở lên | (Chỉ có ở EXPLAIN ANALYZE, đọc thủ công) |
| DISK_SORT | `Sort Method: external merge  Disk: ...` | `Using temporary; Using filesort` |
| NESTED_LOOP_MANY | nút bên trong (inner node) có `loops=` >= 1000 | nhiều dòng ở bảng dẫn động (driving table) + tra cứu `ref` |

## Đọc kế hoạch thực thi PostgreSQL
- Đọc từ nút thụt lề sâu nhất ngược lên trên; mỗi mũi tên `->` cung cấp dữ liệu đầu vào cho nút cha của nó.
- `cost=a..b` là con số ước đoán của bộ lập kế hoạch (đơn vị tùy ý), `actual time=a..b` là số mili giây cho mỗi vòng lặp; hãy nhân với `loops`.
- `rows=` trong phần cost là con số ước tính, còn trong phần actual là số lượng thực tế. Sai lệch gấp 10 lần có nghĩa là thống kê đã cũ hoặc bị thiếu: hãy chạy `ANALYZE table;`, hoặc tạo thống kê mở rộng cho các cột có tương quan với nhau (`CREATE STATISTICS`).
- `Index Scan` / `Index Only Scan` / `Bitmap Index Scan` = đã sử dụng chỉ mục. Nếu `Heap Fetches` cao trong phép quét Index Only Scan = hãy chạy VACUUM.
- `Buffers: shared read=` đếm số trang 8 kB đọc từ ổ đĩa; `hit=` là đọc từ bộ nhớ đệm (cache).
- `Sort Method: external merge` = dữ liệu sắp xếp không vừa với bộ nhớ `work_mem`. Nên ưu tiên dùng chỉ mục trả về các dòng theo đúng thứ tự; chỉ tăng work_mem theo từng phiên (session) đối với các truy vấn báo cáo đã biết rõ.

## Đọc EXPLAIN trên MySQL
- `type` từ tốt nhất đến tệ nhất: `const`, `eq_ref`, `ref`, `range`, `index` (quét toàn bộ chỉ mục), `ALL` (quét toàn bộ bảng).
- `key` là chỉ mục được chọn (`NULL` = không dùng chỉ mục nào); `possible_keys` liệt kê các chỉ mục ứng viên.
- `rows` x `filtered`% = số dòng được chuyển tiếp sang bước tiếp theo.
- `Extra`: `Using index` (chỉ mục bao phủ - covering index, tốt), `Using where` (lọc sau khi đọc), `Using temporary` và `Using filesort` (phát sinh thêm công việc cho GROUP BY / ORDER BY).

## Đo lường trước/sau
1. Chạy câu truy vấn gốc 3 lần, ghi lại thời gian thực thi trung vị và số dòng/buffer đã đọc.
2. Áp dụng từng thay đổi một (viết lại câu truy vấn trước, sau đó mới thêm chỉ mục).
3. Chạy lại với cùng bộ tham số; đồng thời kiểm thử với cả một khách hàng có dữ liệu "lớn" và một khách hàng có dữ liệu "nhỏ", vì kế hoạch thực thi sẽ thay đổi khi dữ liệu bị lệch (data skew).
4. Kiểm tra chi phí ghi đối với các chỉ mục mới trên các bảng có tần suất hoạt động cao (độ trễ khi insert, dung lượng chỉ mục thông qua `pg_relation_size` hoặc `information_schema.STATISTICS`).
FILE:templates/review-report.md
# Đánh giá hiệu năng SQL: <tên câu truy vấn hoặc endpoint>

**Hệ CSDL (Engine):** <PostgreSQL 16 / MySQL 8.0 / ...>   **Các bảng:** <orders ~1.2M dòng, order_items ~4M>
**Tóm tắt:** <một câu: vấn đề chính và hiệu quả dự kiến sau khi khắc phục>

## Các phát hiện
| # | Mức độ | Phát hiện | Câu lệnh | Tại sao quan trọng | Cách khắc phục |
|---|---|---|---|---|---|
| 1 | HIGH | <AND_OR_PRECEDENCE> | <câu lệnh 1> | <trả về mọi đơn hàng đã giao, quét toàn bộ bảng> | <thêm ngoặc đơn / dùng IN (...)> |
| 2 | <MEDIUM> | <...> | <...> | <...> | <...> |

## Các câu truy vấn đã viết lại
### Câu lệnh <n>: <tên>
Trước khi sửa:
```sql
<original>
```
Sau khi sửa:
```sql
<rewrite>
```
Lý do: <một hoặc hai câu>

## Thay đổi về chỉ mục (chỉ khi thực sự cần thiết)
```sql
<CREATE INDEX CONCURRENTLY ix_orders_customer_created ON orders (customer_id, created_at DESC, id);>
```
Chi phí ghi: <tốc độ ghi của bảng, kích thước chỉ mục dự kiến>

## Đo lường
| Truy vấn | Trước (ms, số dòng đọc) | Sau (ms, số dòng đọc) |
|---|---|---|
| <câu lệnh 1> | <912 ms, 1.2M dòng> | <dự kiến: < 5 ms, ~20 dòng> |

Chạy lại `EXPLAIN (ANALYZE, BUFFERS)` sau mỗi thay đổi, thử nghiệm với cả khách hàng có lượng dữ liệu lớn và khách hàng có lượng dữ liệu nhỏ.

## Những mục giữ nguyên không thay đổi
<các phát hiện chủ đích giữ nguyên, ví dụ: SELECT * trong tính năng xuất dữ liệu của quản trị viên, kèm theo lý do>
FILE:examples/order-history.sql
-- Order history page of a small online shop (PostgreSQL), captured from the app log.
-- 1. Order list for the account page
SELECT * FROM orders o
WHERE LOWER(o.customer_email) = 'mina.sato@example.com'
  AND o.status = 'paid' OR o.status = 'shipped'
ORDER BY o.created_at DESC
LIMIT 20 OFFSET 4000;

-- 2-4. Line items, loaded one order at a time by the ORM
SELECT product_id, qty, unit_price FROM order_items WHERE order_id = 98211;
SELECT product_id, qty, unit_price FROM order_items WHERE order_id = 98340;
SELECT product_id, qty, unit_price FROM order_items WHERE order_id = 98502;

-- 5. Search box
SELECT p.id, p.name FROM products p
WHERE p.name LIKE '%wireless%' AND p.sku = 40017;

-- 6. Customers who never ordered
SELECT c.id, c.email FROM customers c
WHERE c.id NOT IN (SELECT customer_id FROM orders)
  AND DATE(c.created_at) = '2026-10-01';

-- 7. Order lookup by id from a URL parameter
SELECT o.id, o.total FROM orders o WHERE o.customer_id = '1042';
FILE:examples/order-history-explain.txt
Limit  (cost=48211.30..48211.35 rows=20 width=212) (actual time=912.4..912.5 rows=20 loops=1)
  ->  Sort  (cost=48201.30..48251.12 rows=19930 width=212) (actual time=905.1..911.8 rows=4020 loops=1)
        Sort Key: created_at DESC
        Sort Method: external merge  Disk: 18432kB
        ->  Seq Scan on orders o  (cost=0.00..41870.00 rows=19930 width=212) (actual time=0.03..801.7 rows=412560 loops=1)
              Filter: (((lower(customer_email) = 'mina.sato@example.com'::text) AND (status = 'paid'::text)) OR (status = 'shipped'::text))
              Rows Removed by Filter: 787440
Planning Time: 0.4 ms
Execution Time: 914.9 ms
FILE:examples/indexes.txt
orders(customer_id, created_at)
orders(status)
order_items(order_id)
customers(id)
products(id)
FILE:scripts/sql_review.py
#!/usr/bin/env python3
"""SQL query performance reviewer (Python 3 standard library only).

Reads a .sql file (one or more statements separated by ;) and, optionally, EXPLAIN output,
and flags common performance problems with a heuristic, regex-based scan. It does not connect
to a database and never runs the SQL. Treat findings as review prompts, not proof.

Query findings (severity):
  HIGH   FUNC_ON_COLUMN     function or arithmetic wraps a column in WHERE/ON (index cannot be used)
  HIGH   LEADING_WILDCARD   LIKE/ILIKE pattern starts with % or _ (full scan)
  HIGH   N_PLUS_ONE         the same statement shape repeats >= --repeat times with different literals
  HIGH   ORDER_BY_RANDOM    ORDER BY RAND()/RANDOM() sorts the whole table
  HIGH   NO_WHERE_WRITE     UPDATE or DELETE without WHERE
  HIGH   AND_OR_PRECEDENCE  AND and OR mixed without parentheses: AND binds first, so the OR branch
                            matches rows from the whole table (wrong results and a full scan)
  MEDIUM SELECT_STAR        SELECT * or alias.* (wide rows, no covering index, breaks on schema change)
  MEDIUM OR_ACROSS_COLUMNS  OR between different columns in WHERE (consider UNION ALL)
  MEDIUM IMPLICIT_CAST      code/phone/sku-like (text) column compared with a bare number: MySQL
                            casts every row and skips the index, PostgreSQL raises a type error
  MEDIUM NOT_IN_SUBQUERY    NOT IN (SELECT ...): NULL trap and poor anti-join plans; use NOT EXISTS
  MEDIUM DEEP_OFFSET        OFFSET (or LIMIT a,b) >= 1000: keyset pagination instead
  MEDIUM NO_INDEX_FOR_FILTER  (needs --indexes) filtered/joined column not leading any known index
  LOW    QUOTED_NUMBER      id/number column compared with a quoted number: usually fine for a literal,
                            but a sign the app binds ids as text (typed text parameters can force a cast)
  LOW    OR_SAME_COLUMN     col = a OR col = b: write col IN (a, b)
  LOW    COUNT_FOR_EXISTS   COUNT(*) > 0 used as an existence check; use EXISTS
  LOW    DISTINCT_JOIN      SELECT DISTINCT with JOIN may hide a join fan-out
EXPLAIN findings (PostgreSQL text format and MySQL EXPLAIN \\G vertical format):
  HIGH   SEQ_SCAN_LARGE     Seq Scan / type ALL over >= --large-rows rows
  MEDIUM ROWS_REMOVED       a filter throws away >= 90% of the rows it reads
  MEDIUM EST_MISMATCH       estimated vs actual rows differ by >= 10x (stale statistics)
  MEDIUM DISK_SORT          external merge sort / Using filesort with Using temporary
  LOW    NESTED_LOOP_MANY   inner side of a nested loop runs >= 1000 loops
Usage:
  python3 sql_review.py query.sql
  python3 sql_review.py query.sql --explain plan.txt --indexes indexes.txt --json
  python3 sql_review.py --self-test
--indexes file: one index per line, e.g. "orders(customer_id, created_at)".
Exit code: 0 no HIGH findings, 1 HIGH findings, 2 bad input.
"""
import argparse
import json
import re
import sys
from collections import defaultdict

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
FUNCS = ("lower", "upper", "trim", "ltrim", "rtrim", "date", "year", "month", "day", "cast",
         "coalesce", "ifnull", "nvl", "substring", "substr", "left", "right", "to_char",
         "date_trunc", "extract", "date_format", "convert", "abs", "round", "concat", "length")
ID_COL = re.compile(r"(^|_)(id|number|count|qty|quantity|amount)$")
TEXT_COL = re.compile(r"(phone|zip|postal_code|postcode|sku|isbn|iban|code|_no)$")
KEYWORDS = {"where", "on", "and", "or", "not", "join", "left", "right", "inner", "outer", "full",
            "cross", "group", "order", "by", "limit", "offset", "having", "union", "select", "from",
            "as", "using", "set", "values", "into", "lateral", "natural", "straight_join", "with"}


class DataError(ValueError):
    pass


def mask(sql):
    """Remove comments, replace string literals with 'Sn' tokens; return (masked, literals)."""
    sql = re.sub(r"--[^\n]*", " ", sql)
    sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.S)
    lits = []

    def rep(m):
        lits.append(m.group(0)[1:-1].replace("''", "'"))
        return f"'S{len(lits) - 1}'"
    return re.sub(r"'(?:[^']|'')*'", rep, sql), lits


def split_statements(text):
    masked, lits = mask(text)
    out = []
    for part in masked.split(";"):
        s = " ".join(part.split())
        if s:
            out.append(s)
    return out, lits


def clause(stmt, start, enders):
    m = re.search(r"\b" + start + r"\b", stmt, re.I)
    if not m:
        return ""
    rest = stmt[m.end():]
    e = re.search(r"\b(" + "|".join(enders) + r")\b", rest, re.I)
    return rest[:e.start()] if e else rest


def where_part(stmt):
    return clause(stmt, "where", ["group\\s+by", "order\\s+by", "limit", "offset", "having",
                                  "union", "returning", "for\\s+update", "window"])


def join_on_parts(stmt):
    return re.findall(r"\bon\b(.*?)(?=\b(?:join|left|right|inner|full|cross|where|group|order|limit|union)\b|$)",
                      stmt, re.I)


def aliases(stmt):
    amap = {}
    for m in re.finditer(r"\b(?:from|join)\s+([a-z_][\w.]*)(?:\s+(?:as\s+)?([a-z_]\w*))?", stmt, re.I):
        table, alias = m.group(1).lower(), (m.group(2) or "").lower()
        if table == "select" or table.startswith("("):
            continue
        amap[table.split(".")[-1]] = table.split(".")[-1]
        if alias and alias not in KEYWORDS:
            amap[alias] = table.split(".")[-1]
    return amap


def shape(stmt):
    s = re.sub(r"'S\d+'", "?", stmt)
    s = re.sub(r"\b\d+(\.\d+)?\b", "?", s)
    s = re.sub(r"\(\s*\?(\s*,\s*\?)*\s*\)", "(?)", s)
    return s.lower()


def review_sql(text, repeat=3, indexes=None):
    stmts, lits = split_statements(text)
    if not stmts:
        raise DataError("no SQL statements found")
    findings = []

    def add(sev, code, n, msg):
        findings.append({"severity": sev, "code": code, "statement": n, "message": msg})

    shapes = defaultdict(list)
    for n, st in enumerate(stmts, 1):
        low = st.lower()
        kind = low.split()[0]
        shapes[shape(st)].append(n)
        where = where_part(st)
        ons = join_on_parts(st)
        filt = " AND ".join([where] + ons)
        if re.search(r"\bselect\s+(distinct\s+)?(\w+\.)?\*", low) or re.search(r",\s*\w+\.\*", low):
            add("MEDIUM", "SELECT_STAR", n, "SELECT * reads every column; list the columns you need so a covering index can serve the query")
        for m in re.finditer(r"\b(" + "|".join(FUNCS) + r")\s*\(\s*([a-z_][\w]*(?:\.[a-z_]\w*)?)\b[^()]*\)\s*(=|<>|!=|<=|>=|<|>|\bin\b|\blike\b|\bbetween\b)",
                             filt, re.I):
            col = m.group(2)
            if col.lower() in KEYWORDS or re.fullmatch(r"S\d+", col):
                continue
            add("HIGH", "FUNC_ON_COLUMN", n, f"{m.group(1)}({col}) in a filter hides {col} from its index; compare the bare column (e.g. a range on the raw value) or add an expression index")
        for m in re.finditer(r"([a-z_][\w.]*)\s*[+\-*/]\s*\d+\s*(=|<|>|<=|>=)", filt, re.I):
            add("HIGH", "FUNC_ON_COLUMN", n, f"arithmetic on {m.group(1)} in a filter; move it to the other side of the comparison")
        for m in re.finditer(r"([a-z_][\w.]*)\s+(not\s+)?(i?like)\s+'S(\d+)'", st, re.I):
            pat = lits[int(m.group(4))]
            if pat[:1] in ("%", "_"):
                add("HIGH", "LEADING_WILDCARD", n, f"{m.group(1)} {m.group(3).upper()} '{pat}' starts with a wildcard, so no B-tree index helps; use full-text search, a trigram index or a reversed column")
        if re.search(r"order\s+by\s+(rand|random|newid)\s*\(\s*\)", low):
            add("HIGH", "ORDER_BY_RANDOM", n, "ORDER BY random sorts every row; pick random ids or use TABLESAMPLE")
        if kind in ("update", "delete") and not where.strip():
            add("HIGH", "NO_WHERE_WRITE", n, f"{kind.upper()} without WHERE touches every row; add a WHERE clause or batch it")
        if where:
            top = where
            while re.search(r"\([^()]*\)", top):
                top = re.sub(r"\([^()]*\)", " _ ", top)
            top = re.sub(r"\bbetween\s+\S+\s+and\b", "between x", top, flags=re.I)
            if re.search(r"\band\b", top, re.I) and re.search(r"\bor\b", top, re.I):
                add("HIGH", "AND_OR_PRECEDENCE", n, "AND and OR are mixed without parentheses; AND binds first, so 'a AND b OR c' returns every row matching c; add parentheses: a AND (b OR c)")
            for group in re.split(r"\band\b", where, flags=re.I):
                if not re.search(r"\bor\b", group, re.I):
                    continue
                cols = re.findall(r"([a-z_][\w.]*)\s*(?:=|<>|!=|<|>|\bin\b|\blike\b)", group, re.I)
                cols = [c.lower() for c in cols if c.lower() not in KEYWORDS and not re.fullmatch(r"s\d+", c.lower())]
                if len(set(cols)) >= 2:
                    add("MEDIUM", "OR_ACROSS_COLUMNS", n, f"OR across {', '.join(sorted(set(cols)))}: one index cannot serve both sides; split into UNION ALL of indexed queries (or check the plan uses a bitmap OR)")
                elif len(cols) >= 2:
                    add("LOW", "OR_SAME_COLUMN", n, f"repeated OR on {cols[0]}; write {cols[0]} IN (...)")
        for m in re.finditer(r"([a-z_][\w.]*)\s*(=|<>|!=|<|>)\s*'S(\d+)'", filt, re.I):
            col = m.group(1).split(".")[-1].lower()
            if ID_COL.search(col) and re.fullmatch(r"-?\d+(\.\d+)?", lits[int(m.group(3))]):
                add("LOW", "QUOTED_NUMBER", n, f"{m.group(1)} compared with the string '{lits[int(m.group(3))]}'; bind it as a number so the driver never sends a text parameter")
        for m in re.finditer(r"([a-z_][\w.]*)\s*(=|<>|!=)\s*(\d{3,})\b", filt, re.I):
            col = m.group(1).split(".")[-1].lower()
            if TEXT_COL.search(col):
                add("MEDIUM", "IMPLICIT_CAST", n, f"{m.group(1)} looks like a text column but is compared with the number {m.group(3)}; MySQL then casts every row and skips the index (PostgreSQL raises a type error); quote the value")
        if re.search(r"\bnot\s+in\s*\(\s*select\b", low):
            add("MEDIUM", "NOT_IN_SUBQUERY", n, "NOT IN (SELECT ...) returns no rows if the subquery yields a NULL and often plans badly; use NOT EXISTS")
        m = re.search(r"\boffset\s+(\d+)", low) or re.search(r"\blimit\s+(\d+)\s*,\s*\d+", low)
        if m and int(m.group(1)) >= 1000:
            add("MEDIUM", "DEEP_OFFSET", n, f"OFFSET {m.group(1)} reads and throws away {m.group(1)} rows; use keyset pagination (WHERE (created_at, id) < (?, ?) ORDER BY ... LIMIT n)")
        if re.search(r"count\s*\(\s*(\*|1)\s*\)\s*(>\s*0|>=\s*1)", low):
            add("LOW", "COUNT_FOR_EXISTS", n, "COUNT(*) > 0 counts every match; EXISTS stops at the first")
        if re.search(r"\bselect\s+distinct\b", low) and re.search(r"\bjoin\b", low):
            add("LOW", "DISTINCT_JOIN", n, "DISTINCT on a join often hides duplicate rows from a one-to-many join; use EXISTS or aggregate first")
        if indexes is not None:
            amap = aliases(st)
            seen = set()
            for m in re.finditer(r"\b([a-z_]\w*)\.([a-z_]\w*)\s*(?:=|<|>|<=|>=|\bin\b|\bbetween\b)", filt, re.I):
                table = amap.get(m.group(1).lower())
                col = m.group(2).lower()
                if not table or (table, col) in seen:
                    continue
                seen.add((table, col))
                if table in indexes and col not in indexes[table]:
                    add("MEDIUM", "NO_INDEX_FOR_FILTER", n, f"{table}.{col} is filtered or joined but no listed index on {table} starts with it")
    for sh, ns in shapes.items():
        if len(ns) >= repeat and ("where" in sh):
            add("HIGH", "N_PLUS_ONE", ns[0], f"statements {', '.join(map(str, ns))} have the same shape with different values ({len(ns)}x); fetch them in one query with IN (...) or a JOIN")
    findings.sort(key=lambda f: (SEV_ORDER[f["severity"]], f["statement"], f["code"]))
    shown = [re.sub(r"'S(\d+)'", lambda m: "'" + lits[int(m.group(1))].replace("'", "''") + "'", st) for st in stmts]
    return shown, findings


def parse_indexes(text):
    idx = defaultdict(set)
    for line in text.splitlines():
        m = re.match(r"\s*([\w.]+)\s*\(\s*([\w\s,]+)\)", line)
        if m:
            idx[m.group(1).split(".")[-1].lower()].add(m.group(2).split(",")[0].strip().lower())
    return idx


def review_explain(text, large=10000):
    findings = []

    def add(sev, code, msg):
        findings.append({"severity": sev, "code": code, "statement": 0, "message": msg})
    lines = text.splitlines()
    if re.search(r"^\s*(\*+ \d+\. row|select_type:)", text, re.M):
        for block in re.split(r"\*+ \d+\. row \*+", text):
            f = dict(re.findall(r"^\s*(\w+):\s*(.*?)\s*$", block, re.M))
            if not f:
                continue
            rows = int(f.get("rows", "0") or 0)
            if f.get("type") == "ALL" and rows >= large:
                add("HIGH", "SEQ_SCAN_LARGE", f"full table scan (type ALL) on {f.get('table')} ~{rows} rows; key={f.get('key', 'NULL')}")
            extra = f.get("Extra", "")
            if "Using filesort" in extra and "Using temporary" in extra:
                add("MEDIUM", "DISK_SORT", f"{f.get('table')}: Using temporary; Using filesort - an index matching GROUP BY/ORDER BY avoids it")
            filtered = float(f.get("filtered", "100") or 100)
            if rows >= 1000 and filtered <= 10:
                add("MEDIUM", "ROWS_REMOVED", f"{f.get('table')}: only {filtered:g}% of {rows} rows pass the filter")
        return findings
    for i, line in enumerate(lines):
        m = re.search(r"(Parallel )?Seq Scan on (\S+).*?rows=(\d+)", line)
        if m:
            act = re.search(r"actual[^)]*rows=(\d+)", line)
            rows = int(act.group(1) if act else m.group(3))
            if rows >= large:
                add("HIGH", "SEQ_SCAN_LARGE", f"Seq Scan on {m.group(2)} returns ~{rows} rows; an index on the filter columns may help")
        m = re.search(r"Rows Removed by Filter:\s*(\d+)", line)
        if m:
            removed = int(m.group(1))
            prev = next((l for l in reversed(lines[:i]) if "actual" in l), "")
            k = re.search(r"actual[^)]*rows=(\d+)", prev)
            kept = int(k.group(1)) if k else 0
            if removed >= 1000 and removed >= 9 * max(kept, 1):
                add("MEDIUM", "ROWS_REMOVED", f"filter removed {removed} rows to keep {kept} ({prev.strip()[:60]}...)")
        m = re.search(r"cost=[\d.]+\.\.[\d.]+ rows=(\d+).*actual time=[\d.]+\.\.[\d.]+ rows=(\d+) loops=(\d+)", line)
        if m:
            est, act, loops = int(m.group(1)), int(m.group(2)), int(m.group(3))
            if max(est, act) >= 100 and max(est, act) >= 10 * max(min(est, act), 1):
                node = line.strip().lstrip("->").strip().split("  ")[0]
                add("MEDIUM", "EST_MISMATCH", f"{node[:50]}: estimated {est} rows, actual {act}; run ANALYZE")
            if loops >= 1000:
                node = line.strip().lstrip("->").strip().split("  ")[0]
                add("LOW", "NESTED_LOOP_MANY", f"{node[:50]} runs {loops} loops; check the join index or consider a hash join")
        if re.search(r"Sort Method: external merge", line):
            add("MEDIUM", "DISK_SORT", "sort spilled to disk (external merge); add an index for ORDER BY or raise work_mem for this query")
    findings.sort(key=lambda f: SEV_ORDER[f["severity"]])
    return findings


def render(path, stmts, findings):
    out = [f"# SQL performance review: {path}", "",
           f"{len(stmts)} statement(s); {sum(f['severity'] == 'HIGH' for f in findings)} HIGH, "
           f"{sum(f['severity'] == 'MEDIUM' for f in findings)} MEDIUM, {sum(f['severity'] == 'LOW' for f in findings)} LOW", ""]
    for n, st in enumerate(stmts, 1):
        out.append(f"{n}. {st[:110]}{'...' if len(st) > 110 else ''}")
    out += ["", "## Findings"]
    if not findings:
        out.append("- none (heuristics only: still check the real plan with EXPLAIN ANALYZE)")
    for f in findings:
        where = f"stmt {f['statement']}" if f["statement"] else "EXPLAIN"
        out.append(f"- {f['severity']} {f['code']} ({where}): {f['message']}")
    return "\n".join(out)


def self_test():
    results = []

    def check(name, cond):
        results.append(cond)
        print(("PASS " if cond else "FAIL ") + name)

    def codes(sql, **kw):
        return [f["code"] for f in review_sql(sql, **kw)[1]]
    check("select star", "SELECT_STAR" in codes("SELECT * FROM users WHERE id = 1"))
    check("alias star", "SELECT_STAR" in codes("SELECT o.* FROM orders o WHERE o.id = 1"))
    check("explicit columns ok", "SELECT_STAR" not in codes("SELECT id, name FROM users WHERE id = 1"))
    check("lower(col) HIGH", "FUNC_ON_COLUMN" in codes("SELECT id FROM users WHERE LOWER(email) = 'a@b.c'"))
    check("year(col) HIGH", "FUNC_ON_COLUMN" in codes("SELECT id FROM orders WHERE YEAR(created_at) = 2026"))
    check("arithmetic HIGH", "FUNC_ON_COLUMN" in codes("SELECT id FROM t WHERE price * 2 > 100"))
    check("function on value side ok", "FUNC_ON_COLUMN" not in codes("SELECT id FROM users WHERE email = LOWER('A@B.C')"))
    check("leading wildcard", "LEADING_WILDCARD" in codes("SELECT id FROM p WHERE name LIKE '%phone%'"))
    check("trailing wildcard ok", "LEADING_WILDCARD" not in codes("SELECT id FROM p WHERE name LIKE 'phone%'"))
    check("wildcard inside string literal ignored", "LEADING_WILDCARD" not in codes("SELECT 'x LIKE %y' AS note FROM p WHERE id = 1"))
    check("N+1 detected", "N_PLUS_ONE" in codes("SELECT name FROM users WHERE id = 1; SELECT name FROM users WHERE id = 2; SELECT name FROM users WHERE id = 3;"))
    check("2 repeats not N+1", "N_PLUS_ONE" not in codes("SELECT name FROM users WHERE id = 1; SELECT name FROM users WHERE id = 2;"))
    check("OR across columns", "OR_ACROSS_COLUMNS" in codes("SELECT id FROM u WHERE email = 'a' OR phone = 'b'"))
    check("OR same column LOW", "OR_SAME_COLUMN" in codes("SELECT id FROM u WHERE status = 'a' OR status = 'b'"))
    check("quoted number LOW", "QUOTED_NUMBER" in codes("SELECT id FROM orders WHERE customer_id = '42'"))
    check("implicit cast phone number", "IMPLICIT_CAST" in codes("SELECT id FROM users WHERE phone = 5551234"))
    check("number id ok", "QUOTED_NUMBER" not in codes("SELECT id FROM orders WHERE customer_id = 42"))
    check("NOT IN subquery", "NOT_IN_SUBQUERY" in codes("SELECT id FROM a WHERE id NOT IN (SELECT a_id FROM b)"))
    check("deep offset", "DEEP_OFFSET" in codes("SELECT id FROM a ORDER BY id LIMIT 20 OFFSET 50000"))
    check("mysql limit a,b offset", "DEEP_OFFSET" in codes("SELECT id FROM a ORDER BY id LIMIT 20000, 20"))
    check("order by rand", "ORDER_BY_RANDOM" in codes("SELECT id FROM a ORDER BY RAND() LIMIT 1"))
    check("delete without where", "NO_WHERE_WRITE" in codes("DELETE FROM sessions"))
    check("AND/OR precedence HIGH", "AND_OR_PRECEDENCE" in codes("SELECT id FROM o WHERE a = 1 AND b = 2 OR b = 3"))
    check("parenthesized OR ok", "AND_OR_PRECEDENCE" not in codes("SELECT id FROM o WHERE a = 1 AND (b = 2 OR b = 3)"))
    check("BETWEEN ... AND not an AND", "AND_OR_PRECEDENCE" not in codes("SELECT id FROM o WHERE d BETWEEN 1 AND 5 OR e = 2"))
    check("literals restored in output", "'mina'" in review_sql("SELECT id FROM u WHERE name = 'mina'")[0][0])
    check("count for exists", "COUNT_FOR_EXISTS" in codes("SELECT CASE WHEN COUNT(*) > 0 THEN 1 END FROM a WHERE b = 1"))
    check("distinct join", "DISTINCT_JOIN" in codes("SELECT DISTINCT u.id FROM users u JOIN orders o ON o.user_id = u.id"))
    idx = parse_indexes("orders(customer_id, created_at)\nusers(id)")
    c = codes("SELECT o.id FROM orders o JOIN users u ON u.id = o.customer_id WHERE o.status = 'paid'", indexes=idx)
    check("no index for filter", c.count("NO_INDEX_FOR_FILTER") == 1)
    check("comments stripped", codes("-- SELECT * FROM x\nSELECT id FROM users WHERE id = 1") == [])
    pg = """Seq Scan on orders  (cost=0.00..25000.00 rows=50 width=8) (actual time=0.1..310.2 rows=48210 loops=1)
  Filter: (status = 'paid'::text)
  Rows Removed by Filter: 951790
Sort Method: external merge  Disk: 51200kB"""
    e = [f["code"] for f in review_explain(pg)]
    check("pg seq scan large", "SEQ_SCAN_LARGE" in e)
    check("pg rows removed", "ROWS_REMOVED" in e)
    check("pg estimate mismatch", "EST_MISMATCH" in e)
    check("pg disk sort", "DISK_SORT" in e)
    my = """*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
         type: ALL
          key: NULL
         rows: 980000
     filtered: 5.00
        Extra: Using where; Using temporary; Using filesort"""
    e = [f["code"] for f in review_explain(my)]
    check("mysql type ALL", "SEQ_SCAN_LARGE" in e and "DISK_SORT" in e and "ROWS_REMOVED" in e)
    try:
        review_sql("  -- only a comment\n")
        check("empty input rejected", False)
    except DataError:
        check("empty input rejected", True)
    print(f"{sum(results)}/{len(results)} passed")
    return 0 if all(results) else 1


def main():
    ap = argparse.ArgumentParser(description="SQL query performance reviewer (heuristic)")
    ap.add_argument("sql", nargs="?", help=".sql file with one or more statements")
    ap.add_argument("--explain", help="EXPLAIN output (PostgreSQL text or MySQL \\G)")
    ap.add_argument("--indexes", help="known indexes, one per line: table(col1, col2)")
    ap.add_argument("--repeat", type=int, default=3, help="N+1 threshold (default 3)")
    ap.add_argument("--large-rows", type=int, default=10000, help="rows for SEQ_SCAN_LARGE (default 10000)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if not a.sql:
        ap.error("give a .sql file or --self-test")
    try:
        with open(a.sql, encoding="utf-8") as fh:
            text = fh.read()
        idx = None
        if a.indexes:
            with open(a.indexes, encoding="utf-8") as fh:
                idx = parse_indexes(fh.read())
        stmts, findings = review_sql(text, a.repeat, idx)
        if a.explain:
            with open(a.explain, encoding="utf-8") as fh:
                findings += review_explain(fh.read(), a.large_rows)
    except (OSError, DataError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    if a.json:
        print(json.dumps({"statements": stmts, "findings": findings}, indent=2))
    else:
        print(render(a.sql, stmts, findings))
    return 1 if any(f["severity"] == "HIGH" for f in findings) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonSQLDatabase+1
F@f
0
Trình Đánh Giá Thiết Kế Lược Đồ SQL (SQL Schema Design Reviewer)
Kỹ năng

Đánh giá các bảng PostgreSQL và MySQL (tệp schema.sql, bản dump hoặc lệnh CREATE TABLE trong pull request) để tìm ra các lỗi thiết kế có thể sửa dễ dàng và ít tốn kém trước khi có dữ liệu thực tế - thiếu khóa chính, lưu tiền tệ bằng cột số thực (float), khóa ngoại lệch kiểu dữ liệu hoặc thiếu chỉ mục (index), ID 32-bit, dấu thời gian (timestamp) không kèm múi giờ, các cờ (flags) cho phép NULL, trạng thái dạng văn bản tự do, đặt tên trùng từ khóa dành riêng và email duy nhất (unique) nhưng phân biệt chữ hoa chữ thường. Trả về mã DDL đã được hiệu chỉnh. Bao gồm một công cụ linter viết bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.

---
name: sql-schema-design-reviewer
description: Đánh giá các bảng PostgreSQL và MySQL (tệp schema.sql, bản dump hoặc lệnh CREATE TABLE trong pull request) để tìm ra các lỗi thiết kế có thể sửa dễ dàng và ít tốn kém trước khi có dữ liệu thực tế - thiếu khóa chính, lưu tiền tệ bằng cột số thực (float), khóa ngoại lệch kiểu dữ liệu hoặc thiếu chỉ mục (index), ID 32-bit, dấu thời gian (timestamp) không kèm múi giờ, các cờ (flags) cho phép NULL, trạng thái dạng văn bản tự do, đặt tên trùng từ khóa dành riêng và email duy nhất (unique) nhưng phân biệt chữ hoa chữ thường. Trả về mã DDL đã được hiệu chỉnh. Bao gồm một công cụ linter viết bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---

# Trình Đánh Giá Thiết Kế Lược Đồ SQL (SQL Schema Design Reviewer)

Bạn đánh giá các lược đồ cơ sở dữ liệu (database schemas) theo đúng cách mà một chuyên gia mô hình hóa dữ liệu giàu kinh nghiệm sẽ làm ở giai đoạn thiết kế: ngay trước khi dòng dữ liệu đầu tiên được ghi vào, khi việc thay đổi một kiểu dữ liệu hay một khóa chỉ là sửa một dòng mã thay vì phải chạy migration vất vả suốt cả dịp cuối tuần. Bạn xem xét cấu trúc hình thái của dữ liệu (khóa, kiểu dữ liệu, ràng buộc, cách đặt tên, chỉ mục), giải thích rõ sự cố thực tế mà từng vấn đề sẽ gây ra, và trả lại mã DDL đã được sửa chuẩn chỉnh.

Kỹ năng này tập trung đánh giá thiết kế của các bảng. Nó không bàn về việc triển khai thay đổi lên một cơ sở dữ liệu đang chạy thực tế (khóa bảng, điền dữ liệu ngược - backfill, triển khai mở rộng/thu hẹp - expand/contract); đối với nhu cầu đó, hãy sử dụng quy trình đánh giá độ an toàn của migration.

## Các tệp trong kỹ năng này

- `scripts/schema_lint.py` - phân tích cú pháp các câu lệnh `CREATE TABLE`, `CREATE INDEX` và `ALTER TABLE ... ADD`, đồng thời báo cáo các vấn đề về khóa, kiểu dữ liệu, ràng buộc, cách đặt tên và chỉ mục kèm theo mức độ nghiêm trọng và cách khắc phục; tự động nhận diện PostgreSQL hoặc MySQL (chỉ sử dụng thư viện chuẩn Python 3, không cần kết nối cơ sở dữ liệu)
- `references/design-rules.md` - toàn bộ mã lỗi phát hiện (finding codes) kèm theo sự cố mà nó gây ra, trường hợp nào có thể chấp nhận giữ nguyên và cách khắc phục
- `references/type-choices.md` - hướng dẫn chọn kiểu dữ liệu nào cho ID, tiền tệ, thời gian, văn bản, cờ (flags), trạng thái và JSON trong PostgreSQL và MySQL
- `templates/schema-review.md` - mẫu báo cáo đánh giá để bàn giao lại
- `examples/shop-schema.sql` - một lược đồ cửa hàng trực tuyến nhỏ chứa các lỗi thiết kế được cài cắm có chủ đích
- `examples/shop-schema-fixed.sql` - cùng lược đồ đó sau khi được đánh giá và sửa lỗi; vượt qua kiểm tra lint hoàn toàn sạch sẽ

## Quy trình làm việc

### 1. Thu thập lược đồ và ngữ cảnh
Yêu cầu cung cấp mã DDL (`schema.sql`, `pg_dump --schema-only`, `mysqldump --no-data`, hoặc câu lệnh `CREATE TABLE` từ pull request), hệ quản trị cơ sở dữ liệu (engine) và phiên bản, số lượng dòng dự kiến của mỗi bảng trong một năm, và cách dữ liệu được sử dụng (ứng dụng OLTP, báo cáo, đa tổ chức - multi-tenant, sao chép dữ liệu - replicated, hay truy xuất thông qua ORM). Hãy hỏi xem nhóm đã có sẵn những quy tắc nội bộ nào chưa (dùng UUID hay `bigint` cho ID, xóa mềm - soft deletes, quy tắc đặt tên).

### 2. Chạy công cụ linter
```
python3 scripts/schema_lint.py examples/shop-schema.sql
python3 scripts/schema_lint.py schema.sql --dialect mysql --min-severity MEDIUM
python3 scripts/schema_lint.py db/*.sql --ignore NO_CREATED_AT --json > schema-review.json
python3 scripts/schema_lint.py --self-test
```
Mã thoát (exit code) 0 có nghĩa là không có phát hiện mức HIGH nào, 1 có nghĩa là có ít nhất một lỗi mức HIGH (dùng làm cổng chặn CI trên các tệp schema), 2 có nghĩa là dữ liệu đầu vào không hợp lệ. Công cụ linter hoạt động theo hướng suy nghiệm (heuristic): nó đọc tên cột (ví dụ cột `price` dùng kiểu float, cột `order_id` không có khóa ngoại), vì vậy hãy đối chiếu xác nhận từng phát hiện với ý đồ thực sự của người dùng.

### 3. Đánh giá những điểm mà script không thể nhìn thấy
Sau đó, hãy tự đọc kỹ lược đồ (xem thêm `references/design-rules.md`):
- Mỗi bảng có mang một ý nghĩa rõ ràng duy nhất và có một khóa tự nhiên duy nhất (natural unique key) bên cạnh ID tự tăng (surrogate id) hay không (ví dụ: `sku`, `email`, `provider + provider_id`)?
- Các mối quan hệ đã chuẩn xác chưa: quan hệ một-nhiều (one-to-many) so với nhiều-nhiều (many-to-many), hành vi `ON DELETE` (`RESTRICT` làm mặc định; chỉ dùng `CASCADE` cho các bảng con phụ thuộc hoàn toàn), khóa ngoại chỉ cho phép `NULL` ở những nơi mối quan hệ thực sự là tùy chọn (optional)?
- Các bất biến dữ liệu (invariants) có được đặt trong ràng buộc `CHECK` (`quantity > 0`, `ends_at > starts_at`) thay vì chỉ kiểm tra trong mã nguồn ứng dụng hay không?
- Đa tổ chức (Multi-tenant): mọi bảng thuộc sở hữu của tenant có mang cột `tenant_id` không, cột đó có nằm trong các khóa duy nhất (unique keys) và đứng ở vị trí cột đầu tiên của chỉ mục (leading index column) không?
- Xóa mềm (Soft deletes): các ràng buộc duy nhất có phải là ràng buộc cục bộ có điều kiện (`WHERE deleted_at IS NULL`) không?
- Dữ liệu cá nhân: những cột nào chứa thông tin định danh cá nhân (PII), và chúng có cần quy tắc kiểm soát truy cập hoặc thời hạn lưu trữ riêng biệt không?

### 4. Bàn giao kết quả
Điền vào mẫu `templates/schema-review.md`: kết luận tổng quan (verdict), các phát hiện được xếp hạng theo mức độ ảnh hưởng kèm theo sự cố thực tế mà từng lỗi gây ra, tiếp theo là mã DDL đã được hiệu chỉnh (xem `examples/shop-schema-fixed.sql`) và danh sách các quyết định dành quyền tự chọn cho nhóm. Nếu các bảng đã chứa dữ liệu thực tế, hãy chỉ rõ những sửa đổi nào hiện cần viết kịch bản di chuyển dữ liệu (migration) và gắn cờ chuyển sang bước đánh giá an toàn migration thay vì viết lại trực tiếp các bảng đang chạy.

## Ví dụ kết quả
`examples/shop-schema.sql` (PostgreSQL, 5 bảng): 20 phát hiện, 4 lỗi mức HIGH. Bảng `order_items` không có khóa chính (có thể bị trùng lặp dòng); `products.price` dùng kiểu `double precision` và `payments.amount` dùng kiểu `real` (gây sai lệch từng xu khi tính toán); `payments.order_id` là `integer` nhưng lại tham chiếu tới `"order".id` (`bigserial`). Mức MEDIUM: dùng ID kiểu `serial` trên bảng `customers` và `products`, dùng `timestamp without time zone` trên `customers.created_at` và `placed_at`, đặt tên bảng là `"order"` (trùng từ khóa dành riêng của SQL), khóa ngoại trên `"order".customer_id` và `order_items.order_id` không có chỉ mục (index). Mức LOW: email duy nhất (unique) nhưng phân biệt chữ hoa chữ thường, thói quen dùng `varchar(255)`, cột cờ `is_verified` cho phép NULL, cột `status` lưu văn bản tự do, `product_id` không có ràng buộc khóa ngoại, dư thừa một chỉ mục trên bảng `payments`, lưu dữ liệu JSON trong cột kiểu `text`. Tệp `examples/shop-schema-fixed.sql` khắc phục toàn bộ các lỗi trên (đổi tên thành `orders`, dùng `bigint identity`, `numeric(12,2)`, `timestamptz`, `citext`, ràng buộc `CHECK` trên cột `status`, khóa chính tổ hợp trên `order_items`) và vượt qua kiểm tra lint hoàn toàn sạch sẽ.

## Quy tắc

- Giải thích từng phát hiện dưới góc độ sự cố thực tế mà nó gây ra (sai lệch tổng tiền, bản ghi mồ côi, đăng ký trùng lặp tài khoản, sập hệ thống khi chạm ngưỡng 2,1 tỷ ID), chứ không phải như một quy tắc bắt bẻ về mặt hình thức.
- Giữ nguyên các quy ước của người dùng (dùng UUID làm ID, quy tắc đặt tên) trừ khi chúng gây ra vấn đề thực sự; hãy nói rõ khi nào một phát hiện chỉ mang tính chất cân nhắc đánh đổi (judgement call).
- Ưu tiên đặt các ràng buộc (constraints) ngay trong cơ sở dữ liệu hơn là chỉ kiểm tra trong tầng ứng dụng; cơ sở dữ liệu là tuyến phòng thủ cuối cùng.
- Tiền tệ phải chính xác tuyệt đối: dùng `numeric`/`DECIMAL` hoặc số nguyên biểu thị đơn vị tiền tệ nhỏ nhất (như xu/cents), không bao giờ dùng `float`, và phải lưu kèm loại tiền tệ (currency).
- Thời gian là một thời điểm xác định (instant): dùng `timestamptz` trong PostgreSQL, và lưu theo giờ chuẩn UTC ở mọi nơi trong MySQL.
- Không đề xuất sửa đổi trực tiếp các bảng đã chứa dữ liệu trên môi trường production mà không chỉ rõ rằng mỗi thay đổi đó đều đòi hỏi một quy trình migration với những rủi ro riêng.
FILE:references/design-rules.md
# Quy tắc thiết kế và mã lỗi phát hiện (finding codes)

| Mã lỗi | Mức độ | Sự cố xảy ra là gì | Chấp nhận được khi nào | Cách khắc phục |
|---|---|---|---|---|
| NO_PRIMARY_KEY | HIGH | không thể phân biệt các dòng trùng lặp hoặc xóa từng dòng một; sao chép logic (logical replication) và CDC cần có khóa; các ORM hoạt động sai lệch | không bao giờ chấp nhận cho các bảng OLTP (có thể tạm chấp nhận cho bảng tạm staging chỉ nạp vào rồi xóa ngay trong một job) | thêm khóa đại diện `id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY` hoặc khóa tự nhiên tổ hợp (`order_id`, `product_id`) |
| FLOAT_MONEY | HIGH | `0.1 + 0.2 = 0.30000000000000004`; tổng tiền và tiền hoàn trả bị lệch từng xu, báo cáo đối soát không khớp | không bao giờ dùng cho tiền tệ; kiểu float chỉ phù hợp cho các số liệu đo đạc vật lý | dùng `numeric(12,2)` / `DECIMAL(12,2)` hoặc số nguyên `bigint` tính theo đơn vị nhỏ nhất (`amount_cents`) kèm theo cột loại tiền tệ (`currency`) |
| FK_TYPE_MISMATCH | HIGH | cột `integer` tham chiếu tới khóa `bigint`: các phép JOIN phải ép kiểu, và lệnh INSERT sẽ lỗi ngay khi ID vượt quá 2.147.483.647 | không bao giờ | dùng cùng một kiểu dữ liệu ở cả hai phía |
| MYISAM_ENGINE | HIGH | không hỗ trợ giao dịch (transactions), không có khóa ngoại, khóa toàn bảng (table locks), hỏng dữ liệu khi sập nguồn | hiếm khi dùng, họa chăng chỉ cho các bảng lưu trữ chỉ đọc (read-only archives) | `ENGINE=InnoDB` |
| INT32_PRIMARY_KEY | MEDIUM | cạn kiệt ID ở ngưỡng khoảng 2,1 tỷ (các sequence cũng bị nhảy số khi rollback); cách sửa lúc đó là phải viết lại toàn bộ bảng trên bảng lớn nhất | các bảng tra cứu nhỏ (danh mục trạng thái, quốc gia) | `bigint` identity / `BIGINT UNSIGNED AUTO_INCREMENT` |
| TIMESTAMP_WITHOUT_TZ | MEDIUM | giá trị được lưu mang ý nghĩa thời điểm khác nhau tùy thuộc vào múi giờ của tiến trình ghi; việc đổi giờ mùa hè (DST) và chuyển máy chủ sẽ làm hỏng thứ tự thời gian | các giá trị giờ đồng hồ treo tường tại địa phương không đại diện cho một thời điểm tuyệt đối (giờ mở cửa cửa hàng, giờ sinh nhật) | `timestamptz` |
| MONEY_TYPE | MEDIUM | kiểu `money` của Postgres phụ thuộc vào cài đặt `lc_monetary`, chỉ hỗ trợ một loại tiền tệ, làm tròn số khi chia | không bao giờ dùng trong các lược đồ mới | `numeric(12,2)` + cột tiền tệ (`currency`) |
| NULLABLE_AUDIT_COLUMN | MEDIUM | các dòng không có `created_at` làm hỏng các báo cáo "7 ngày qua" và gây lỗi khi chạy backfill | không bao giờ | `NOT NULL DEFAULT now()` |
| FK_WITHOUT_INDEX | MEDIUM | (PostgreSQL) việc xóa hoặc cập nhật bản ghi cha sẽ quét toàn bộ bảng con kèm theo giữ khóa (lock); phép JOIN từ bảng cha sang bảng con bị chậm. MySQL InnoDB tự động tạo chỉ mục này | bảng con cực nhỏ, bản ghi cha không bao giờ bị xóa | tạo chỉ mục (index) có các cột đầu tiên (leading columns) là các cột khóa ngoại |
| RESERVED_WORD_NAME | MEDIUM | `order`, `user`, `group` phải được đặt trong dấu ngoặc kép ở mọi truy vấn, ORM, công cụ BI và phiên psql đột xuất | không bao giờ dùng trong các schema mới | dùng tên số nhiều hoặc có tiền tố định danh: orders, app_users, user_groups, sort_order |
| MYSQL_UTF8MB3 | MEDIUM | `utf8` chỉ dài 3-byte: emoji và một số ký tự CJK sẽ bị lỗi hoặc bị cắt cụt | không bao giờ dùng trong các schema mới | `utf8mb4` + `utf8mb4_0900_ai_ci` |
| NULLABLE_BOOLEAN | LOW | logic ba giá trị: `WHERE NOT is_verified` sẽ bỏ qua các dòng có giá trị NULL | khi "chưa rõ" là một trạng thái thực tế (khi đó nên ưu tiên dùng một cột trạng thái tường minh) | `NOT NULL DEFAULT false` |
| UNCHECKED_STATUS | LOW | 'Shipped', 'shipped ', 'shiped' đều có thể xuất hiện; làm báo cáo bị phân mảnh | các trạng thái thuộc sở hữu của một hệ thống khác mà bạn chỉ sao chép lại | `CHECK (status IN (...))`, kiểu enum hoặc khóa ngoại (FK) trỏ tới bảng tra cứu |
| MISSING_FOREIGN_KEY | LOW | dữ liệu mồ côi sau khi xóa, gõ sai id, không có mối quan hệ được ghi nhận | tham chiếu liên cơ sở dữ liệu hoặc theo hướng sự kiện (event-sourced); hãy ghi tài liệu cho chúng | `REFERENCES parent(id)` + chỉ mục (index) |
| CASE_SENSITIVE_UNIQUE_EMAIL | LOW | Ana@x.com và ana@x.com thành hai tài khoản khác nhau; email đặt lại mật khẩu bị gửi nhầm chỗ | không bao giờ dùng cho email đăng nhập | `citext` hoặc `UNIQUE INDEX ON t (lower(email))` và chuẩn hóa khi ghi dữ liệu |
| VARCHAR_255_HABIT | LOW | trong Postgres, 255 là giới hạn tùy tiện từ thời MySQL; thiếu các quy tắc thực sự và những giá trị hợp lệ dài hơn lại bị từ chối | khi 255 thực sự là quy tắc nghiệp vụ | `text` + `CHECK (char_length(x) <= n)` cho các giới hạn thực tế |
| REDUNDANT_INDEX | LOW | một chỉ mục là tiền tố của chỉ mục khác (hoặc của khóa chính PK) gây tốn chi phí ghi và bộ nhớ vô ích | chỉ mục ngắn hơn có kích thước nhỏ hơn nhiều và được truy vấn cực kỳ thường xuyên | xóa bỏ nó |
| JSON_IN_TEXT | LOW | không có kiểm tra hợp lệ, không có toán tử truy vấn, không có chỉ mục GIN | chuỗi văn bản đó không thực sự là JSON | `jsonb` / `JSON` |
| NO_CREATED_AT | LOW | việc gỡ lỗi, kiểm toán và nạp bù dữ liệu (backfill) cần biết thời điểm các dòng dữ liệu xuất hiện | các bảng nối (join tables) thuần túy | `created_at timestamptz NOT NULL DEFAULT now()` |
| MIXED_CASE_NAME | LOW | tên đặt trong ngoặc kép "OrderItems" sẽ phải đặt trong ngoặc kép mãi mãi trong Postgres | không bao giờ | snake_case |
| DATETIME_NO_ZONE | LOW | (MySQL) DATETIME không có múi giờ; nhiều tiến trình ghi khác nhau sẽ lưu các múi giờ lẫn lộn | khi mọi kết nối đều thiết lập time_zone='+00:00' | bắt buộc dùng UTC |

## Những điều công cụ linter không thể đánh giá (cần rà soát thủ công)
- **Các khóa (Keys)**: có khóa duy nhất tự nhiên (natural unique key) nào ngoài id tự tăng (surrogate id) không? Thiếu `UNIQUE (provider, provider_id)` chính là nguyên nhân khiến các khoản thanh toán bị ghi nhận hai lần.
- **ON DELETE**: mặc định dùng RESTRICT/NO ACTION; chỉ dùng CASCADE cho các dòng con thực sự (order_items của một order), không bao giờ dùng từ bảng customers sang bảng payments.
- **Ràng buộc CHECK cho các bất biến (invariants)**: `quantity > 0`, `ends_at > starts_at`, `discount <= total`.
- **Đa thuê bao (Multi-tenancy)**: phải có tenant_id trên mọi bảng thuộc phạm vi thuê bao, đặt ở vị trí đầu tiên trong các khóa duy nhất và chỉ mục, cũng như trong mọi khóa ngoại FK (khóa ngoại phức hợp giúp ngăn chặn việc tham chiếu chéo giữa các thuê bao).
- **Xóa mềm (Soft deletes)**: sử dụng chỉ mục duy nhất từng phần (partial unique index) `WHERE deleted_at IS NULL`, nếu không địa chỉ email đó sẽ không bao giờ có thể được tái sử dụng.
- **Tổng giá trị phi chuẩn hóa (Denormalized totals)**: nếu lưu trữ cột orders.total, hãy nêu rõ cơ chế đảm bảo giá trị của nó luôn bằng tổng của order_items.
- **Dữ liệu định danh cá nhân (PII)**: liệt kê các cột chứa dữ liệu cá nhân; cân nhắc tách ra một bảng riêng với quyền truy cập và chính sách lưu trữ nghiêm ngặt hơn.
FILE:references/type-choices.md
# Lựa chọn kiểu dữ liệu (PostgreSQL / MySQL 8)

| Dữ liệu | PostgreSQL | MySQL 8 | Nên tránh |
|---|---|---|---|
| ID tự tăng (Surrogate id) | `bigint GENERATED ALWAYS AS IDENTITY` | `BIGINT UNSIGNED AUTO_INCREMENT` | `serial`/`int` trên các bảng đang tăng trưởng |
| ID công khai / phân tán | `uuid` (UUIDv7 để tối ưu tính cục bộ của chỉ mục, `gen_random_uuid()` cho v4) | `BINARY(16)` kết hợp với UUID_TO_BIN(x, 1) | UUID dạng `varchar(36)` (lớn gấp 3 lần, chậm hơn) |
| Tiền tệ | `numeric(12,2)` (hoặc (19,4) cho tỷ giá) + `currency char(3)` | `DECIMAL(12,2)` + `CHAR(3)` | `float`, `double`, `real`, kiểu `money` của Postgres |
| Tiền tệ dạng số nguyên | `bigint amount_cents` (lưu ý các loại tiền tệ có 0 hoặc 3 chữ số thập phân) | `BIGINT` | số xu dạng `int` (bị tràn số ở mức 21 triệu) |
| Thời điểm (sự kiện đã xảy ra) | `timestamptz` | `DATETIME(6)` theo chuẩn UTC, hoặc `TIMESTAMP` (giới hạn năm 2038, được chuyển đổi theo múi giờ của phiên) | `timestamp` không kèm múi giờ |
| Ngày lịch | `date` | `DATE` | dấu thời gian (timestamps) đặt ở nửa đêm |
| Giờ đồng hồ / lịch trình | `time` + một cột múi giờ chuẩn IANA | `TIME` + cột múi giờ | `timetz` |
| Thời lượng | `interval` hoặc số giây dạng số nguyên | số giây dạng `INT` | chuỗi văn bản như "1h30" |
| Văn bản | `text` (+ CHECK cho các giới hạn thực tế) | `VARCHAR(n)` với số n thực tế; `TEXT` cho nội dung dài | dùng `varchar(255)` theo thói quen; đệm khoảng trắng với `char(n)` |
| Văn bản không phân biệt hoa thường (email, username) | `citext` hoặc chỉ mục duy nhất dùng hàm `lower()` | bảng mã đối chiếu `_ci` không phân biệt hoa thường (mặc định) | ràng buộc unique thuần túy trên kiểu text trong Postgres |
| Cờ (Flag) | `boolean NOT NULL DEFAULT false` | `BOOLEAN` (TINYINT(1)) `NOT NULL DEFAULT 0` | kiểu boolean cho phép null, chuỗi 'Y'/'N' |
| Trạng thái | `text` + `CHECK (... IN (...))`, kiểu enum, hoặc bảng tra cứu | `ENUM(...)` hoặc bảng tra cứu | kiểu text không có ràng buộc |
| JSON | `jsonb` (dùng chỉ mục GIN cho truy vấn bao hàm) | `JSON` (dùng các cột sinh tự động để đánh chỉ mục) | lưu JSON trong cột text |
| Địa chỉ IP | `inet` / `cidr` | `VARBINARY(16)` kết hợp với INET6_ATON | kiểu text |
| Dữ liệu nhị phân lớn | lưu trữ đối tượng (object storage) + lưu khóa (key) trong cột `text` | tương tự | lưu các BLOB lớn trong những bảng truy cập thường xuyên |

## Lưu ý
- Việc thay đổi kiểu dữ liệu của cột về sau sẽ buộc phải ghi lại toàn bộ bảng trên cả hai hệ quản trị cơ sở dữ liệu (với một vài ngoại lệ như mở rộng độ dài varchar trong Postgres). Hãy thiết kế chuẩn xác các cột id và tiền tệ ngay từ đầu.
- Kiểu enum trong Postgres: thêm một giá trị mới thì nhẹ nhàng, nhưng xóa hoặc đổi tên một giá trị lại rất phức tạp. Bảng tra cứu (lookup tables) dễ nâng cấp hơn và có thể chứa thêm nhãn hiển thị cùng thứ tự sắp xếp.
- Sử dụng `NOT NULL` kèm giá trị mặc định hợp lý là quy chuẩn chung; chỉ nên cho phép null khi mang ý nghĩa "thực sự chưa biết hoặc không áp dụng".
- Luôn lưu mã tiền tệ ngay cạnh mọi khoản tiền, ngay cả khi hiện tại hệ thống chỉ mới dùng một loại tiền tệ duy nhất.
- Đặt tên nhất quán ở mọi nơi: dùng `customer_id` trong mọi bảng tham chiếu đến bảng customers, hậu tố `_at` cho thời điểm, `_on` hoặc `_date` cho ngày tháng, tiền tố `is_`/`has_` cho kiểu boolean.
FILE:templates/schema-review.md
# Đánh giá schema: <tệp dự án / schema>, <ngày>

**Hệ quản trị:** <PostgreSQL 16>   **Phạm vi:** <5 bảng: customers, products, orders, order_items, payments>
**Bối cảnh:** <Backend cửa hàng OLTP, ~2 triệu đơn hàng/năm, Prisma ORM, chưa có dữ liệu>
**Lệnh chạy:** `python3 scripts/schema_lint.py <shop-schema.sql>`
**Kết luận:** <CẦN THAY ĐỔI trước bản phát hành đầu tiên: 4 CAO (HIGH), 8 TRUNG BÌNH (MEDIUM), 8 THẤP (LOW)>

## Bắt buộc sửa (HIGH)
| # | Bảng.cột | Phát hiện | Vấn đề xảy ra | Cách khắc phục |
|---|---|---|---|---|
| 1 | <products.price> | <FLOAT_MONEY> | <0.1 + 0.2 != 0.3; tổng tiền đơn hàng bị lệch từng xu> | <numeric(12,2) NOT NULL CHECK (price >= 0)> |

## Nên sửa (MEDIUM)
| # | Bảng.cột | Phát hiện | Vấn đề xảy ra | Cách khắc phục |
|---|---|---|---|---|
| 1 | <"order"> | <RESERVED_WORD_NAME> | <phải đặt trong dấu ngoặc kép ở mọi truy vấn và công cụ BI> | <đổi tên thành orders> |

## Cân nhắc (LOW / tùy thuộc đánh giá)
- <customers.email: dùng citext để Ana@x.com và ana@x.com được tính là cùng một tài khoản>

## Phát hiện qua đọc hiểu (không phải từ công cụ linter)
- <payments: thêm UNIQUE (provider, provider_id) để khi webhook thử lại (retry) không thể ghi nhận một khoản thanh toán hai lần>
- <ON DELETE: giữ RESTRICT trên mối quan hệ payments -> orders; chỉ dùng CASCADE cho order_items -> orders>

## DDL đã hiệu chỉnh
```sql
<full corrected schema, lints clean>
```

## Các quyết định dành cho nhóm phát triển
- <chọn bigint identity hay UUIDv7 cho các id công khai>
- <lưu trực tiếp cột orders.total hay tính toán từ bảng order_items?>

## Nếu các bảng đã chứa dữ liệu
- <mỗi thay đổi ở trên là một bản di trú dữ liệu (migration); hãy liệt kê chúng và thực hiện rà soát an toàn migration trước khi triển khai>
FILE:examples/shop-schema.sql
-- Small online shop schema (PostgreSQL), first draft for review.
-- The problems are deliberate: this is what the skill is meant to catch.

CREATE TABLE customers (
    id          serial PRIMARY KEY,
    email       varchar(255) NOT NULL UNIQUE,
    full_name   varchar(255),
    phone       varchar(255),
    is_verified boolean,
    created_at  timestamp
);

CREATE TABLE products (
    id         serial PRIMARY KEY,
    sku        varchar(64) NOT NULL UNIQUE,
    title      text NOT NULL,
    price      double precision NOT NULL,
    attributes text,
    created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE "order" (
    id          bigserial PRIMARY KEY,
    customer_id integer NOT NULL REFERENCES customers (id),
    status      varchar(20) NOT NULL DEFAULT 'pending',
    total       numeric(12,2) NOT NULL,
    placed_at   timestamp NOT NULL DEFAULT now(),
    created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE order_items (
    order_id   bigint NOT NULL REFERENCES "order" (id),
    product_id bigint NOT NULL,
    quantity   integer NOT NULL,
    unit_price numeric(12,2) NOT NULL
);

CREATE TABLE payments (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_id    integer NOT NULL REFERENCES "order" (id),
    amount      real NOT NULL,
    provider    text NOT NULL,
    provider_id text,
    created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX payments_order_idx ON payments (order_id);
CREATE INDEX payments_order_created_idx ON payments (order_id, created_at);
FILE:examples/shop-schema-fixed.sql
-- Small online shop schema (PostgreSQL), after review.
-- Every change is explained in templates/schema-review.md style in the review.

CREATE EXTENSION IF NOT EXISTS citext;

CREATE TABLE customers (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email       citext NOT NULL UNIQUE,
    full_name   text,
    phone       text CHECK (char_length(phone) <= 32),
    is_verified boolean NOT NULL DEFAULT false,
    created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE products (
    id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    sku        text NOT NULL UNIQUE,
    title      text NOT NULL,
    price      numeric(12,2) NOT NULL CHECK (price >= 0),
    attributes jsonb NOT NULL DEFAULT '{}',
    created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE orders (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id bigint NOT NULL REFERENCES customers (id),
    status      text NOT NULL DEFAULT 'pending'
                CHECK (status IN ('pending', 'paid', 'shipped', 'cancelled', 'refunded')),
    total       numeric(12,2) NOT NULL CHECK (total >= 0),
    placed_at   timestamptz NOT NULL DEFAULT now(),
    created_at  timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX orders_customer_idx ON orders (customer_id);

CREATE TABLE order_items (
    order_id   bigint NOT NULL REFERENCES orders (id),
    product_id bigint NOT NULL REFERENCES products (id),
    quantity   integer NOT NULL CHECK (quantity > 0),
    unit_price numeric(12,2) NOT NULL CHECK (unit_price >= 0),
    created_at timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (order_id, product_id)
);
CREATE INDEX order_items_product_idx ON order_items (product_id);

CREATE TABLE payments (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_id    bigint NOT NULL REFERENCES orders (id),
    amount      numeric(12,2) NOT NULL,
    provider    text NOT NULL,
    provider_id text,
    created_at  timestamptz NOT NULL DEFAULT now(),
    UNIQUE (provider, provider_id)
);
CREATE INDEX payments_order_created_idx ON payments (order_id, created_at);
FILE:scripts/schema_lint.py
#!/usr/bin/env python3
"""SQL schema design linter for PostgreSQL and MySQL DDL (Python 3 standard library only).

Reads CREATE TABLE / CREATE INDEX / ALTER TABLE ... ADD statements (a schema dump,
schema.sql, or the DDL of new tables in a pull request) and reports design problems
that are cheap to fix before data exists and expensive after: tables without a
primary key, money stored in floating point, foreign keys whose type differs from the
referenced key or that have no supporting index (PostgreSQL), *_id columns without a
foreign key, 32-bit primary keys, timestamps without time zone, nullable audit
columns and booleans, unchecked status columns, reserved-word and mixed-case names,
case-sensitive unique emails, redundant indexes, JSON kept in text columns, and MySQL
tables using MyISAM or 3-byte utf8.

It does not connect to a database and does not judge migration safety (locks,
backfills); it reviews the shape of the schema itself.

Usage:
  python3 schema_lint.py FILE.sql [FILE.sql ...] [--dialect auto|postgres|mysql]
        [--min-severity LOW|MEDIUM|HIGH] [--ignore CODE,CODE] [--json]
  python3 schema_lint.py --self-test
Exit codes: 0 = no HIGH findings, 1 = HIGH findings, 2 = bad input (unreadable file,
no CREATE TABLE found).
"""
import argparse, json, re, sys

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
MONEY_RE = re.compile(r"(^|_)(price|amount|total|subtotal|cost|balance|fee|fees|tax|salary|refund|discount|paid|payment|revenue|credit|debit|charge)(_|$)", re.I)
RESERVED = {"user", "order", "group", "select", "table", "column", "desc", "asc", "check", "default", "from",
            "where", "limit", "offset", "references", "primary", "key", "range", "rank", "window", "all", "both",
            "end", "grant", "to", "type", "values", "constraint", "index", "match", "condition", "interval", "read", "row"}
RESERVED_PG_ONLY_SAFE = {"key", "range", "rank", "type", "values", "match", "condition", "interval", "read", "row", "index"}  # fine unquoted in Postgres
RESERVED_MY_ONLY_SAFE = {"offset", "user", "end", "type", "values", "row"}  # fine unquoted in MySQL 8
STATUS_RE = re.compile(r"^(status|state|kind|role|type|.*_status|.*_state|.*_type)$", re.I)
JSONISH_RE = re.compile(r"(^|_)(json|payload|metadata|meta|attributes|settings|extra|data)$", re.I)
INT32 = {"int", "integer", "int4", "serial", "serial4", "mediumint", "smallint", "int2", "smallserial", "tinyint"}
INT64 = {"bigint", "int8", "bigserial", "serial8"}


def finding(sev, code, table, column, problem, fix, line=0):
    return {"severity": sev, "code": code, "table": table, "column": column, "line": line, "problem": problem, "fix": fix}


def strip_comments(sql):
    out, i, n = [], 0, len(sql)
    while i < n:
        c = sql[i]
        if c in "'\"`":
            j = i + 1
            while j < n and sql[j] != c:
                j += 2 if sql[j] == "\\" and c == "'" else 1
            out.append(sql[i:j + 1]); i = j + 1
        elif sql.startswith("--", i) or c == "#" and (i == 0 or sql[i - 1] in "\n \t"):
            j = sql.find("\n", i); j = n if j < 0 else j
            out.append(" " * (j - i)); i = j
        elif sql.startswith("/*", i):
            j = sql.find("*/", i + 2); j = n if j < 0 else j + 2
            out.append(re.sub(r"[^\n]", " ", sql[i:j])); i = j
        else:
            out.append(c); i += 1
    return "".join(out)


def split_top(s, sep=","):
    parts, depth, cur, q = [], 0, [], None
    for ch in s:
        if q:
            cur.append(ch)
            if ch == q:
                q = None
            continue
        if ch in "'\"`":
            q = ch
        elif ch == "(":
            depth += 1
        elif ch == ")":
            depth -= 1
        if ch == sep and depth == 0:
            parts.append("".join(cur)); cur = []
        else:
            cur.append(ch)
    if "".join(cur).strip():
        parts.append("".join(cur))
    return parts


def statements(sql):
    clean = strip_comments(sql)
    pos = 0
    for part in split_top(clean, ";"):
        line = clean.count("\n", 0, pos + len(part) - len(part.lstrip())) + 1
        pos += len(part) + 1
        if part.strip():
            yield line, part.strip()


def ident(s):
    s = s.strip()
    quoted = s[:1] in "\"`"
    name = s.split(".")[-1].strip().strip("\"`")
    return name, quoted


def cols_list(s):
    return [ident(re.sub(r"\s+(ASC|DESC|NULLS\s+(FIRST|LAST)).*$", "", c.strip(), flags=re.I))[0].lower() if not c.strip().startswith("(") and "(" not in c else c.strip().lower()
            for c in split_top(s)]


class Schema:
    def __init__(self):
        self.tables, self.indexes, self.fks, self.enums = {}, [], [], set()
        self.dialect_hints = {"mysql": 0, "postgres": 0}


def parse(sql):
    sc = Schema()
    if re.search(r"AUTO_INCREMENT|ENGINE\s*=|`|UNSIGNED|DATETIME|TINYINT\(", sql, re.I):
        sc.dialect_hints["mysql"] += 1
    if re.search(r"\bSERIAL\b|BIGSERIAL|TIMESTAMPTZ|JSONB|GENERATED\s+(ALWAYS|BY DEFAULT)\s+AS\s+IDENTITY|::|CREATE\s+TYPE|CITEXT|CONCURRENTLY|\bTEXT\[\]", sql, re.I):
        sc.dialect_hints["postgres"] += 1
    for line, st in statements(sql):
        m = re.match(r"CREATE\s+(?:TEMP(?:ORARY)?\s+|UNLOGGED\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w\".`]+)\s*\((.*)\)\s*(.*)$", st, re.I | re.S)
        if m:
            name, quoted = ident(m.group(1))
            t = {"name": name, "quoted": quoted, "line": line, "cols": {}, "order": [], "pk": [], "uniques": [],
                 "checks": " ".join([]), "options": m.group(3), "raw": st}
            checks = []
            for item in split_top(m.group(2)):
                parse_item(item.strip(), t, sc, checks)
            t["checks"] = " ".join(checks).lower()
            sc.tables[name.lower()] = t
            continue
        m = re.match(r"CREATE\s+(UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?([\w\".`]*)\s*ON\s+(?:ONLY\s+)?([\w\".`]+)\s*(?:USING\s+\w+\s*)?\((.*)\)\s*(.*)$", st, re.I | re.S)
        if m:
            sc.indexes.append({"table": ident(m.group(3))[0].lower(), "cols": cols_list(m.group(4)), "unique": bool(m.group(1)),
                               "name": ident(m.group(2))[0] if m.group(2) else "", "partial": "where" in m.group(5).lower(), "line": line})
            continue
        m = re.match(r"ALTER\s+TABLE\s+(?:ONLY\s+)?(?:IF\s+EXISTS\s+)?([\w\".`]+)\s+(.*)$", st, re.I | re.S)
        if m:
            tname = ident(m.group(1))[0].lower()
            for action in split_top(m.group(2)):
                a = action.strip()
                am = re.match(r"ADD\s+(?:COLUMN\s+)(?:IF\s+NOT\s+EXISTS\s+)?(.*)$", a, re.I | re.S)
                if am and tname in sc.tables:
                    parse_item(am.group(1), sc.tables[tname], sc, [])
                    continue
                am = re.match(r"ADD\s+(.*)$", a, re.I | re.S)
                if am and tname in sc.tables:
                    chk = []
                    parse_item(am.group(1), sc.tables[tname], sc, chk)
                    sc.tables[tname]["checks"] += " " + " ".join(chk).lower()
            continue
        m = re.match(r"CREATE\s+TYPE\s+([\w\".]+)\s+AS\s+ENUM", st, re.I)
        if m:
            sc.enums.add(ident(m.group(1))[0].lower())
        m = re.match(r"CREATE\s+(?:DOMAIN|EXTENSION)\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w\".]+)", st, re.I)
        if m:
            sc.enums.add(ident(m.group(1))[0].lower())
    return sc


def parse_item(item, t, sc, checks):
    u = item.upper()
    m = re.match(r"CONSTRAINT\s+[\w\"`]+\s+(.*)$", item, re.I | re.S)
    if m:
        item, u = m.group(1).strip(), m.group(1).strip().upper()
    if u.startswith("PRIMARY KEY"):
        t["pk"] = cols_list(re.search(r"\((.*)\)", item, re.S).group(1)); return
    if re.match(r"UNIQUE\b", u):
        cm = re.search(r"\((.*?)\)", item, re.S)
        if cm:
            cols = cols_list(cm.group(1))
            t["uniques"].append(cols)
            sc.indexes.append({"table": t["name"].lower(), "cols": cols, "unique": True, "name": "", "partial": False, "line": t["line"]})
        return
    if u.startswith("FOREIGN KEY"):
        fm = re.match(r"FOREIGN\s+KEY\s*\((.*?)\)\s*REFERENCES\s+([\w\".`]+)\s*(?:\((.*?)\))?", item, re.I | re.S)
        if fm:
            sc.fks.append({"table": t["name"].lower(), "cols": cols_list(fm.group(1)), "ref": ident(fm.group(2))[0].lower(),
                           "ref_cols": cols_list(fm.group(3)) if fm.group(3) else None})
        return
    if u.startswith("CHECK"):
        checks.append(item); return
    if re.match(r"(UNIQUE\s+)?(KEY|INDEX)\b", u) or re.match(r"(FULLTEXT|SPATIAL)\b", u):
        cm = re.search(r"\((.*)\)", item, re.S)
        if cm:
            sc.indexes.append({"table": t["name"].lower(), "cols": cols_list(cm.group(1)), "unique": u.startswith("UNIQUE"),
                               "name": "", "partial": False, "line": t["line"]})
        return
    if u.startswith(("EXCLUDE", "LIKE", "PERIOD")):
        return
    cm = re.match(r"([\"`][^\"`]+[\"`]|[\w$]+)\s+(.*)$", item, re.S)
    if not cm:
        return
    name, quoted = ident(cm.group(1))
    rest = cm.group(2)
    tm = re.match(r"((?:double\s+precision|character\s+varying|bit\s+varying|timestamp(?:\s*\(\d+\))?(?:\s+with(?:out)?\s+time\s+zone)?(?!\w)|time(?:\s+with(?:out)?\s+time\s+zone)?(?!\w)|[\w.]+)(?:\s*\([^)]*\))?(?:\s+unsigned)?(?:\s*\[\])?)", rest, re.I)
    ctype = re.sub(r"\s+", " ", tm.group(1).lower()) if tm else ""
    ru = rest.upper()
    col = {"name": name, "quoted": quoted, "type": ctype, "not_null": "NOT NULL" in ru or "PRIMARY KEY" in ru,
           "default": "DEFAULT" in ru or "IDENTITY" in ru or "AUTO_INCREMENT" in ru or ctype.endswith("serial"),
           "check": "CHECK" in ru}
    t["cols"][name.lower()] = col
    t["order"].append(name.lower())
    if "PRIMARY KEY" in ru:
        t["pk"] = [name.lower()]
    if re.search(r"\bUNIQUE\b", ru):
        t["uniques"].append([name.lower()])
        sc.indexes.append({"table": t["name"].lower(), "cols": [name.lower()], "unique": True, "name": "", "partial": False, "line": t["line"]})
    rm = re.search(r"REFERENCES\s+([\w\".`]+)\s*(?:\((.*?)\))?", rest, re.I)
    if rm:
        sc.fks.append({"table": t["name"].lower(), "cols": [name.lower()], "ref": ident(rm.group(1))[0].lower(),
                       "ref_cols": cols_list(rm.group(2)) if rm.group(2) else None})
    if col["check"]:
        checks.append(f"check ({name})")


def qi(name):
    return f'"{name}"' if name.lower() in RESERVED or name != name.lower() else name


def base_type(ctype):
    t = re.sub(r"\(.*?\)|\s+unsigned|\[\]", "", ctype).strip()
    return {"int4": "integer", "int": "integer", "serial": "integer", "serial4": "integer", "int8": "bigint",
            "bigserial": "bigint", "serial8": "bigint", "int2": "smallint", "smallserial": "smallint"}.get(t, t)


def lint(sql, dialect="auto", ignore=()):
    sc = parse(sql)
    if dialect == "auto":
        dialect = "mysql" if sc.dialect_hints["mysql"] > sc.dialect_hints["postgres"] else "postgres"
    out = []

    def add(*a, **k):
        f = finding(*a, **k)
        if f["code"] not in ignore:
            out.append(f)

    table_names = set(sc.tables)
    for key, t in sc.tables.items():
        tn = t["name"]
        ln = t["line"]
        if not t["pk"]:
            add("HIGH", "NO_PRIMARY_KEY", tn, "", "table has no primary key: duplicate rows cannot be told apart, updates and replication (logical replication, MySQL row-based, CDC) suffer",
                "add a bigint identity (or UUID) primary key, or a composite key on the natural unique columns", line=ln)
        elif len(t["pk"]) == 1 and t["pk"][0] in t["cols"]:
            c = t["cols"][t["pk"][0]]
            bt = base_type(c["type"])
            if bt in {"integer", "smallint", "mediumint", "tinyint"} and not re.search(r"(^|_)(type|status|kind|country|currency|lookup)s?$", tn, re.I):
                add("MEDIUM", "INT32_PRIMARY_KEY", tn, c["name"], f"primary key {c['name']} is {c['type']} (max about 2.1 billion, less for smaller types); running out means an emergency type change on the biggest table",
                    "use bigint GENERATED ALWAYS AS IDENTITY (Postgres) or BIGINT UNSIGNED AUTO_INCREMENT (MySQL)", line=ln)
        if dialect == "postgres" and t["quoted"] and t["name"] != t["name"].lower():
            add("LOW", "MIXED_CASE_NAME", tn, "", f'table "{tn}" is quoted mixed case: every query must quote it forever', "use snake_case lower-case names", line=ln)
        if tn.lower() in RESERVED and not (dialect == "postgres" and tn.lower() in RESERVED_PG_ONLY_SAFE) and not (dialect == "mysql" and tn.lower() in RESERVED_MY_ONLY_SAFE):
            add("MEDIUM", "RESERVED_WORD_NAME", tn, "", f"table name '{tn}' is a reserved word and must be quoted in every query and ORM", "rename (e.g. users, orders, user_groups)", line=ln)
        if dialect == "mysql":
            opts = t["options"].lower()
            if re.search(r"engine\s*=\s*myisam", opts):
                add("HIGH", "MYISAM_ENGINE", tn, "", "ENGINE=MyISAM has no transactions, no foreign keys and table-level locks; crashes can corrupt it", "use ENGINE=InnoDB", line=ln)
            if re.search(r"charset\s*=\s*utf8(mb3)?\b(?!mb4)", opts):
                add("MEDIUM", "MYSQL_UTF8MB3", tn, "", "utf8 in MySQL is 3-byte utf8mb3: emoji and some CJK characters fail to insert or get mangled", "use DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci", line=ln)
        if not any(re.match(r"^(created_at|created|created_on|inserted_at|date_created|creation_date)$", c) for c in t["cols"]):
            add("LOW", "NO_CREATED_AT", tn, "", "no created_at column: hard to debug, audit or backfill later", "add created_at timestamptz NOT NULL DEFAULT now()", line=ln)
        v255 = [c["name"] for c in t["cols"].values() if re.match(r"(varchar|character varying)\s*\(255\)", c["type"])]
        if dialect == "postgres" and len(v255) >= 3:
            add("LOW", "VARCHAR_255_HABIT", tn, ", ".join(v255), f"{len(v255)} columns are varchar(255): in Postgres the limit costs nothing to drop but 255 is rarely the real rule",
                "use text, with a CHECK (char_length(x) <= n) where a real limit exists", line=ln)
        for cname in t["order"]:
            c = t["cols"][cname]
            ct, bt = c["type"], base_type(c["type"])
            cn = c["name"]
            if MONEY_RE.search(cn) and bt in {"float", "real", "double", "double precision", "float4", "float8"}:
                add("HIGH", "FLOAT_MONEY", tn, cn, f"{cn} is {ct}: binary floating point cannot store 0.10 exactly, sums drift by cents",
                    "use numeric(12,2) / DECIMAL(12,2), or bigint minor units (amount_cents)", line=ln)
            if bt == "money" and dialect == "postgres":
                add("MEDIUM", "MONEY_TYPE", tn, cn, "Postgres money type depends on lc_monetary, has one fixed currency and rounds", "use numeric(12,2) plus a currency column", line=ln)
            if dialect == "postgres" and (re.match(r"^timestamp(\s*\(\d+\))?$", ct) or "without time zone" in ct):
                add("MEDIUM", "TIMESTAMP_WITHOUT_TZ", tn, cn, f"{cn} is timestamp without time zone: the instant depends on whatever zone the writer assumed", "use timestamptz (stores an absolute instant, displays in the session zone)", line=ln)
            if dialect == "mysql" and bt == "datetime" and re.search(r"_at$", cn):
                add("LOW", "DATETIME_NO_ZONE", tn, cn, f"{cn} is DATETIME (no zone): make sure every writer stores UTC", "document and enforce UTC (time_zone='+00:00' on connections) or use TIMESTAMP within its 2038 range", line=ln)
            if re.match(r"^(created_at|updated_at|inserted_at)$", cn, re.I) and not c["not_null"]:
                add("MEDIUM", "NULLABLE_AUDIT_COLUMN", tn, cn, f"{cn} is nullable{'' if c['default'] else ' and has no default'}: rows without it will appear", f"{cn} timestamptz NOT NULL DEFAULT now()", line=ln)
            if bt in {"boolean", "bool"} and not c["not_null"]:
                add("LOW", "NULLABLE_BOOLEAN", tn, cn, f"{cn} is a nullable boolean: three states (true, false, unknown) and NULL-unsafe WHERE clauses", f"{cn} boolean NOT NULL DEFAULT false", line=ln)
            if STATUS_RE.match(cn) and re.match(r"^(text|varchar|character varying|char|character)", bt) and not c["check"] \
                    and cn.lower() not in t["checks"] and not any(f["table"] == key and cname in f["cols"] for f in sc.fks):
                add("LOW", "UNCHECKED_STATUS", tn, cn, f"{cn} is free text: typos like 'Shipped ' and 'shiped' will end up in the data", "add CHECK (status IN (...)), an enum type, or a FK to a lookup table", line=ln)
            if bt in {"text", "longtext", "mediumtext"} and JSONISH_RE.search(cn):
                add("LOW", "JSON_IN_TEXT", tn, cn, f"{cn} looks like JSON stored as {ct}: no validation, no indexing", "use jsonb (Postgres) or JSON (MySQL) if it really is JSON", line=ln)
            if cn.lower() in RESERVED and not (dialect == "postgres" and cn.lower() in RESERVED_PG_ONLY_SAFE) and not (dialect == "mysql" and cn.lower() in RESERVED_MY_ONLY_SAFE) and cn.lower() not in {"type"}:
                add("MEDIUM", "RESERVED_WORD_NAME", tn, cn, f"column name '{cn}' is a reserved word and needs quoting everywhere", "rename it (e.g. sort_order, user_id, group_name, ends_at)", line=ln)
            if dialect == "postgres" and c["quoted"] and cn != cn.lower():
                add("LOW", "MIXED_CASE_NAME", tn, cn, f'column "{cn}" is quoted mixed case: every query must quote it', "use snake_case", line=ln)
            if cn.lower().endswith("_id") and cname not in t["pk"] and not any(f["table"] == key and cname in f["cols"] for f in sc.fks):
                stem = cn.lower()[:-3]
                cands = [x for x in (stem, stem + "s", stem + "es", stem[:-1] + "ies" if stem.endswith("y") else None) if x and x in table_names]
                if cands:
                    add("LOW", "MISSING_FOREIGN_KEY", tn, cn, f"{cn} looks like a reference to {cands[0]} but has no foreign key: orphans and typos are possible", f"add REFERENCES {cands[0]}(id) (with an index)", line=ln)
            if re.search(r"(^|_)email$", cn, re.I) and [cname] in t["uniques"] and dialect == "postgres" and bt != "citext":
                if not any(i["table"] == key and any("lower(" in c2 for c2 in i["cols"]) for i in sc.indexes):
                    add("LOW", "CASE_SENSITIVE_UNIQUE_EMAIL", tn, cn, "UNIQUE on email is case-sensitive in Postgres: Ana@x.com and ana@x.com can both sign up", "CREATE UNIQUE INDEX ... ON t (lower(email)) or use citext", line=ln)
    # foreign keys: types and indexes
    for f in sc.fks:
        t = sc.tables.get(f["table"])
        r = sc.tables.get(f["ref"])
        if not t:
            continue
        ref_cols = f["ref_cols"] or (r["pk"] if r else None)
        if r and ref_cols and len(ref_cols) == len(f["cols"]):
            for c, rc in zip(f["cols"], ref_cols):
                a, b = t["cols"].get(c), r["cols"].get(rc)
                if a and b and base_type(a["type"]) != base_type(b["type"]) and not ({base_type(a["type"]), base_type(b["type"])} <= {"uuid"}):
                    add("HIGH", "FK_TYPE_MISMATCH", t["name"], a["name"], f"{a['name']} is {a['type']} but references {r['name']}.{b['name']} ({b['type']}): joins need casts and the FK fails once ids pass the smaller range",
                        f"make {a['name']} the same type as {r['name']}.{b['name']}", line=t["line"])
        if dialect == "postgres":
            covered = any(idx["table"] == f["table"] and not idx["partial"] and idx["cols"][:len(f["cols"])] == f["cols"] for idx in sc.indexes) \
                or t["pk"][:len(f["cols"])] == f["cols"] or any(u[:len(f["cols"])] == f["cols"] for u in t["uniques"])
            if not covered:
                add("MEDIUM", "FK_WITHOUT_INDEX", t["name"], ", ".join(f["cols"]), f"foreign key ({', '.join(f['cols'])}) -> {f['ref']} has no index starting with it: deletes/updates on {f['ref']} scan {t['name']}, and joins are slow",
                    f"CREATE INDEX CONCURRENTLY ON {qi(t['name'])} ({', '.join(f['cols'])})", line=t["line"])
    # redundant indexes
    for i, a in enumerate(sc.indexes):
        t = sc.tables.get(a["table"])
        if not t or a["partial"] or any("(" in c for c in a["cols"]):
            continue
        others = [b for j, b in enumerate(sc.indexes) if j != i and b["table"] == a["table"] and not b["partial"]]
        if t["pk"] and a["cols"] == t["pk"][:len(a["cols"])] and not a["unique"]:
            add("LOW", "REDUNDANT_INDEX", t["name"], ", ".join(a["cols"]), f"index on ({', '.join(a['cols'])}) duplicates the primary key's leading columns", "drop it", line=a["line"])
            continue
        for b in others:
            if len(b["cols"]) > len(a["cols"]) and b["cols"][:len(a["cols"])] == a["cols"] and not a["unique"]:
                add("LOW", "REDUNDANT_INDEX", t["name"], ", ".join(a["cols"]), f"index on ({', '.join(a['cols'])}) is a prefix of the index on ({', '.join(b['cols'])})", "drop the shorter one unless it is much smaller and hot", line=a["line"])
                break
            if b["cols"] == a["cols"] and j_before(sc.indexes, b, a) and a["unique"] == b["unique"]:
                add("LOW", "REDUNDANT_INDEX", t["name"], ", ".join(a["cols"]), f"two indexes on ({', '.join(a['cols'])})", "drop one", line=a["line"])
                break
    out.sort(key=lambda f: (SEV_ORDER[f["severity"]], f["table"], f["column"], f["code"]))
    return dialect, sc, out


def j_before(lst, b, a):
    return lst.index(b) < lst.index(a)


def report(path, dialect, sc, found):
    counts = {s: sum(1 for f in found if f["severity"] == s) for s in SEV_ORDER}
    out = [f"# Schema review: {path} ({dialect})", "",
           f"Tables: {len(sc.tables)}   Indexes: {len(sc.indexes)}   Foreign keys: {len(sc.fks)}   Findings: {len(found)} (HIGH {counts['HIGH']}, MEDIUM {counts['MEDIUM']}, LOW {counts['LOW']})", ""]
    if not found:
        out.append("No findings.")
        return "\n".join(out)
    out += ["| Sev | Code | Table.Column | Problem | Fix |", "|---|---|---|---|---|"]
    for f in found:
        loc = f["table"] + ("." + f["column"] if f["column"] else "")
        out.append(f"| {f['severity']} | {f['code']} | {loc} | {f['problem'].replace('|', '/')} | {f['fix'].replace('|', '/')} |")
    return "\n".join(out)


def self_test():
    ok_pk = "CREATE TABLE t (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT now()"
    cases = [
        ("clean postgres table", ok_pk + ", name text NOT NULL);", "postgres", [], ["NO_PRIMARY_KEY", "INT32_PRIMARY_KEY", "NO_CREATED_AT"]),
        ("no primary key", "CREATE TABLE logs (msg text, created_at timestamptz NOT NULL);", "auto", ["NO_PRIMARY_KEY"], []),
        ("table-level composite pk", "CREATE TABLE m (a bigint, b bigint, created_at timestamptz NOT NULL, PRIMARY KEY (a, b));", "auto", [], ["NO_PRIMARY_KEY"]),
        ("named pk constraint", "CREATE TABLE m (id bigint, created_at timestamptz NOT NULL, CONSTRAINT m_pk PRIMARY KEY (id));", "auto", [], ["NO_PRIMARY_KEY"]),
        ("pk added by alter", "CREATE TABLE m (id bigint NOT NULL, created_at timestamptz NOT NULL); ALTER TABLE m ADD CONSTRAINT m_pkey PRIMARY KEY (id);", "auto", [], ["NO_PRIMARY_KEY"]),
        ("serial pk is int32", "CREATE TABLE orders (id serial PRIMARY KEY, created_at timestamptz NOT NULL);", "auto", ["INT32_PRIMARY_KEY"], []),
        ("small lookup table int pk ok", "CREATE TABLE order_status (id smallint PRIMARY KEY, created_at timestamptz NOT NULL);", "auto", [], ["INT32_PRIMARY_KEY"]),
        ("float money", ok_pk + ", total_price double precision);", "postgres", ["FLOAT_MONEY"], []),
        ("numeric money ok", ok_pk + ", amount numeric(12,2) NOT NULL);", "postgres", [], ["FLOAT_MONEY"]),
        ("float non-money ok", ok_pk + ", latitude double precision);", "postgres", [], ["FLOAT_MONEY"]),
        ("money type", ok_pk + ", balance money);", "postgres", ["MONEY_TYPE"], []),
        ("timestamp without tz", ok_pk + ", shipped_at timestamp);", "postgres", ["TIMESTAMP_WITHOUT_TZ"], []),
        ("timestamp(3) without time zone", ok_pk + ", shipped_at timestamp(3) without time zone);", "postgres", ["TIMESTAMP_WITHOUT_TZ"], []),
        ("timestamptz ok", ok_pk + ", shipped_at timestamp with time zone);", "postgres", [], ["TIMESTAMP_WITHOUT_TZ"]),
        ("nullable created_at", "CREATE TABLE t (id bigint PRIMARY KEY, created_at timestamptz);", "postgres", ["NULLABLE_AUDIT_COLUMN"], []),
        ("no created_at", "CREATE TABLE t (id bigint PRIMARY KEY, name text);", "postgres", ["NO_CREATED_AT"], []),
        ("nullable boolean", ok_pk + ", is_active boolean);", "postgres", ["NULLABLE_BOOLEAN"], []),
        ("boolean not null ok", ok_pk + ", is_active boolean NOT NULL DEFAULT true);", "postgres", [], ["NULLABLE_BOOLEAN"]),
        ("unchecked status", ok_pk + ", status varchar(20) NOT NULL);", "postgres", ["UNCHECKED_STATUS"], []),
        ("status with check ok", ok_pk + ", status text NOT NULL CHECK (status IN ('a','b')));", "postgres", [], ["UNCHECKED_STATUS"]),
        ("status table check ok", ok_pk + ", status text NOT NULL, CONSTRAINT st CHECK (status IN ('a')));", "postgres", [], ["UNCHECKED_STATUS"]),
        ("reserved table name", "CREATE TABLE \"user\" (id bigint PRIMARY KEY, created_at timestamptz NOT NULL);", "postgres", ["RESERVED_WORD_NAME"], []),
        ("reserved column order", ok_pk + ", \"order\" int);", "postgres", ["RESERVED_WORD_NAME"], []),
        ("mixed case quoted", "CREATE TABLE \"OrderItems\" (id bigint PRIMARY KEY, created_at timestamptz NOT NULL);", "postgres", ["MIXED_CASE_NAME"], []),
        ("fk without index", "CREATE TABLE a (id bigint PRIMARY KEY, created_at timestamptz NOT NULL); CREATE TABLE b (id bigint PRIMARY KEY, created_at timestamptz NOT NULL, a_id bigint NOT NULL REFERENCES a(id));", "postgres", ["FK_WITHOUT_INDEX"], ["FK_TYPE_MISMATCH"]),
        ("fk with index ok", "CREATE TABLE a (id bigint PRIMARY KEY, created_at timestamptz NOT NULL); CREATE TABLE b (id bigint PRIMARY KEY, created_at timestamptz NOT NULL, a_id bigint REFERENCES a); CREATE INDEX ON b (a_id, id);", "postgres", [], ["FK_WITHOUT_INDEX"]),
        ("fk covered by composite pk", "CREATE TABLE a (id bigint PRIMARY KEY, created_at timestamptz NOT NULL); CREATE TABLE ab (a_id bigint REFERENCES a(id), x bigint, created_at timestamptz NOT NULL, PRIMARY KEY (a_id, x));", "postgres", [], ["FK_WITHOUT_INDEX"]),
        ("fk type mismatch", "CREATE TABLE a (id bigint PRIMARY KEY, created_at timestamptz NOT NULL); CREATE TABLE b (id bigint PRIMARY KEY, created_at timestamptz NOT NULL, a_id integer, FOREIGN KEY (a_id) REFERENCES a (id));", "postgres", ["FK_TYPE_MISMATCH"], []),
        ("missing fk on _id", "CREATE TABLE customers (id bigint PRIMARY KEY, created_at timestamptz NOT NULL); CREATE TABLE orders (id bigint PRIMARY KEY, created_at timestamptz NOT NULL, customer_id bigint NOT NULL);", "postgres", ["MISSING_FOREIGN_KEY"], []),
        ("external _id without table ok", ok_pk + ", stripe_id text);", "postgres", [], ["MISSING_FOREIGN_KEY"]),
        ("case-sensitive unique email", ok_pk + ", email text NOT NULL UNIQUE);", "postgres", ["CASE_SENSITIVE_UNIQUE_EMAIL"], []),
        ("lower(email) index ok", ok_pk + ", email text NOT NULL UNIQUE); CREATE UNIQUE INDEX t_email ON t (lower(email));", "postgres", [], ["CASE_SENSITIVE_UNIQUE_EMAIL"]),
        ("redundant prefix index", ok_pk + ", a bigint, b bigint); CREATE INDEX i1 ON t (a); CREATE INDEX i2 ON t (a, b);", "postgres", ["REDUNDANT_INDEX"], []),
        ("json in text", ok_pk + ", payload text);", "postgres", ["JSON_IN_TEXT"], []),
        ("varchar 255 habit", ok_pk + ", a varchar(255), b varchar(255), c varchar(255));", "postgres", ["VARCHAR_255_HABIT"], []),
        ("mysql myisam and utf8", "CREATE TABLE `t` (`id` int unsigned NOT NULL AUTO_INCREMENT, `created_at` datetime NOT NULL, PRIMARY KEY (`id`)) ENGINE=MyISAM DEFAULT CHARSET=utf8;", "auto", ["MYISAM_ENGINE", "MYSQL_UTF8MB3", "INT32_PRIMARY_KEY", "DATETIME_NO_ZONE"], ["TIMESTAMP_WITHOUT_TZ"]),
        ("mysql utf8mb4 innodb ok", "CREATE TABLE `t` (`id` bigint unsigned NOT NULL AUTO_INCREMENT, `created_at` datetime NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;", "auto", [], ["MYISAM_ENGINE", "MYSQL_UTF8MB3", "INT32_PRIMARY_KEY"]),
        ("mysql fk needs no extra index", "CREATE TABLE a (id bigint PRIMARY KEY, created_at datetime NOT NULL) ENGINE=InnoDB; CREATE TABLE b (id bigint PRIMARY KEY, created_at datetime NOT NULL, a_id bigint, FOREIGN KEY (a_id) REFERENCES a(id)) ENGINE=InnoDB;", "mysql", [], ["FK_WITHOUT_INDEX"]),
        ("comments and strings ignored", "-- CREATE TABLE x (a int);\n/* CREATE TABLE y (b int); */\n" + ok_pk + ", note text DEFAULT 'a;b -- c');", "postgres", [], ["NO_PRIMARY_KEY"]),
        ("alter add column parsed", ok_pk + "); ALTER TABLE t ADD COLUMN total_amount float;", "postgres", ["FLOAT_MONEY"], []),
    ]
    passed = 0
    for name, sql, dialect, expect, absent in cases:
        d, sc, found = lint(sql, dialect)
        codes = {f["code"] for f in found}
        ok = all(c in codes for c in expect) and not any(c in codes for c in absent)
        if name == "clean postgres table":
            ok = ok and not found
        if name == "comments and strings ignored":
            ok = ok and len(sc.tables) == 1
        if name.startswith("mysql myisam"):
            ok = ok and d == "mysql"
        passed += ok
        print(("PASS " if ok else "FAIL ") + name + ("" if ok else f" -> got {sorted(codes)}"))
    d, sc, found = lint("", "auto")
    ok = report("x.sql", d, sc, found).endswith("No findings.")
    passed += ok
    print(("PASS " if ok else "FAIL ") + "empty report")
    total = len(cases) + 1
    print(f"{passed}/{total} passed")
    return 0 if passed == total else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="SQL schema design linter (Postgres/MySQL DDL)")
    ap.add_argument("files", nargs="*")
    ap.add_argument("--dialect", default="auto", choices=["auto", "postgres", "mysql"])
    ap.add_argument("--min-severity", default="LOW", choices=list(SEV_ORDER))
    ap.add_argument("--ignore", default="")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.files:
        ap.print_usage(sys.stderr)
        return 2
    ignore = {c.strip() for c in a.ignore.split(",") if c.strip()}
    sql_all = []
    for p in a.files:
        try:
            with open(p, encoding="utf-8") as fh:
                sql_all.append(fh.read())
        except OSError as e:
            print(f"error: cannot read {p}: {e}", file=sys.stderr)
            return 2
    dialect, sc, found = lint("\n;\n".join(sql_all), a.dialect, ignore)
    if not sc.tables:
        print("error: no CREATE TABLE statements found", file=sys.stderr)
        return 2
    found = [f for f in found if SEV_ORDER[f["severity"]] <= SEV_ORDER[a.min_severity]]
    if a.json:
        print(json.dumps({"dialect": dialect, "tables": len(sc.tables), "findings": found}, indent=2))
    else:
        print(report(", ".join(a.files), dialect, sc, found))
    return 1 if any(f["severity"] == "HIGH" for f in found) else 0


if __name__ == "__main__":
    sys.exit(main())
BackendPythonSQL+2
F@f
0