VieTopik
Tiếng HànTiếng AnhIT
  • Góc học tập
Tải app
  • Thư viện
  • Luyện thi
  • Cẩm nang
  • Góc học tập
Spring Boot Web APIDữ liệu với JPA
Bài 16/19
5 phút

Quan hệ một-nhiều

Nội dung bài · 5 mục
  1. 1.Khái niệm
  2. 2.Ví dụ
  3. 3.Thử ngay
  4. 4.Lỗi hay gặp
  5. 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());
    }
}
  • @OneToMany trên Order.lines cho biết một đơn có nhiều dòng. @JoinColumn chỉ ra cột khoá ngoại order_id nằm ở bảng order_lines, trỏ về đơn hàng. nullable = false khớp với NOT NULL của cột đó.
  • cascade = CascadeType.ALL: lưu Order thì lưu luôn các OrderLine trong lines.
  • Hai class tương ứng với bảng orders, order_lines của khoá SQL. Đơn chỉ lưu customerId, dòng hàng lưu productId và unitPrice là giá lúc bán, đúng như bài Chuẩn hoá.
  • JPA cần constructor không tham số khi đọc từ database, để protected là đủ.
  • @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ột left join từ orders sang order_lines. Chữ WithLines giữa find và By chỉ để đặt tên, Spring Data bỏ qua.
Order long id long customerId OrderLine long id FK long order_id long productId int quantity long unitPrice có
Một Order có nhiều OrderLine, nối qua order_id

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/1

Rồ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 @OneToMany trên List<OrderLine> lines và @JoinColumn chỉ cột khoá ngoại order_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âu
Câu 1

Class Post có @OneToMany @JoinColumn(name = "post_id") List<Comment> comments. Cột POST_ID nằm ở đâu?

Câu 2

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ì?

Câu 3

Trả thẳng entity Order có lines, mà mỗi OrderLine lại có field order, thì chuyện gì xảy ra?

Truy vấn với JPAXử lý lỗi tập trung

Nội dung bài

  1. 1.Khái niệm
  2. 2.Ví dụ
  3. 3.Thử ngay
  4. 4.Lỗi hay gặp
  5. 5.Tóm tắt