Nếu chỉ được học một khái niệm về ORM của Odoo, hãy học cái này: recordset. Nó là thứ khiến code Odoo trông khác hẳn code Python/Django thông thường, và hiểu nhầm nó là nguồn gốc của phần lớn lỗi người mới. Mở đầu chương ORM, ta đi thẳng vào nó. (Bạn có thể ôn lại odoo shell để tự thử theo.)
Ý tưởng cốt lõi: MỌI thứ là một tập
Trong nhiều framework, Partner.objects.get(id=1) trả về một object, còn .filter(...) trả về một danh sách object. Odoo không phân biệt như vậy. Trong Odoo, mọi thứ đều là recordset — một tập bản ghi — dù tập đó có 0, 1, hay 500 phần tử. Không có "object một bản ghi" riêng biệt.

Hình 1: Recordset có ba "hình dạng" — rỗng (0), một (1), nhiều (N) — nhưng cùng một kiểu. Tập rỗng là falsy (dùng thẳng trong if). Tập một phần tử vẫn là tập (không phải object). Và recordset hành xử như một tập hợp Python: mapped, filtered, các phép hợp |, giao &, hiệu -, chỉ số [0], len(), vòng for.
Nhìn tận mắt trên dữ liệu thật
Đây là output thật từ odoo shell trên db blog19, cho thấy đủ ba hình dạng:

Hình 2: Output thật. search([...]) trả về res.partner(15, 24, 6, 1, 10, 23) — một recordset 6 bản ghi, in ra kèm các id. Tìm không thấy gì thì được res.partner() rỗng (bool = False). browse(1) cho tập một phần tử. Và các phép tập hợp — mapped('name'), filtered(...), hợp |, chỉ số [0] — đều chạy thật, trả đúng kết quả.
Ba hệ quả bạn phải nhớ
1. Tập rỗng là False — kiểm tra rất gọn
partner = env['res.partner'].search([('email', '=', email)], limit=1)
if partner: # tập rỗng là falsy, không cần "is not None"
partner.send_welcome()
else:
_logger.info("Không tìm thấy đối tác")
search luôn trả về recordset (không bao giờ None), nên if partner: là cách đúng để hỏi "có tìm thấy không".
2. Truy cập trường chỉ hợp lệ trên tập MỘT
mot.name chạy tốt vì mot có đúng 1 bản ghi. Nhưng nhieu.name (tập 6) sẽ báo lỗi — Odoo không biết trả tên của bản ghi nào. Với tập nhiều, phải duyệt hoặc dùng mapped('name'). Đây là lỗi kinh điển của người mới, và là lý do có ensure_one() (bài sau).
3. Recordset mang theo env
Mỗi recordset không chỉ là "danh sách id" — nó gắn với env (môi trường: người dùng hiện tại, công ty, con trỏ CSDL). Nhờ vậy từ một recordset bạn gọi tiếp ORM được: partner.env['sale.order'].search(...), hay partner.with_company(cid). Hai recordset cùng model nhưng khác env là khác nhau.
Vì sao thiết kế vậy lại hay?
- Thao tác hàng loạt tự nhiên:
orders.action_confirm()xác nhận cả tập; Odoo tự lặp. Không cần viết vòngforở mọi nơi. - Ghi hàng loạt một câu:
partners.write({'active': False})cập nhật mọi bản ghi trong tập bằng một lệnh SQL. - Gộp/lọc như toán tập hợp:
(nhom_a | nhom_b).filtered(...)— biểu đạt gọn, đọc như tiếng Anh.
Chính vì "một" và "nhiều" cùng kiểu, code Odoo viết một lần chạy được cho mọi cỡ tập — đó là sức mạnh, nhưng cũng là cái bẫy nếu bạn quên mình đang cầm tập mấy phần tử.
Nhớ ba ý
- Trong Odoo mọi thứ là recordset — tập bản ghi 0/1/N phần tử, cùng một kiểu; không có "object đơn" riêng.
- Tập rỗng là falsy (
if rs:để kiểm tra tìm thấy); truy cập trường như.namechỉ hợp lệ trên tập một phần tử, tập nhiều phải duyệt hoặcmapped. - Recordset hành xử như tập hợp (
mapped,filtered,|,&,-,[0],len) và mang theoenvđể gọi tiếp ORM.
Đã biết recordset là gì, bước tiếp là thao tác thành thạo với nó. Phần sau đi vào duyệt recordset, browse, và ensure_one — cách lấy đúng một bản ghi và tránh lỗi "truy cập trường trên tập nhiều".