ORM Odoo lo được 99% nhu cầu. Nhưng có lúc bạn cần một câu SQL thô: một phép gộp phức tạp, một UPDATE hàng loạt cần nhanh, một truy vấn thống kê mà read_group không diễn đạt nổi. Odoo cho phép qua self.env.cr.execute(...). Sức mạnh đó đi kèm một cái bẫy chết người: nối chuỗi input vào câu lệnh là mở cửa cho SQL injection. Bài này chỉ cách gọi SQL thô cho đúng, với một ví dụ injection thật để bạn thấy hậu quả cụ thể.
Cách đúng: tham số hoá bằng %s
Quy tắc số một: không bao giờ ghép giá trị vào chuỗi SQL. Thay vào đó, để %s làm chỗ giữ chỗ (placeholder) và truyền giá trị qua tham số thứ hai — driver psycopg sẽ tự thoát an toàn:
self.env.cr.execute(
"SELECT id, name, diem FROM quan_the_thanh_vien WHERE name = %s",
(ten,)) # tuple tham số — psycopg tự thoát
rows = self.env.cr.fetchall() # [(1, 'VIP-0001', 120)]
rows = self.env.cr.dictfetchall() # [{'id':1,'name':'VIP-0001','diem':120}]
Lưu ý một điểm dễ nhầm chết người: %s ở đây KHÔNG phải toán tử % của Python. Nó là placeholder của psycopg. Bạn không viết ... %s" % ten (đó chính là lỗ hổng); bạn để dấu phẩy: execute(sql, (ten,)). Hai cái trông giống nhau nhưng một cái an toàn, một cái là cửa hậu.
fetchall() trả về list các tuple; dictfetchall() trả về list các dict với khoá là tên cột — tiện khi câu có nhiều cột.
Ví dụ injection thật: một dòng đủ lộ cả bảng
Để thấy vì sao chuyện này nghiêm trọng, cùng chạy thật với một chuỗi input độc hại x' OR '1'='1:

Hình 1: Cùng một chuỗi input độc hại, hai kết quả trái ngược. Bản tham số hoá coi cả x' OR '1'='1 là một cái tên để so khớp — không có ai tên vậy nên count = 0, an toàn. Bản nối chuỗi biến input thành cú pháp SQL: mệnh đề OR '1'='1' luôn đúng, nên câu đếm ra count = 1 — tức toàn bộ bảng, bỏ qua điều kiện lọc. Với dữ liệu nhạy cảm thật, đây là cách kẻ tấn công moi sạch dữ liệu họ không được phép thấy.
Điểm cốt lõi: injection không phải lỗi "hiếm khi gặp" — nó xảy ra bất cứ khi nào một chuỗi do người dùng kiểm soát chui vào câu SQL qua phép nối. Tham số hoá chặn đứng bằng cách tách bạch câu lệnh và dữ liệu.
Ghép câu động an toàn: đối tượng SQL của Odoo 19
%s chỉ thay được giá trị, không thay được tên cột/bảng hay mảnh cú pháp. Khi bạn cần ghép động cả những thứ đó (ví dụ ORDER BY theo cột do người dùng chọn), Odoo 19 có đối tượng SQL trong odoo.tools — nó thoát cả giá trị lẫn định danh (identifier) đúng cách:
from odoo.tools import SQL
q = SQL(
"SELECT name, diem FROM quan_the_thanh_vien WHERE diem >= %s ORDER BY %s",
100, SQL.identifier("diem")) # 100 là giá trị; identifier thoát tên cột
self.env.cr.execute(q) # → [('VIP-0001', 120)]
SQL.identifier("diem") bọc tên cột thành định danh được thoát an toàn, nên kể cả tên cột đến từ input cũng không thành lỗ hổng. Đây là cách chuẩn để dựng câu động trong Odoo hiện đại, thay cho việc tự format chuỗi.
Đừng quên cache và flush
SQL thô đi vòng qua ORM — nghĩa là mọi bài học ở phần cache và phần flush áp dụng ngay:

Hình 2: Khuôn mẫu đầy đủ. Ngoài tham số hoá, nhớ cặp đồng bộ cache: flush_all() trước khi SQL đọc bảng (để DB có dữ liệu mới nhất ORM đang giữ trong cache), và invalidate_all() sau khi SQL ghi (để cache không trả giá trị cũ). Bỏ qua bước này là gặp đúng loại bug "số liệu lệch" ở hai bài trước.
Khi nào nên — và không nên — dùng SQL thô
- Nên khi: cần hiệu năng cho thao tác hàng loạt lớn, một truy vấn phân tích phức tạp, hay đọc thứ ORM không mô hình hoá. Nhưng luôn tham số hoá.
- Không nên khi: ORM làm được. SQL thô bỏ qua access rights, record rules, computed fields, constraint Python và cache — bạn tự chịu trách nhiệm mọi thứ ORM vốn lo hộ. Một
searchan toàn hơn nhiều mộtSELECTtay. - Cẩn thận với
UPDATE/DELETEthô: chúng không kích hoạt logicwrite/unlink, không ghiwrite_date, không chạy@api.constrains. Chỉ dùng khi bạn thật sự muốn bỏ qua các tầng đó.
Ba ý mang về
- Luôn tham số hoá bằng
%s+ tuple (execute(sql, (val,))), không bao giờ nối chuỗi (sql % val). Đã chứng minh: chuỗix' OR '1'='1khi tham số hoá chocount=0(an toàn), khi nối chuỗi chocount=1— lộ cả bảng. - Ghép câu động an toàn bằng
SQLcủaodoo.tools(Odoo 19):%scho giá trị,SQL.identifier(...)cho tên cột/bảng — thoát đúng cả hai. - SQL thô đi vòng qua ORM: nhớ
flush_all()trước khi đọc,invalidate_all()sau khi ghi; và ý thức rằng nó bỏ qua quyền, constraint, computed field — chỉ dùng khi ORM thật sự không đủ.
Đã xong nhóm bài về ruột của ORM. Lần sau chuyển sang một loại model đặc biệt dùng cho hộp thoại tương tác: Phần sau nói về TransientModel — model "tạm" đứng sau mọi wizard trong Odoo, dữ liệu tự dọn sau một thời gian.