Tài liệu tham khảo: Các phương thức ORM trong Odoo 17
Tài liệu giải thích chi tiết các phương thức ORM (Object-Relational Mapping) thường dùng nhất trong Odoo 17: lý thuyết, khi nào dùng, tại sao dùng, và ví dụ cụ thể có giải thích.
Mọi ví dụ giả định ta đang viết trong một model kế thừa
models.Model, ví dụlibrary.book.
Mục lục
- Khái niệm nền tảng cần nắm trước
- Nhóm CRUD – Ghi dữ liệu
- Nhóm hỗ trợ tạo bản ghi
- Nhóm Truy vấn & Đọc dữ liệu
- Nhóm Metadata & Kiểm tra
- Nhóm Xử lý recordset trong bộ nhớ (in-memory)
- Nhóm View
- Bảng tổng hợp nhanh
- Best practices chung
0. Khái niệm nền tảng
Trước khi đi vào từng phương thức, cần nắm các khái niệm cốt lõi vì chúng quyết định việc khi nào và tại sao dùng một phương thức:
- Recordset (tập bản ghi): Mọi thao tác trong Odoo đều xoay quanh recordset – một "tập hợp" gồm 0, 1 hoặc nhiều bản ghi của cùng một model.
selftrong một method chính là một recordset. Một recordset có thể lặp (for rec in self). selfmang theo Environment: Recordset luôn gắn với mộtenvchứa:cr(con trỏ DB/cursor),uid(user hiện tại),context(dict ngữ cảnh),su(superuser hay không). Đây là lý do mọi method ORM "biết" được ai đang gọi và trong ngữ cảnh nào.- Cache (bộ nhớ đệm ORM): Khi bạn đã đọc dữ liệu, Odoo lưu trong cache. Truy cập lại field không bắn thêm SQL. Đây là nền tảng để hiểu vì sao
filtered/mapped/sorted(xử lý trên cache) nhanh hơn việc gọisearchnhiều lần (bắn SQL). - Domain (điều kiện lọc): Một list các tuple
(field, operator, value)dùng chosearch, ví dụ[('state', '=', 'done'), ('amount', '>', 100)]. Mặc định nối với nhau bằngAND. Toán tử logic dùng tiền tố Ba Lan:'|'(OR),'&'(AND),'!'(NOT). - Lệnh trì hoãn (lazy): Nhiều method ORM (search, browse...) không thực sự đọc tất cả dữ liệu ngay; ORM chỉ đọc khi field được truy cập (prefetch). Hiểu điều này giúp tránh tối ưu sai.
Quy ước về decorator hay gặp:
@api.model: method cấp model,selflà recordset rỗng (không cần record cụ thể) – ví dụdefault_get,name_create,search,_name_search.@api.model_create_multi: methodcreatenhận danh sách dict (batch).
Nhóm 1 — CRUD (Ghi dữ liệu)
1.1 create
Lý thuyết
create(vals) tạo mới hoàn toàn một (hoặc nhiều) bản ghi và lưu xuống database, trả về recordset của (các) bản ghi vừa tạo. Trong Odoo 17, chuẩn khuyến nghị là override với @api.model_create_multi, khi đó tham số là một list các dict (vals_list) để hỗ trợ tạo hàng loạt hiệu quả.
Khi nào dùng
- Khi cần thêm dữ liệu mới vào hệ thống (tạo đơn hàng, sản phẩm, khách hàng...).
- Khi muốn chèn logic nghiệp vụ chạy tại thời điểm tạo (gán mã tự động, set giá trị mặc định phức tạp, kích hoạt thông báo...).
Tại sao dùng
- Đây là cổng ghi duy nhất khi sinh bản ghi mới: nó kích hoạt
default_get, các ràng buộc@api.constrains, tính lại các computed field phụ thuộc, và ghi log audit (create_uid,create_date). - Việc override
createcho phép đảm bảo dữ liệu luôn hợp lệ ngay từ khi sinh ra.
Ví dụ có giải thích
# Gọi tạo một bản ghi
book = self.env['library.book'].create({
'name': 'Clean Code',
'author_id': author.id,
'price': 45.0,
})
# -> book là recordset chứa đúng 1 record vừa tạo, có book.id thật trong DB.
# Tạo nhiều bản ghi cùng lúc (batch) - hiệu quả hơn gọi create() trong vòng lặp
books = self.env['library.book'].create([
{'name': 'Book A', 'price': 10},
{'name': 'Book B', 'price': 20},
])
# -> books là recordset chứa 2 record.
Override để thêm logic – luôn dùng model_create_multi ở Odoo 17:
from odoo import api, models
class LibraryBook(models.Model):
_inherit = 'library.book'
@api.model_create_multi
def create(self, vals_list):
# vals_list là LIST các dict, kể cả khi chỉ tạo 1 record
for vals in vals_list:
if not vals.get('reference'):
# sinh mã tham chiếu tự động trước khi ghi DB
vals['reference'] = self.env['ir.sequence'].next_by_code('library.book')
records = super().create(vals_list) # gọi tạo thực sự
records._notify_librarian() # logic sau khi tạo
return records
Giải thích: Vòng lặp xử lý từng
valstrướcsuper().create()để dữ liệu được vá xong rồi mới ghi DB một lần. Việc gọisuper()đảm bảo cơ chế lõi (constraints, computed, audit) vẫn chạy. Sau khi có record thật mới gọi logic phụ.
Lưu ý: Không dùng
create({...})(dict đơn) khi đã override bằngmodel_create_multiở vị trí gọi nội bộ phức tạp – nhưng phía gọi vẫn được phép truyền dict đơn, Odoo tự bọc thành list.
1.2 write
Lý thuyết
write(vals) cập nhật các field cho toàn bộ record trong recordset hiện tại (self), ghi thay đổi xuống DB. Trả về True. Nó áp dụng cùng một dict thay đổi cho mọi record trong self.
Khi nào dùng
- Khi sửa dữ liệu của bản ghi đã tồn tại (đổi trạng thái, cập nhật giá, gán quan hệ...).
- Khi cần can thiệp logic mỗi lần dữ liệu thay đổi (audit, đồng bộ, validate khi chuyển trạng thái).
Tại sao dùng
writelà cổng cập nhật chuẩn: kích hoạt@api.constrains, tính lại computed field phụ thuộc, ghiwrite_uid/write_date.- Cập nhật theo batch: gọi
recordset.write()một lần cho nhiều record tiết kiệm hơn nhiều so với gán field từng record trong vòng lặp.
Ví dụ có giải thích
# Cập nhật 1 record
book.write({'price': 50.0, 'state': 'available'})
# Cập nhật HÀNG LOẠT: mọi book trong recordset thành state 'archived'
books = self.env['library.book'].search([('price', '<', 5)])
books.write({'state': 'archived'}) # 1 câu UPDATE cho cả tập
Cú pháp đặc biệt cho field quan hệ One2many/Many2many (Command):
from odoo import Command
order.write({
'line_ids': [
Command.create({'product_id': p.id, 'qty': 2}), # thêm dòng mới
Command.unlink(old_line.id), # xóa dòng
Command.update(line.id, {'qty': 5}), # sửa dòng đang có
],
})
Override để chèn logic:
def write(self, vals):
if 'state' in vals and vals['state'] == 'done':
self._check_can_be_done() # validate trước khi đổi trạng thái
res = super().write(vals) # ghi thật
if 'price' in vals:
self._log_price_change(vals['price'])
return res
Giải thích: Kiểm tra
'state' in valsđể chỉ chạy logic khi field đó thực sự bị đổi. So sánh giá trị mới với DB (quaself.priceđọc trướcsuper()) nếu cần giá trị cũ.
Khác biệt cốt lõi với
create:writeluôn cần một recordset đã tồn tại (selfcó id);createsinh id mới.
1.3 unlink
Lý thuyết
unlink() xóa vĩnh viễn các bản ghi trong recordset khỏi database (lệnh SQL DELETE). Trả về True.
Khi nào dùng
- Khi cần loại bỏ hẳn dữ liệu rác/sai mà không cần lưu vết.
- Khi dọn dữ liệu tạm, nháp (draft) bị hủy.
Tại sao dùng
- Là cách chuẩn để xóa, đồng thời tôn trọng các ràng buộc khóa ngoại (
ondelete), kích hoạt logic dọn dẹp liên quan, và kiểm tra quyềnunlink.
Lưu ý quan trọng
- Xóa không thể hoàn tác. Trong nhiều nghiệp vụ, người ta dùng archiving (
active = False) thay vìunlinkđể giữ lịch sử. unlinkcó thể bị chặn nếu record đang được tham chiếu bởi record khác vớiondelete='restrict', hoặc nếu record đã được xác nhận/đã khóa.
Ví dụ có giải thích
drafts = self.env['library.book'].search([('state', '=', 'draft')])
drafts.unlink() # xóa toàn bộ sách ở trạng thái nháp
Override để chặn xóa theo nghiệp vụ:
from odoo.exceptions import UserError
def unlink(self):
for rec in self:
if rec.state == 'done':
raise UserError("Không thể xóa bản ghi đã hoàn tất, hãy lưu trữ thay vì xóa.")
return super().unlink()
Giải thích: Duyệt từng record để validate, raise
UserError(sẽ rollback giao dịch) trước khi gọisuper().unlink(). Đây là chỗ phổ biến để thực thi chính sách "chỉ xóa nháp".
1.4 copy
Lý thuyết
copy(default=None) nhân bản một bản ghi hiện có thành một bản ghi hoàn toàn mới, trả về recordset của bản sao. Tham số default là dict ghi đè giá trị cho bản sao.
Khi nào dùng
- Khi người dùng bấm "Duplicate" trên form (Odoo gọi
copyngầm). - Khi cần tạo bản ghi mới gần giống một bản ghi mẫu (đơn hàng lặp, template...).
Tại sao dùng
- Tránh nhập lại thủ công; sao chép cả các dòng con (One2many) theo cấu hình.
- Cho phép kiểm soát field nào không được sao chép.
Kiến thức quan trọng — thuộc tính copy của field
- Mặc định, field thường được sao chép. Nhưng nếu khai báo
copy=Falsethì field đó sẽ bị bỏ qua khi copy (ví dụ số phiếu, mã sequence, trạng thái nên reset). copychỉ yêu cầu đúng 1 record (self.ensure_one()được gọi bên trong).
Ví dụ có giải thích
# Khai báo field không nên copy
class LibraryBook(models.Model):
_inherit = 'library.book'
reference = fields.Char(copy=False) # mã tham chiếu không sao chép
# Nhân bản, đồng thời ghi đè vài field cho bản sao
new_book = book.copy({
'name': book.name + ' (Copy)',
'state': 'draft',
})
Giải thích:
default={'name': ...}đảm bảo bản sao có tên khác để dễ phân biệt vàstateđược reset vềdraft. Fieldreferenceđã đặtcopy=Falsenên bản sao sẽ trống/được sinh lại, tránh trùng mã.
Override để tùy biến hành vi sao chép:
def copy(self, default=None):
default = dict(default or {})
default.setdefault('name', f"{self.name} (Bản sao)")
return super().copy(default)
Giải thích:
dict(default or {})tạo bản sao an toàn để không sửa nhầm dict của người gọi;setdefaultchỉ đặt tên nếu người gọi chưa truyềnname.
Nhóm 2 — Hỗ trợ tạo bản ghi
2.1 default_get
Lý thuyết
default_get(fields_list) trả về một dict các giá trị mặc định cho danh sách field truyền vào, dùng khi mở form tạo mới. Đây là nơi Odoo gom giá trị mặc định từ: thuộc tính default= của field, context (default_<field>), và logic động bạn tự viết.
Khi nào dùng
- Khi muốn đặt giá trị mặc định động/phức tạp mà không thể biểu diễn bằng
default=tĩnh (phụ thuộc user, công ty, context, ngày hiện tại, bản ghi liên quan...).
Tại sao dùng
default=lambda self: ...chỉ hợp cho một field;default_getcho phép tính nhiều field cùng lúc với logic chung và đọc context.- Là điểm chuẩn để khởi tạo form theo ngữ cảnh (ví dụ mở tạo dòng từ một đơn cha cụ thể).
Ví dụ có giải thích
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list) # lấy mặc định gốc trước
# Mặc định gán thủ thư là người đang đăng nhập
if 'librarian_id' in fields_list:
res['librarian_id'] = self.env.user.id
# Nếu mở form từ một danh mục cụ thể qua context
if 'category_id' in fields_list and self.env.context.get('default_category_id'):
res['category_id'] = self.env.context['default_category_id']
return res
Giải thích: Luôn gọi
super().default_get()đầu tiên để giữ các mặc định lõi, rồi bổ sung/ghi đè. Kiểm trafield in fields_listđể tránh tính field mà form không yêu cầu. Việc đọccontext['default_category_id']cho thấy cách Odoo truyền mặc định qua action.
Mẹo: Bạn có thể truyền mặc định mà không cần override bằng cách dùng context
{'default_<field>': value}khi mở action.default_getmới cần khi logic phức tạp.
2.2 name_create
Lý thuyết
name_create(name) là phương thức quick-create: chỉ nhận một chuỗi và tạo ngay một bản ghi, trả về tuple (id, display_name). Mặc định nó gán chuỗi đó vào field name (hoặc field _rec_name).
Khi nào dùng
- Khi người dùng gõ một giá trị mới vào ô Many2one và chọn "Create '<text>'" – Odoo gọi
name_createđể tạo nhanh bản ghi liên kết mà không mở form.
Tại sao dùng
- Tăng tốc nhập liệu: tạo đối tượng phụ (tag, danh mục, tác giả...) ngay trong luồng làm việc.
- Cho phép can thiệp để điền thêm field bắt buộc khi quick-create.
Ví dụ có giải thích
@api.model
def name_create(self, name):
# Mặc định chỉ set 'name'. Ở đây ta tách "Tên - Mã" để điền thêm field.
if ' - ' in name:
real_name, code = name.split(' - ', 1)
record = self.create({'name': real_name.strip(), 'code': code.strip()})
return record.id, record.display_name
return super().name_create(name)
Giải thích: Khi người dùng gõ
"Văn học - VH01", ta tách thànhnamevàcoderồicreate. Trả về(id, display_name)đúng định dạng giao diện cần. Nếu không khớp định dạng, gọisuper()để giữ hành vi mặc định.
Lưu ý: Nếu model có field bắt buộc khác
name, quick-create sẽ lỗi trừ khi bạn overridename_createđể điền đủ – hoặc đặt_rec_namevà để các field kia có default.
Nhóm 3 — Truy vấn & Đọc dữ liệu
3.1 browse
Lý thuyết
browse(ids) chuyển đổi id (hoặc list id) thành recordset mà không bắn SQL ngay. Nó chỉ tạo "tay cầm" trỏ tới record; dữ liệu chỉ được đọc khi bạn truy cập field.
Khi nào dùng
- Khi bạn đã biết id (từ API bên ngoài, từ tham số, từ kết quả khác) và cần biến nó thành recordset để thao tác ORM.
Tại sao dùng
- Cực rẻ: không truy vấn cho tới khi cần. Cho phép gom prefetch hiệu quả.
- Là cách đúng đắn để "hồi sinh" id thành đối tượng ORM thay vì viết SQL.
Ví dụ có giải thích
book = self.env['library.book'].browse(42) # chưa có SQL nào chạy
print(book.name) # ĐẾN ĐÂY mới SELECT từ DB
books = self.env['library.book'].browse([1, 2, 3]) # recordset 3 record
Giải thích:
browse(42)chỉ tạo recordset trỏ id 42. Khi đọcbook.name, ORM mới SELECT (và prefetch luôn các field/record lân cận để tối ưu).
Cảnh báo:
browsekhông kiểm tra tồn tại. Nếu id không có thật, recordset vẫn tạo được nhưng đọc field sẽ lỗiMissingError. Khi nghi ngờ, dùng kèmexists():book = self.env['...'].browse(id).exists().
3.2 search
Lý thuyết
search(domain, offset=0, limit=None, order=None) tìm và trả về recordset các bản ghi thỏa domain (điều kiện). Đây là method truy vấn cốt lõi nhất.
Khi nào dùng
- Khi cần lấy các bản ghi theo điều kiện và sẽ thao tác chúng như đối tượng ORM (gọi method, đọc/ghi field, đi theo quan hệ).
Tại sao dùng
- Trả về recordset đầy đủ năng lực ORM (khác
search_readchỉ trả dict). - Hỗ trợ phân trang (
offset,limit) và sắp xếp (order). - Tự động áp record rules (an ninh theo dòng) — chỉ trả record user được phép thấy.
Ví dụ có giải thích
# Domain cơ bản (các điều kiện ngầm AND)
books = self.env['library.book'].search([
('state', '=', 'available'),
('price', '>', 20),
])
# Toán tử OR (tiền tố): (state = available) OR (price < 5)
books = self.env['library.book'].search(
['|', ('state', '=', 'available'), ('price', '<', 5)]
)
# Phân trang + sắp xếp: 10 record, bỏ 20 record đầu, sắp theo giá giảm dần
page = self.env['library.book'].search(
[], offset=20, limit=10, order='price desc'
)
# Lấy record mới nhất
last = self.env['library.book'].search([], order='create_date desc', limit=1)
Giải thích:
'|'đứng trước hai điều kiện mà nó nối (ký pháp Ba Lan).search([])với domain rỗng nghĩa là "tất cả" (vẫn lọc theo quyền).limit=1+orderlà mẫu chuẩn để lấy bản ghi đầu/cuối.
Lưu ý hiệu năng: Nếu chỉ cần đếm, dùng
search_count. Nếu chỉ cần đọc một vài field để hiển thị, dùngsearch_read. Đừngsearchrồi lặp đọc nếu có cách gọn hơn.
3.3 search_count
Lý thuyết
search_count(domain) trả về số nguyên — số bản ghi thỏa domain, không tải dữ liệu record.
Khi nào dùng
- Khi chỉ cần biết "có bao nhiêu", "có tồn tại record nào không".
Tại sao dùng
- Nhanh và nhẹ: chạy
SELECT COUNT(*)thay vì kéo recordset về rồilen(). - Tránh lãng phí bộ nhớ và prefetch khi không cần dữ liệu.
Ví dụ có giải thích
n = self.env['library.book'].search_count([('state', '=', 'borrowed')])
# -> ví dụ 17 (int)
# Kiểm tra tồn tại theo cách rẻ
if self.env['library.book'].search_count([('isbn', '=', isbn)], limit=1):
raise UserError("ISBN đã tồn tại")
Giải thích: So với
len(search(domain))(kéo toàn bộ record về rồi đếm),search_countchỉ chạy COUNT trên DB → rẻ hơn nhiều với tập lớn. Mẫusearch_count(domain, limit=1)là cách kiểm tra "tồn tại hay không" hiệu quả (Odoo 17 cho phéplimit).
3.4 _name_search
Lý thuyết
_name_search(name, args=None, operator='ilike', limit=100, ...) là phần lõi đứng sau cơ chế tìm kiếm của field Many2one (ô gõ để chọn record). Mặc định nó tìm theo field _rec_name/name. Override để tùy biến logic tìm (cho phép tìm theo nhiều field).
Khi nào dùng
- Khi muốn ô Many2one tìm được record theo nhiều thuộc tính (ví dụ gõ mã hoặc tên đều ra), hoặc lọc theo ngữ cảnh.
Tại sao dùng
- Trải nghiệm tìm kiếm tốt hơn: người dùng nhập mã ISBN, mã nội bộ, hoặc tên — đều khớp.
- Là điểm móc chuẩn (thay cho
name_get/name_searchcũ) trong Odoo 17.
Ví dụ có giải thích
from odoo import api, models
class LibraryBook(models.Model):
_inherit = 'library.book'
@api.model
def _name_search(self, name, domain=None, operator='ilike', limit=None, order=None):
domain = domain or []
if name:
# Cho phép tìm theo TÊN hoặc ISBN cùng lúc
domain = ['|', ('name', operator, name),
('isbn', operator, name)] + domain
return self._search(domain, limit=limit, order=order)
Giải thích: Khi người dùng gõ vào ô chọn sách, Odoo gọi
_name_searchvớinamelà chuỗi gõ. Ta mở rộng domain để khớpnameHOẶCisbn._search(...)(gạch dưới) trả về tập id ở mức thấp mà cơ chế hiển thị cần. Lưu ý chữ ký method ở Odoo 17 dùngdomain(không phảiargsnhư bản cũ).
Liên quan: display text hiển thị do
display_name/_compute_display_namequyết định;_name_searchchỉ lo tìm.
3.5 read
Lý thuyết
read(fields=None, load='_classic_read') đọc dữ liệu từ một recordset đã có và trả về list các dict (dạng JSON-friendly), mỗi dict ứng một record với các field yêu cầu. Many2one trả về dạng (id, display_name).
Khi nào dùng
- Khi cần serialize dữ liệu để gửi ra ngoài (RPC, API, JSON), hoặc cần một snapshot dạng dict.
Tại sao dùng
- Trả về cấu trúc thuần (list/dict) dễ chuyển JSON, thay vì đối tượng recordset.
- Đọc nhiều field cùng lúc trong một lượt.
Ví dụ có giải thích
books = self.env['library.book'].browse([1, 2])
data = books.read(['name', 'price', 'author_id'])
# data = [
# {'id': 1, 'name': 'A', 'price': 10.0, 'author_id': (5, 'Tác giả X')},
# {'id': 2, 'name': 'B', 'price': 20.0, 'author_id': (7, 'Tác giả Y')},
# ]
Giải thích:
readcần một recordset có sẵn (browse/searchtrước). Many2oneauthor_idtrả về tuple(id, display_name)— rất tiện cho giao diện. Nếu bỏ trốngfields, đọc tất cả field (nặng, nên hạn chế).
So sánh:
readthao tác trên recordset đã có; nếu bạn xuất phát từ domain và muốn dict luôn, dùngsearch_readđể gộp một bước.
3.6 search_read
Lý thuyết
search_read(domain, fields, offset, limit, order) là gộp của search + read: truy vấn theo domain và trả thẳng list dict, không cần tạo recordset trung gian.
Khi nào dùng
- Khi cần lấy dữ liệu để hiển thị/xuất (bảng, biểu đồ, API) mà không cần thao tác ORM tiếp theo trên các record đó.
Tại sao dùng
- Hiệu quả hơn
search()rồiread(): tối ưu thành ít truy vấn, tránh chi phí dựng recordset/prefetch thừa. - Là method rất hay dùng cho frontend/JS-RPC.
Ví dụ có giải thích
rows = self.env['library.book'].search_read(
domain=[('state', '=', 'available')],
fields=['name', 'price'],
order='price desc',
limit=20,
)
# rows = [{'id': .., 'name': .., 'price': ..}, ...]
Giải thích: Một lệnh trả ra ngay list dict đã lọc + sắp xếp + giới hạn. So với
search().read()(hai bước, dựng recordset rồi mới đọc),search_readgọn và nhanh hơn khi đích đến chỉ là dữ liệu thô.
Khi KHÔNG dùng: Nếu sau đó bạn cần gọi method nghiệp vụ trên các record (vd
record.action_confirm()), hãy dùngsearchđể giữ recordset.
3.7 read_group
Lý thuyết
read_group(domain, fields, groupby, ...) là truy vấn tối ưu cho gom nhóm (GROUP BY) và tổng hợp (sum, count, avg, min, max). Trả về list dict, mỗi dict đại diện một nhóm cùng các giá trị tổng hợp.
Khi nào dùng
- Khi cần báo cáo/thống kê: tổng doanh thu theo tháng, số sách theo danh mục, giá trung bình theo tác giả...
- Là động cơ phía sau các view pivot, graph, và nhóm trong list view.
Tại sao dùng
- Đẩy phép tổng hợp xuống tận DB (PostgreSQL) → nhanh hơn rất nhiều so với kéo hết record về Python rồi tự cộng.
- Hỗ trợ gom theo field thời gian (theo ngày/tháng/năm qua
:month).
Ví dụ có giải thích
# Tổng và đếm giá sách theo từng danh mục
result = self.env['library.book'].read_group(
domain=[('state', '=', 'available')],
fields=['price:sum', 'price:avg'], # hàm tổng hợp trên field price
groupby=['category_id'],
)
# result = [
# {'category_id': (1, 'Văn học'), 'price': 320.0, 'price_avg': 40.0,
# 'category_id_count': 8, '__domain': [...]},
# ...
# ]
# Gom theo tháng của ngày xuất bản
by_month = self.env['library.book'].read_group(
domain=[],
fields=['price:sum'],
groupby=['publish_date:month'],
)
Giải thích: Cú pháp
'<field>:<aggregate>'(vdprice:sum) yêu cầu DB tính tổng. Khóa<groupby>_countcho biết số record mỗi nhóm.__domaincủa mỗi nhóm rất hữu ích: bấm vào nhóm trên UI để "drill-down" xuống đúng các record đó. Hậu tố:monthgom theo tháng cho field ngày.
Lưu ý: Field trong
fieldsđể tổng hợp phải là kiểu số (hoặc dùng đếm). Trong Odoo 17,read_groupvẫn là API ổn định cho aggregation phía server.
3.8 fetch (mới từ Odoo 17)
Lý thuyết
fetch(field_names) là method mới giới thiệu từ Odoo 17, dùng để nạp trước (load) giá trị các field vào cache cho recordset hiện tại — nhưng không trả về list dict như read. Nó trả về chính recordset (đã có dữ liệu trong cache) / không trả dữ liệu dạng serialize.
Khi nào dùng
- Khi muốn chủ động prefetch một nhóm field cụ thể vào cache trước khi xử lý, để tránh các truy vấn lẻ tẻ về sau.
- Khi cần dữ liệu trong Python để tính toán (chứ không phải để serialize ra ngoài như
read).
Tại sao dùng
readtạo ra list dict (tốn công serialize, đặc biệt format Many2one thành tuple). Nếu bạn chỉ cần giá trị nằm sẵn trong recordset để truy cập field bình thường,fetchnhẹ và đúng mục đích hơn.- Kiểm soát rõ ràng thời điểm nạp dữ liệu (tối ưu vòng lặp lớn).
Ví dụ có giải thích
books = self.env['library.book'].search([('state', '=', 'available')])
# Nạp sẵn 'price' và 'page_count' cho toàn bộ tập trong 1 truy vấn
books.fetch(['price', 'page_count'])
# Từ đây truy cập các field này KHÔNG bắn thêm SQL (đã ở cache)
total = sum(b.price for b in books)
Giải thích: Sau
fetch(['price', 'page_count']), ORM đã SELECT các cột này cho mọi record một lượt. Vòng lặp đọcb.pricechạy hoàn toàn trên cache. Khácreadở chỗ ta vẫn làm việc với recordset (b.price) chứ không phải dict.
Phân biệt nhanh:
read→ trả về list dict để xuất ra ngoài.fetch→ nạp vào cache để dùng tiếp trong Python với cú pháp recordset.
Nhóm 4 — Metadata & Kiểm tra
4.1 fields_get
Lý thuyết
fields_get(allfields=None, attributes=None) trả về metadata của các field: kiểu dữ liệu (type), nhãn (string), bắt buộc (required), chỉ đọc (readonly), lựa chọn (selection), model quan hệ (relation)...
Khi nào dùng
- Khi viết code động/đa hình cần biết cấu trúc field: tự sinh form, export, validate, công cụ tích hợp.
- Khi UI (web client) cần mô tả field để render.
Tại sao dùng
- Cho phép viết logic không phụ thuộc cứng vào model cụ thể (introspection).
- Lấy được nhãn/selection để hiển thị thân thiện.
Ví dụ có giải thích
meta = self.env['library.book'].fields_get(['state', 'author_id'])
# meta = {
# 'state': {'type': 'selection', 'string': 'Trạng thái',
# 'selection': [('draft','Nháp'), ('available','Sẵn có')], ...},
# 'author_id': {'type': 'many2one', 'string': 'Tác giả',
# 'relation': 'res.partner', ...},
# }
# Chỉ lấy vài thuộc tính để nhẹ
labels = self.env['library.book'].fields_get(attributes=['string', 'type'])
Giải thích: Với
author_idta biết đượcrelation='res.partner'để biết nó trỏ tới model nào. Vớistateta lấy được danh sáchselectionđể map giá trị → nhãn hiển thị. Lọcattributesgiúp giảm khối lượng dữ liệu trả về.
4.2 exists
Lý thuyết
exists() lọc recordset, chỉ giữ lại những record thực sự còn tồn tại trong DB. Trả về recordset (có thể rỗng).
Khi nào dùng
- Khi recordset có thể chứa id đã bị xóa (do người khác xóa, do
browseid không chắc chắn, do thao tác bất đồng bộ). - Như một "trạm kiểm tra" trước khi thao tác để tránh
MissingError.
Tại sao dùng
- Ngăn lỗi khi truy cập record đã biến mất.
- Phổ biến trong các method chạy nền/queue nơi record có thể đã bị xóa giữa chừng.
Ví dụ có giải thích
book = self.env['library.book'].browse(book_id).exists()
if not book:
return # id không còn tồn tại -> thoát an toàn
book.action_borrow()
# Lọc bỏ các record đã bị xóa khỏi một tập
valid_books = books.exists()
Giải thích:
browse(book_id)không kiểm tra tồn tại; nối.exists()sẽ chạy truy vấn nhẹ và trả về recordset rỗng nếu id không còn. Nhờ đóif not bookxử lý được trường hợp đã bị xóa mà không nổ lỗi.
4.3 ensure_one
Lý thuyết
ensure_one() bắt buộc recordset hiện tại phải có đúng 1 record. Nếu có 0 hoặc nhiều hơn 1 → ném ValueError (Singleton Error). Trả về chính recordset đó.
Khi nào dùng
- Ở đầu các method/logic chỉ có ý nghĩa với một record (truy cập trực tiếp
self.field, mở wizard cho 1 đối tượng...).
Tại sao dùng
- Truy cập
self.pricetrên recordset nhiều record sẽ gây lỗi/khó lường.ensure_one()biến lỗi mơ hồ thành thông báo rõ ràng, sớm. - Bảo vệ giả định "đơn lẻ" của code.
Ví dụ có giải thích
def action_print_label(self):
self.ensure_one() # đảm bảo chỉ 1 sách được chọn
return self._render_label(self.name, self.isbn)
Giải thích: Khi nút được bấm trên đúng 1 form,
selfcó 1 record vàensure_one()qua êm. Nếu method bị gọi nhầm trên nhiều record (vd hành động hàng loạt), nó báo lỗi ngay thay vì lặng lẽ chỉ lấy record đầu — giúp lộ bug sớm.
4.4 get_metadata
Lý thuyết
get_metadata() trả về metadata cốt lõi của (các) record: người tạo (create_uid), ngày tạo (create_date), người sửa cuối (write_uid), ngày sửa cuối (write_date), và XML ID (xmlid) cùng thông tin liên quan. Trả về list dict (một dict mỗi record).
Khi nào dùng
- Khi cần thông tin audit/truy vết của bản ghi (ai tạo, khi nào, ai sửa).
- Khi cần biết XML ID của record (rất hữu ích để export data, viết migration, hoặc tham chiếu trong code).
Tại sao dùng
- Tập trung lấy nhanh thông tin "lý lịch" record mà UI hay hiển thị ở nút "i" (info).
Ví dụ có giải thích
info = book.get_metadata()
# info = [{
# 'id': 42,
# 'create_uid': (2, 'Admin'),
# 'create_date': '2026-01-10 08:00:00',
# 'write_uid': (5, 'Thủ thư A'),
# 'write_date': '2026-05-30 14:22:00',
# 'xmlid': 'library_data.book_clean_code', # nếu có external id
# 'noupdate': False,
# }]
Giải thích:
xmlidcho biết external identifier nếu record được nạp từ data file — cực kỳ quan trọng khi viết module/migration để tham chiếu ổn định. Các fieldcreate_*/write_*cho biết lịch sử chỉnh sửa cơ bản.
Nhóm 5 — Xử lý recordset trong bộ nhớ
Điểm chung quan trọng: Cả 4 method dưới (
filtered,mapped,sorted,grouped) hoạt động trên recordset đã tải, dùng cache, không bắn thêm SQL (miễn là field đã có trong cache). Đây là lý do chúng giúp tối ưu: thay vì gọisearchnhiều lần, ta tải một lần rồi xử lý trong RAM.
5.1 filtered
Lý thuyết
filtered(func) trả về recordset con gồm các record thỏa điều kiện. func có thể là một hàm/lambda trả về bool, hoặc một chuỗi tên field (lọc theo tính "truthy" của field, hỗ trợ đi sâu quan hệ bằng dấu chấm).
Khi nào dùng
- Khi đã có sẵn recordset và muốn lọc tiếp theo điều kiện mà không quay lại DB.
Tại sao dùng
- Tránh
searchthừa: nếu dữ liệu đã nằm trong tay, lọc trong bộ nhớ nhanh hơn và đỡ tải DB. - Cú pháp gọn, đọc dễ.
Ví dụ có giải thích
books = self.env['library.book'].search([]) # tải 1 lần
# Lọc bằng lambda
expensive = books.filtered(lambda b: b.price > 50)
# Lọc theo chuỗi field (giữ record có active = True)
active_books = books.filtered('active')
# Đi sâu quan hệ: giữ sách mà tác giả đang ở Việt Nam
vn_books = books.filtered(lambda b: b.author_id.country_id.code == 'VN')
Giải thích:
filtered('active')tương đươngfiltered(lambda b: b.active). Vìbooksđã tải, các phép lọc trên chạy trong RAM. Nếu thay bằng nhiều lệnhsearchkhác nhau trên cùng tập gốc, ta sẽ bắn thêm nhiều SQL không cần thiết.
Khi nên dùng
searchthay vìfiltered: Nếu tập gốc rất lớn và bạn chỉ cần một phần nhỏ,searchvới domain (lọc tại DB) lại tốt hơn vì không kéo cả tập về.filteredtối ưu khi bạn đã cần/đã có recordset đó.
5.2 mapped
Lý thuyết
mapped(func) duyệt recordset và trích xuất giá trị một field (hoặc kết quả một hàm), gom lại trả về. Với field thường → trả về list Python. Với field quan hệ → trả về recordset (đã tự gộp, loại trùng theo thứ tự).
Khi nào dùng
- Khi cần "rút" một cột giá trị ra khỏi recordset, hoặc "nhảy" qua quan hệ để gom tập record liên quan.
Tại sao dùng
- Viết gọn thay vì
forthủ công. mappedtrên field quan hệ trả về recordset đã hợp nhất — rất tiện để thao tác tiếp.
Ví dụ có giải thích
books = self.env['library.book'].search([])
prices = books.mapped('price') # -> [10.0, 20.0, ...] (list float)
names = books.mapped('name') # -> ['A', 'B', ...] (list str)
# Field quan hệ -> recordset gộp, KHÔNG trùng
authors = books.mapped('author_id') # recordset res.partner duy nhất
# Dùng hàm để biến đổi
labels = books.mapped(lambda b: f"{b.name} ({b.price}đ)")
# Đi sâu nhiều cấp
countries = books.mapped('author_id.country_id')
Giải thích:
books.mapped('author_id')gom toàn bộ tác giả của các sách thành một recordset không trùng — sau đó có thểauthors.mapped('email')để lấy mọi email. Đây là sức mạnh "đi xuyên quan hệ" củamapped. Với field thường, kết quả là list để dễsum(prices),max(prices)...
5.3 sorted
Lý thuyết
sorted(key=None, reverse=False) trả về recordset đã sắp xếp (bản mới), sắp theo key (tên field dạng chuỗi hoặc hàm). reverse=True để giảm dần. Sắp xếp diễn ra trong bộ nhớ.
Khi nào dùng
- Khi cần sắp xếp một recordset đã có theo tiêu chí (có thể là tiêu chí tính toán không nằm trong DB).
Tại sao dùng
- Linh hoạt hơn
ordercủasearchở chỗ có thể sort theo hàm/biểu thức phức tạp. - Tránh round-trip DB khi đã có dữ liệu.
Ví dụ có giải thích
books = self.env['library.book'].search([])
# Sắp theo field (chuỗi)
by_price = books.sorted('price') # tăng dần
by_price_desc = books.sorted('price', reverse=True)
# Sắp theo hàm phức tạp: theo độ dài tên rồi theo giá
custom = books.sorted(key=lambda b: (len(b.name), b.price))
Giải thích:
sorted('price')đơn giản và đủ cho phần lớn nhu cầu. Khi tiêu chí phức tạp (tuple nhiều khóa, giá trị suy diễn), dùngkey=lambda. Lưu ý: với tập rất lớn, sắp xếp tại DB quasearch(order=...)thường hiệu quả hơnsortedtrong RAM.
5.4 grouped (mới từ Odoo 17)
Lý thuyết
grouped(key) là tiện ích mới từ Odoo 17, gom recordset đang ở trong bộ nhớ theo một key (tên field dạng chuỗi hoặc hàm) và trả về dict: khóa là giá trị nhóm, value là recordset con thuộc nhóm đó.
Lưu ý phân biệt với
read_group:read_groupgom nhóm ở tầng DB và trả về aggregation (sum/count...).groupedgom trong RAM trên recordset có sẵn và trả về recordset theo nhóm (không tự tổng hợp).
Khi nào dùng
- Khi đã có recordset và muốn tổ chức lại thành các nhóm để xử lý từng nhóm bằng Python (không cần truy vấn lại).
Tại sao dùng
- Trước Odoo 17 phải tự viết vòng lặp dồn vào dict;
groupedlàm chuẩn hóa, gọn gàng. - Giữ value là recordset → tiếp tục dùng được mọi method ORM (
mapped,filtered,write...).
Ví dụ có giải thích
books = self.env['library.book'].search([])
# Gom theo field (chuỗi) -> dict {recordset_key: recordset_books}
by_category = books.grouped('category_id')
for category, group in by_category.items():
total = sum(group.mapped('price'))
print(category.display_name, '->', len(group), 'cuốn, tổng', total)
# Gom theo hàm tự định nghĩa
by_band = books.grouped(lambda b: 'đắt' if b.price > 50 else 'rẻ')
Giải thích: Với
grouped('category_id'), khóa của dict là record danh mục (recordset Many2one), value là recordset các sách thuộc danh mục đó — nên ngay trong vòng lặp tagroup.mapped('price')để tính tổng. Vì mọi thứ chạy trên cache, không có thêm SQL. Khi cần tổng hợp số liệu lớn hãy cân nhắcread_group(DB tính nhanh hơn); khi cần thao tác tiếp trên record thìgroupedtiện hơn.
Nhóm 6 — View
6.1 get_view (thay thế fields_view_get)
Lý thuyết
get_view(view_id=None, view_type='form', **options) trả về kiến trúc (architecture) của một view dưới dạng dữ liệu mà web client dùng để render: XML arch, danh sách model, các model con... Trong Odoo 17, get_view thay thế fields_view_get của các phiên bản cũ. Override để tùy biến XML view động từ Python backend.
Khi nào dùng
- Khi cần thay đổi cấu trúc view tùy theo ngữ cảnh/người dùng/dữ liệu mà không thể làm bằng XML tĩnh hay thuộc tính
attrs/invisible. - Ví dụ: thêm cột động, ẩn/hiện nhóm theo công ty, chèn nút phụ thuộc cấu hình.
Tại sao dùng
- Cho phép can thiệp cây XML trước khi gửi cho client → linh hoạt tối đa cho UI động.
- Là điểm móc chuẩn (đã đổi tên so với
fields_view_get) cần dùng đúng để tương thích Odoo 17.
Ví dụ có giải thích
from lxml import etree
from odoo import api, models
class LibraryBook(models.Model):
_inherit = 'library.book'
@api.model
def get_view(self, view_id=None, view_type='form', **options):
res = super().get_view(view_id=view_id, view_type=view_type, **options)
if view_type == 'form':
doc = etree.XML(res['arch']) # parse XML hiện có
# Ví dụ: đặt field 'isbn' thành readonly cho người không thuộc nhóm thủ thư
if not self.env.user.has_group('library.group_librarian'):
for node in doc.xpath("//field[@name='isbn']"):
node.set('readonly', '1')
res['arch'] = etree.tostring(doc, encoding='unicode') # ghi lại
return res
Giải thích: Ta gọi
super().get_view()để lấyarchgốc, parse bằnglxml, dùngxpathđể tìm nodefield name="isbn"và setreadonlyđộng theo quyền, rồi serialize ngược lạires['arch']. Đây chính là cách "sửa XML view từ Python". Nếu bạn từng thấy code cũ overridefields_view_get, ở Odoo 17 hãy chuyển sangget_view.
Lưu ý: Phần lớn nhu cầu ẩn/hiện đơn giản nên làm bằng XML (
invisible,groups,attrs); chỉ dùngget_viewkhi thực sự cần biến đổi cấu trúc động.
Bảng tổng hợp nhanh
| Method | Nhóm | Trả về | Có bắn SQL? | Mục đích một dòng |
|---|---|---|---|---|
create | CRUD | recordset mới | Có (INSERT) | Tạo bản ghi mới |
write | CRUD | True | Có (UPDATE) | Cập nhật bản ghi đã có |
unlink | CRUD | True | Có (DELETE) | Xóa vĩnh viễn |
copy | CRUD | recordset mới | Có (INSERT) | Nhân bản bản ghi |
default_get | Tạo | dict | Thường không | Lấy giá trị mặc định khi tạo |
name_create | Tạo | (id, name) | Có (INSERT) | Quick-create từ một chuỗi |
browse | Đọc | recordset | Không (lazy) | id → recordset |
search | Đọc | recordset | Có (SELECT id) | Tìm record theo domain |
search_count | Đọc | int | Có (COUNT) | Đếm record theo domain |
_name_search | Đọc | ids/recordset | Có | Tùy biến tìm cho Many2one |
read | Đọc | list[dict] | Có (nếu chưa cache) | Serialize recordset → dict |
search_read | Đọc | list[dict] | Có | search + read gộp |
read_group | Đọc | list[dict] | Có (GROUP BY) | Gom nhóm + tổng hợp tại DB |
fetch | Đọc | recordset | Có (SELECT) | Nạp field vào cache (Odoo 17+) |
fields_get | Meta | dict | Không | Metadata các field |
exists | Meta | recordset | Có (nhẹ) | Lọc record còn tồn tại |
ensure_one | Meta | recordset | Không | Bắt buộc đúng 1 record |
get_metadata | Meta | list[dict] | Có | Audit info + XML ID |
filtered | In-memory | recordset | Không* | Lọc trên cache |
mapped | In-memory | list/recordset | Không* | Trích field / xuyên quan hệ |
sorted | In-memory | recordset | Không* | Sắp xếp trong RAM |
grouped | In-memory | dict | Không* | Gom nhóm trong RAM (Odoo 17+) |
get_view | View | dict (arch) | Có | Lấy/sửa kiến trúc view động |
*Không bắn SQL với điều kiện các field cần dùng đã có trong cache; nếu chưa, ORM sẽ nạp lazy.
Best practices chung
-
Chọn đúng method theo mục đích đầu ra:
- Cần thao tác ORM tiếp →
search/browse(giữ recordset). - Cần dữ liệu thô để xuất/JS →
search_read/read. - Cần con số tổng hợp →
read_group(hoặcsearch_countđể đếm). - Cần giá trị trong Python để tính →
fetchrồi đọc field.
- Cần thao tác ORM tiếp →
-
Tránh truy vấn trong vòng lặp (N+1): Đừng
search/create/writetừng record trongfor. Hãy gom: mộtsearchrồifiltered/mapped, mộtcreate([...])batch, mộtrecordset.write({...}). -
Ưu tiên xử lý trên cache khi đã có recordset:
filtered/mapped/sorted/groupedrẻ hơn nhiều lệnhsearchlặp lại — nhưng nếu tập gốc khổng lồ và chỉ cần phần nhỏ, lọc tại DB bằng domain vẫn tốt hơn. -
Phòng thủ với
exists()vàensure_one(): Dùngexists()khi id có thể đã bị xóa (đặc biệt job nền); dùngensure_one()mở đầu các method "đơn lẻ". -
Tôn trọng decorator khi override:
create→@api.model_create_multi(nhận list);default_get/name_create/_name_search→@api.model. Luôn gọisuper()để giữ hành vi lõi. -
Dùng
Commandcho field quan hệ trongcreate/writethay cho cú pháp tuple cũ(0, 0, {...})để code rõ ràng hơn ở Odoo 17. -
Cập nhật tên method theo phiên bản: Ở Odoo 17 dùng
get_view(không phảifields_view_get), tận dụngfetchvàgroupedmới; tránh các API đã loại bỏ nhưname_get(thay bằng_compute_display_name).