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:

Ảnh chụp phiên odoo shell blog19 so sánh tham số hoá và injection, câu execute tham số hoá với name bằng phần trăm s trả về bộ ba id 1 tên VIP-0001 diem 120, dictfetchall trả về danh sách một từ điển, dùng SQL của Odoo 19 ghép câu động với diem lớn hơn hoặc bằng 100 và order by identifier diem trả về VIP-0001 120, với chuỗi độc hại x phẩy OR 1 bằng 1 thì bản tham số hoá cho count 0 vì coi cả chuỗi là một tên không khớp, còn bản nối chuỗi bị injection cho count 1 vì mệnh đề OR 1 bằng 1 lấy hết bảng, tổng bản ghi thật trong bảng là 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:

Ảnh chụp đoạn mã Python nền tối về gọi SQL thô an toàn, phần đầu dùng cr chấm execute với placeholder phần trăm s và tuple tham số kèm fetchall và dictfetchall, một dòng đỏ cảnh báo nối chuỗi bằng toán tử phần trăm là lỗ hổng injection đừng bao giờ làm, phần giữa import SQL từ odoo tools và ghép câu động với SQL identifier cho tên cột, phần cuối nhắc gọi flush_all trước khi SQL đọc và invalidate_all sau khi SQL ghi vì SQL thô đi vòng qua ORM

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 search an toàn hơn nhiều một SELECT tay.
  • Cẩn thận với UPDATE/DELETE thô: chúng không kích hoạt logic write/unlink, không ghi write_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ề

  1. 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ỗi x' OR '1'='1 khi tham số hoá cho count=0 (an toàn), khi nối chuỗi cho count=1 — lộ cả bảng.
  2. Ghép câu động an toàn bằng SQL của odoo.tools (Odoo 19): %s cho giá trị, SQL.identifier(...) cho tên cột/bảng — thoát đúng cả hai.
  3. 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.