Trong quá trình làm việc với MyBatis, một trong những thử thách phổ biến nhất đối với lập trình viên là xử lý quan hệ một-nhiều (One-to-Many). Vấn đề thường gặp nhất là thay vì nhận về một đối tượng cha chứa danh sách các đối tượng con, kết quả trả về lại bị phân tách thành nhiều đối tượng cha trùng lặp, mỗi đối tượng chỉ chứa một phần tử con.
Nguyên nhân cốt lõi của vấn đề
Hiện tượng này thường xuất phát từ việc cấu hình resultMap không chính xác hoặc do sự xung đột tên cột khi thực hiện phép nối bảng (JOIN). Khi sử dụng các lệnh INNER JOIN hoặc LEFT JOIN, nếu các bảng có những cột trùng tên mà không được đặt bí danh (alias) rõ ràng, MyBatis có thể ánh xạ nhầm dữ liệu hoặc không thể phân biệt được ranh giới giữa các bản ghi cha.
MyBatis dựa vào thẻ <id> trong resultMap để xác định tính duy nhất của đối tượng. Nếu thông tin này bị thiếu hoặc ánh xạ tới một cột có giá trị không phân biệt được giữa các dòng, cơ chế gộp kết quả (grouping) sẽ thất bại.
Ví dụ thực tế và cách triển khai
Giả sử chúng ta có hai thực thể: Customer (Khách hàng) và Invoice (Hóa đơn). Một khách hàng có thể sở hữu nhiều hóa đơn.
1. Định nghĩa lớp Entity
public class Customer {
private Integer customerId;
private String fullName;
private String contactEmail;
private List<Invoice> invoiceList;
// Getters và Setters
public Integer getCustomerId() { return customerId; }
public void setCustomerId(Integer customerId) { this.customerId = customerId; }
public String getFullName() { return fullName; }
public void setFullName(String fullName) { this.fullName = fullName; }
public List<Invoice> getInvoiceList() { return invoiceList; }
public void setInvoiceList(List<Invoice> invoiceList) { this.invoiceList = invoiceList; }
// ... các trường khác
}
2. Cấu hình ResultMap và SQL
Để tránh xung đột, chúng ta nên đặt alias cho các cột trong câu lệnh SQL và ánh xạ chúng tương ứng trong resultMap.
<resultMap id="CustomerWithInvoicesMap" type="com.example.entity.Customer">
<!-- Định danh chính cho thực thể cha -->
<id property="customerId" column="c_id" />
<result property="fullName" column="c_name" />
<result property="contactEmail" column="c_email" />
<!-- Ánh xạ danh sách thực thể con -->
<collection property="invoiceList" ofType="com.example.entity.Invoice">
<id property="invoiceId" column="i_id" />
<result property="amount" column="i_amount" />
<result property="issueDate" column="i_date" />
</collection>
</resultMap>
<select id="findCustomerDetails" resultMap="CustomerWithInvoicesMap">
SELECT
t1.id AS c_id,
t1.name AS c_name,
t1.email AS c_email,
t2.id AS i_id,
t2.total_amount AS i_amount,
t2.created_at AS i_date
FROM customers t1
LEFT JOIN invoices t2 ON t1.id = t2.customer_id
WHERE t1.id = #{id}
</select>
Các quy tắc quan trọng để tránh sai sót
- Sử dụng thẻ <id> bắt buộc: Trong cả
resultMapchính và thẻ<collection>, bạn phải xác định rõ cột nào đóng vai trò là ID. Đây là chìa khóa để MyBatis thực hiện gom nhóm các dòng dữ liệu từ kết quả JOIN vào một đối tượng duy nhất. - Định danh cột rõ ràng: Luôn sử dụng alias (
AS alias_name) trong SQL khi truy vấn từ nhiều bảng. Điều này ngăn chặn việc MyBatis lấy nhầm giá trị của các cột trùng tên (ví dụ: cộtidcó ở cả hai bảng). - Kiểm tra kết quả rỗng: Khi sử dụng
LEFT JOIN, nếu một khách hàng không có hóa đơn nào, danh sáchinvoiceListcó thể chứa một đối tượng rỗng với tất cả các thuộc tính là null tùy thuộc vào cách cấu hình. Cần lưu ý xử lý logic này ở tầng ứng dụng. - Phân biệt giữa kết quả trả về: Nếu bạn nhận được nhiều đối tượng cha giống hệt nhau, hãy kiểm tra lại xem cột được chỉ định trong thẻ
<id>của thực thể cha có xuất hiện trong danh sáchSELECTcủa SQL hay không. Nếu cột đó không có trong tập kết quả, MyBatis sẽ không thể định danh và gộp các bản ghi lại với nhau.
Việc nắm vững cơ chế ánh xạ của resultMap không chỉ giúp mã nguồn sạch sẽ hơn mà còn tối ưu hóa hiệu suất ứng dụng bằng cách giảm thiểu số lượng truy vấn (tránh lỗi N+1) trong khi vẫn đảm bảo tính chính xác của dữ liệu.