Chuyển đến nội dung chính

Lesson 8: Entity Relationships, Pagination & Specification

@OneToMany, @ManyToOne, @ManyToMany, @OneToOne. Fetch strategies (LAZY vs EAGER), N+1 problem. Pageable, Sort, Slice. JPA Specification for dynamic queries.

💻 Programming — Lesson 7 Lesson 8: Entity Relationships, Pagination & Specification

Spring Boot 4: From Basics to Advanced

Part 2: Building REST API

xdev.asia

Introduction

In real applications, entities have complex relationships with each other. Understanding how to map relationships, avoid the N+1 problem, and implement pagination are required skills for backend developers. This article dives into JPA relationships and dynamic queries.


1. Entity Relationships

1.1 @ManyToOne & @OneToMany

// Category (1) ←→ (N) Product
@Entity
@Table(name = "categories")
public class Category extends BaseEntity {

    @Column(nullable = false, unique = true)
    private String name;

    private String slug;

    @OneToMany(mappedBy = "category", cascade = CascadeType.ALL)
    private List<Product> products = new ArrayList<>();

    // Convenience method
    public void addProduct(Product product) {
        products.add(product);
        product.setCategory(this);
    }
}

@Entity
@Table(name = "products")
public class Product extends BaseEntity {

    private String name;
    private BigDecimal price;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id", nullable = false)
    private Category category;
}

1.2 @ManyToMany

// Product (N) ←→ (N) Tag
@Entity
@Table(name = "products")
public class Product extends BaseEntity {

    // ...other fields

    @ManyToMany
    @JoinTable(
        name = "product_tags",
        joinColumns = @JoinColumn(name = "product_id"),
        inverseJoinColumns = @JoinColumn(name = "tag_id")
    )
    private Set<Tag> tags = new HashSet<>();

    public void addTag(Tag tag) {
        tags.add(tag);
        tag.getProducts().add(this);
    }

    public void removeTag(Tag tag) {
        tags.remove(tag);
        tag.getProducts().remove(this);
    }
}

@Entity
@Table(name = "tags")
public class Tag extends BaseEntity {

    @Column(nullable = false, unique = true)
    private String name;

    @ManyToMany(mappedBy = "tags")
    private Set<Product> products = new HashSet<>();
}

1.3 @OneToOne

@Entity
@Table(name = "users")
public class User extends BaseEntity {
    private String name;
    private String email;

    @OneToOne(mappedBy = "user", cascade = CascadeType.ALL,
              fetch = FetchType.LAZY, optional = false)
    private UserProfile profile;
}

@Entity
@Table(name = "user_profiles")
public class UserProfile extends BaseEntity {
    private String bio;
    private String avatarUrl;
    private String phone;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", unique = true)
    private User user;
}

2. Fetch Strategies & N+1 Problem

2.1 LAZY vs EAGER

// LAZY (Default cho @OneToMany, @ManyToMany)
// → Query phụ chỉ chạy khi access field
@OneToMany(fetch = FetchType.LAZY)
private List<Product> products;

// EAGER (Default cho @ManyToOne, @OneToOne)
// → Luôn JOIN khi query entity
@ManyToOne(fetch = FetchType.EAGER)
private Category category;

Best practice: Always use FetchType.LAZY for all relationships, then fetch as needed using JOIN FETCH.

2.2 N+1 Problem

// ⚠️ N+1 Problem
List<Product> products = productRepository.findAll();
// Query 1: SELECT * FROM products → 100 products
for (Product p : products) {
    System.out.println(p.getCategory().getName());
    // Query 2..101: SELECT * FROM categories WHERE id = ?
}
// Total: 1 + 100 = 101 queries! 💀

2.3 N+1 Solution

// Solution 1: JOIN FETCH
@Query("SELECT p FROM Product p JOIN FETCH p.category")
List<Product> findAllWithCategory();

// Solution 2: @EntityGraph
@EntityGraph(attributePaths = {"category", "tags"})
List<Product> findAll();

// Solution 3: Batch fetching
@Entity
public class Category {
    @OneToMany(mappedBy = "category")
    @BatchSize(size = 20) // Fetch 20 categories per query
    private List<Product> products;
}

// Solution 4: DTO Projection (tốt nhất cho read-only)
@Query("""
    SELECT new com.example.dto.ProductWithCategory(
        p.id, p.name, p.price, c.name)
    FROM Product p JOIN p.category c
    """)
List<ProductWithCategory> findAllProjected();

3. Pagination & Sorting

3.1 Pageable in Repository

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Tự động hỗ trợ pagination
    Page<Product> findByActiveTrue(Pageable pageable);

    Page<Product> findByCategorySlug(String slug, Pageable pageable);

    @Query("SELECT p FROM Product p WHERE p.price > :minPrice")
    Page<Product> findExpensive(@Param("minPrice") BigDecimal minPrice,
                                 Pageable pageable);
}

3.2 Controller with Pagination

@GetMapping
public ResponseEntity<Page<ProductResponse>> getProducts(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size,
        @RequestParam(defaultValue = "createdAt") String sortBy,
        @RequestParam(defaultValue = "desc") String sortDir) {

    Sort sort = sortDir.equalsIgnoreCase("asc")
        ? Sort.by(sortBy).ascending()
        : Sort.by(sortBy).descending();

    Pageable pageable = PageRequest.of(page, size, sort);
    Page<Product> products = productRepository.findByActiveTrue(pageable);

    Page<ProductResponse> response = products.map(ProductResponse::from);
    return ResponseEntity.ok(response);
}

3.3 Page Response

{
    "content": [
        {"id": 1, "name": "Product A", "price": 100},
        {"id": 2, "name": "Product B", "price": 200}
    ],
    "pageable": {
        "pageNumber": 0,
        "pageSize": 20,
        "sort": {"sorted": true, "direction": "DESC"}
    },
    "totalElements": 150,
    "totalPages": 8,
    "first": true,
    "last": false,
    "numberOfElements": 20
}

3.4 Slice vs Page

// Page: Chạy COUNT query → biết total elements (tốn performance)
Page<Product> findByActiveTrue(Pageable pageable);

// Slice: Không chạy COUNT → chỉ biết hasNext (tốt cho infinite scroll)
Slice<Product> findByActiveTrue(Pageable pageable);

4. JPA Specification — Dynamic Queries

4.1 When do we need Specification?

When the API has multiple filter parameters optional:

GET /api/products?category=electronics&minPrice=100&maxPrice=500&status=ACTIVE&keyword=phone

Writing a derived query method for every combination is not feasible. Specification allows building queries dynamically.

4.2 Implementation

// Repository extends JpaSpecificationExecutor
public interface ProductRepository extends
        JpaRepository<Product, Long>,
        JpaSpecificationExecutor<Product> {
}

// Specification builder
public class ProductSpecification {

    public static Specification<Product> hasCategory(String categorySlug) {
        return (root, query, cb) -> {
            if (categorySlug == null) return null;
            Join<Product, Category> category = root.join("category");
            return cb.equal(category.get("slug"), categorySlug);
        };
    }

    public static Specification<Product> priceBetween(BigDecimal min, BigDecimal max) {
        return (root, query, cb) -> {
            if (min == null && max == null) return null;
            if (min != null && max != null) {
                return cb.between(root.get("price"), min, max);
            }
            if (min != null) {
                return cb.greaterThanOrEqualTo(root.get("price"), min);
            }
            return cb.lessThanOrEqualTo(root.get("price"), max);
        };
    }

    public static Specification<Product> hasStatus(ProductStatus status) {
        return (root, query, cb) -> {
            if (status == null) return null;
            return cb.equal(root.get("status"), status);
        };
    }

    public static Specification<Product> nameContains(String keyword) {
        return (root, query, cb) -> {
            if (keyword == null || keyword.isBlank()) return null;
            return cb.like(
                cb.lower(root.get("name")),
                "%" + keyword.toLowerCase() + "%"
            );
        };
    }
}

4.3 Use in Service

@Service
public class ProductService {

    private final ProductRepository productRepository;

    public Page<ProductResponse> search(
            String category, BigDecimal minPrice, BigDecimal maxPrice,
            ProductStatus status, String keyword, Pageable pageable) {

        Specification<Product> spec = Specification
            .where(ProductSpecification.hasCategory(category))
            .and(ProductSpecification.priceBetween(minPrice, maxPrice))
            .and(ProductSpecification.hasStatus(status))
            .and(ProductSpecification.nameContains(keyword));

        return productRepository.findAll(spec, pageable)
            .map(ProductResponse::from);
    }
}

4.4 Controllers

@GetMapping("/search")
public ResponseEntity<Page<ProductResponse>> searchProducts(
        @RequestParam(required = false) String category,
        @RequestParam(required = false) BigDecimal minPrice,
        @RequestParam(required = false) BigDecimal maxPrice,
        @RequestParam(required = false) ProductStatus status,
        @RequestParam(required = false) String keyword,
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size) {

    Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
    Page<ProductResponse> results = productService.search(
        category, minPrice, maxPrice, status, keyword, pageable);

    return ResponseEntity.ok(results);
}

5. Cascade & Orphan Removal

@Entity
public class Order extends BaseEntity {

    @OneToMany(mappedBy = "order",
               cascade = CascadeType.ALL,    // Cascade tất cả operations
               orphanRemoval = true)          // Xóa item khi remove khỏi list
    private List<OrderItem> items = new ArrayList<>();

    public void addItem(OrderItem item) {
        items.add(item);
        item.setOrder(this);
    }

    public void removeItem(OrderItem item) {
        items.remove(item);
        item.setOrder(null);
        // orphanRemoval = true → item bị DELETE khỏi DB
    }
}

Summary

  • JPA relationships (@OneToMany, @ManyToOne, @ManyToMany) map relationships between entities, always use FetchType.LAZY
  • N+1 problem solved by JOIN FETCH, @EntityGraph, @BatchSize or DTO projection
  • Spring Data Pagination (Page, Slice, Pageable) supports automatic pagination and sorting
  • JPA Specification allows building dynamic queries with many optional filter parameters

Exercises

  1. Create 3 entities: Author (1-N) Book (N-N) Tag. Fully implement relationships with convenience methods
  2. Write a query to get all Books with Author without N+1 (using @EntityGraph or JOIN FETCH). Verify by number of queries in log
  3. Implement search API for Books with JPA Specification: filter by author, tags, price range, keyword in title. Combined with Pagination