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.

Sơ đồ recordset trong ORM Odoo mọi thứ là một tập bản ghi: không có object một bản ghi riêng chỉ có recordset với 0, 1 hay N phần tử; ba thẻ minh hoạ là tập rỗng res.partner mở đóng ngoặc với bool bằng False và len bằng 0, tập một res.partner 1 với chấm name trả giá trị, tập nhiều res.partner chứa các id 15 24 6 1 10 23 mà gọi chấm name sẽ lỗi vì phải duyệt; recordset hành xử như tập hợp Python với mapped tên trả list giá trị của trường trên mọi bản ghi, filtered trả recordset con thoả điều kiện, các phép hợp gạch đứng giao và hiệu trừ, lấy phần tử đầu bằng chỉ số 0, len đếm số bản ghi, vòng for duyệt; mỗi recordset còn mang theo env là môi trường gồm user company cursor nên gọi được ORM

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:

Output recordset thật trong odoo shell trên database blog19: gán nhieu bằng search res.partner với is_company bằng True trả về res.partner chứa sáu id 15 24 6 1 10 23 với len bằng 6 là tập sáu bản ghi; gán rong bằng search với tên KhongCoAi trả về res.partner rỗng với bool bằng False và len bằng 0 là tập rỗng falsy; gán mot bằng browse id 1 trả về res.partner 1 với len bằng 1 là tập một phần tử vẫn là tập và chấm name bằng Công ty TNHH Cà Phê Việt; phần phép tập hợp chạy thật gồm nhieu chấm mapped name lấy hai phần tử đầu ra danh sách hai tên, len của nhieu filtered lambda có city bằng 6, browse 1 hợp browse 6 ra res.partner 1 6, và nhieu chỉ số 0 chấm name ra Cà Phê Việt Chi nhánh Hà Nội

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òng for ở 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 ý

  1. 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.
  2. Tập rỗng là falsy (if rs: để kiểm tra tìm thấy); truy cập trường như .name chỉ hợp lệ trên tập một phần tử, tập nhiều phải duyệt hoặc mapped.
  3. Recordset hành xử như tập hợp (mapped, filtered, |, &, -, [0], len) và mang theo env để 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".