Quan hệ một-nhiều
Nội dung bài · 5 mục
- 1.Khái niệm
- 2.Ví dụ
- 3.Thử ngay
- 4.Lỗi hay gặp
- 5.Tóm tắt
Một đơn hàng có nhiều dòng hàng: 2 bút, 3 vở. Hai loại dữ liệu này nằm ở hai bảng, nối với nhau qua mã đơn hàng. Bài này khai báo quan hệ đó bằng JPA và lấy đơn hàng kèm các dòng hàng của nó.
Khái niệm
Quan hệ một-nhiều và khoá ngoại đã học ở bài Khoá ngoại của khoá SQL. Bài này khai báo chúng bằng class.
🧲 @OneToMany: annotation đặt trên field danh sách như Order.lines, để đi từ đơn hàng tới các dòng hàng của nó.
🦥 Lazy loading: cách JPA chỉ đọc danh sách liên quan từ database khi code dùng tới nó lần đầu.
Ví dụ
// OrderLine.java
package com.shop.api;
import jakarta.persistence.*;
@Entity
@Table(name = "order_lines")
class OrderLine {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private long productId;
private int quantity;
private long unitPrice;
protected OrderLine() {
}
public OrderLine(long productId, int quantity,
long unitPrice) {
this.productId = productId;
this.quantity = quantity;
this.unitPrice = unitPrice;
}
public Long getId() { return id; }
public long getProductId() { return productId; }
public int getQuantity() { return quantity; }
public long getUnitPrice() { return unitPrice; }
}// Order.java
package com.shop.api;
import jakarta.persistence.*;
import java.util.*;
@Entity
@Table(name = "orders")
class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private long customerId;
@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name = "order_id", nullable = false)
private List<OrderLine> lines = new ArrayList<>();
public Long getId() { return id; }
public long getCustomerId() { return customerId; }
public void setCustomerId(long customerId) {
this.customerId = customerId;
}
public List<OrderLine> getLines() { return lines; }
}// OrderRepository.java
package com.shop.api;
import java.util.Optional;
import org.springframework.data.jpa.repository.*;
interface OrderRepository
extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = "lines")
Optional<Order> findWithLinesById(long id);
}// OrdersController.java
package com.shop.api;
import java.util.Optional;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/orders")
class OrdersController {
private final OrderRepository orders;
public OrdersController(OrderRepository orders) {
this.orders = orders;
}
@GetMapping("/{id}")
public ResponseEntity<Order> getById(
@PathVariable long id) {
Optional<Order> order =
orders.findWithLinesById(id);
if (order.isEmpty()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(order.get());
}
}@OneToManytrênOrder.linescho biết một đơn có nhiều dòng.@JoinColumnchỉ ra cột khoá ngoạiorder_idnằm ở bảngorder_lines, trỏ về đơn hàng.nullable = falsekhớp vớiNOT NULLcủa cột đó.cascade = CascadeType.ALL: lưuOrderthì lưu luôn cácOrderLinetronglines.- Hai class tương ứng với bảng
orders,order_linescủa khoá SQL. Đơn chỉ lưucustomerId, dòng hàng lưuproductIdvàunitPricelà giá lúc bán, đúng như bài Chuẩn hoá. - JPA cần constructor không tham số khi đọc từ database, để
protectedlà đủ. @EntityGraph(attributePaths = "lines")bảo JPA đọc luôn các dòng hàng cùng đơn hàng. Câu SQL được sinh ra là mộtleft jointừorderssangorder_lines. ChữWithLinesgiữafindvàBychỉ để đặt tên, Spring Data bỏ qua.
Thử ngay
Giữ các file cũ, thêm các file của phần Ví dụ và migration
V3__create_orders.sql cạnh hai file trước:
CREATE TABLE orders (
id NUMBER GENERATED BY DEFAULT AS IDENTITY
PRIMARY KEY,
customer_id NUMBER NOT NULL
);
CREATE TABLE order_lines (
id NUMBER GENERATED BY DEFAULT AS IDENTITY
PRIMARY KEY,
order_id NUMBER NOT NULL
REFERENCES orders (id),
product_id NUMBER NOT NULL,
quantity NUMBER(10) NOT NULL,
unit_price NUMBER NOT NULL
);Rồi thêm controller sau để tạo một đơn mẫu có hai dòng hàng:
// SampleController.java
package com.shop.api;
import org.springframework.web.bind.annotation.*;
@RestController
class SampleController {
private final OrderRepository orders;
public SampleController(OrderRepository orders) {
this.orders = orders;
}
@PostMapping("/api/orders/sample")
public long createSample() {
Order order = new Order();
order.setCustomerId(1);
order.getLines().add(
new OrderLine(1, 2, 5000));
order.getLines().add(
new OrderLine(2, 3, 12000));
orders.save(order);
return order.getId();
}
}Chạy server (Flyway tự chạy V3), tạo đơn mẫu rồi đọc lại:
curl -i -X POST http://localhost:5000/api/orders/sample
curl -i http://localhost:5000/api/orders/1Rồi xoá dòng @EntityGraph(...) trong OrderRepository, chạy lại server và
gọi lại lần nữa.
Đoán trước khi chạy: bỏ @EntityGraph thì lines trong response là
null, mảng rỗng, hay vẫn đủ hai dòng? Log SQL có gì khác?
Xem kết quả
POST sample: 200, body là 1
Có @EntityGraph: 200 {"customerId":1,"id":1,
"lines":[{...},{...}]}
Bỏ @EntityGraph: 200 {"customerId":1,"id":1,
"lines":[{...},{...}]}Vẫn đủ hai dòng, nhưng log có hai câu SQL thay cho một câu left join:
Hibernate: select o1_0.id,o1_0.customer_id
from orders o1_0 where o1_0.id=?
Hibernate: select l1_0.order_id,l1_0.id,...
from order_lines l1_0 where l1_0.order_id=?Đây là lazy loading. Câu đầu chỉ đọc bảng ORDERS, tới khi Jackson gọi
getLines() để tạo JSON thì Hibernate mới đọc ORDER_LINES. Spring Boot
mặc định giữ kết nối tới hết request (dòng cảnh báo spring.jpa.open-in-view
lúc khởi động), nên việc này chạy được.
Lỗi hay gặp
Quên @EntityGraph. Không báo lỗi gì, nhưng mỗi lần đọc đơn tốn thêm
một câu select cho các dòng hàng. Đặt spring.jpa.open-in-view=false thì
request trả 500, log báo Cannot lazily initialize collection of role 'com.shop.api.Order.lines'.
Quan hệ hai chiều khi trả JSON. Thêm field order vào OrderLine để đi
ngược lại, rồi trả thẳng entity ra API. Order chứa lines, mỗi dòng lại
chứa order, JSON lồng nhau mãi tới độ sâu 500 thì Jackson dừng và ghi lỗi
vào log. Client vẫn nhận 200 với một JSON bị cắt dở.
// SAI — OrderLine trỏ ngược về Order, JSON lặp mãi
// OrderLine.java
package com.shop.api;
import jakarta.persistence.*;
@Entity
class OrderLine {
@Id
private Long id;
@ManyToOne
@JoinColumn(name = "order_id",
insertable = false, updatable = false)
private Order order;
public Order getOrder() { return order; }
}Trả dữ liệu qua DTO như bài DTO, chỉ chọn những trường cần gửi, vòng lặp sẽ không xảy ra.
// ĐÚNG — DTO chỉ giữ giá trị, không trỏ ngược
// LineResponse.java
package com.shop.api;
record LineResponse(long id, long productId,
int quantity, long unitPrice) {
}Tóm tắt
- Một-nhiều: một đơn hàng có nhiều dòng hàng.
- Khai báo bằng
@OneToManytrênList<OrderLine> linesvà@JoinColumnchỉ cột khoá ngoạiorder_id. - Danh sách liên quan được đọc kiểu lazy. Muốn đọc luôn trong một câu SQL
thì dùng
@EntityGraph. - Trả dữ liệu có quan hệ ra API qua DTO để tránh vòng lặp JSON.
Tự kiểm tra
0/3 câuClass Post có @OneToMany @JoinColumn(name = "post_id") List<Comment> comments. Cột POST_ID nằm ở đâu?
Tạo Post mới, thêm hai Comment vào post.getComments(), rồi gọi posts.save(post) một lần (field comments có cascade = CascadeType.ALL). Cột POST_ID của hai comment là gì?
Trả thẳng entity Order có lines, mà mỗi OrderLine lại có field order, thì chuyện gì xảy ra?