Appearance
JPA Part 7: One-to-Many, Many-to-One and Many-to-Many
Part 6 covered one-to-one associations, cascading, fetch types and JSON cycles. This part covers the other three association types, with users, orders and products, and applies the same ideas.
@OneToMany unidirectional
One entity is linked to many rows of another: a user can have many orders. The reference goes one way, from the parent (user) to the children (orders). By default it is lazy: reading a user does not load the orders until you access them.
java
@Table(name = "user_details")
@Entity
public class UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userId;
private String name;
private String phone;
@OneToMany(cascade = CascadeType.ALL)
private List<OrderDetails> orderDetails = new ArrayList<>();
public UserDetails() {
}
// getters and setters
}java
@Table(name = "order_details")
@Entity
public class OrderDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String productName;
// getters and setters
}A user row cannot hold several order ids, so by default JPA creates a new join table holding the pairs:
sql
create table user_details_order_details (user_details_user_id bigint not null, order_details_id bigint not null unique)Saving a user with two orders inserts the user, the two orders, and two rows in the join table.
A foreign key in the child instead
For one-to-many, a simpler design is a column in the child table saying which parent it belongs to: order 1 belongs to user 1, order 2 belongs to user 1, and so on. @JoinColumn tells JPA to do that instead of creating a table:
java
@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name = "user_id_fk", referencedColumnName = "userId")
private List<OrderDetails> orderDetails = new ArrayList<>();sql
create table order_details (id bigint generated by default as identity, user_id_fk bigint, product_name varchar(255), primary key (id))Keep join tables for many-to-many; for one-to-many, prefer the foreign key.
Note what saving does here:
insert into user_details ...
insert into order_details (product_name, id) ...
insert into order_details (product_name, id) ...
update order_details set user_id_fk=? where id=?
update order_details set user_id_fk=? where id=?The orders are inserted without the foreign key and then updated, because OrderDetails itself knows nothing about the user. The bidirectional mapping below avoids these extra updates.
Lazy and eager
java
@GetMapping("/user/{id}")
public UserDetailsDTO fetchUser(@PathVariable Long id) {
UserDetails output = userDetailsService.findByID(id);
System.out.println("going to map UserDetails to UserDTO");
return output.mapUserDetailsToUserDTO();
}java
public class UserDetailsDTO {
private Long id;
private String name;
private String phone;
private List<OrderDetails> orders;
public UserDetailsDTO(UserDetails userDetails) {
this.id = userDetails.getUserId();
this.name = userDetails.getName();
this.phone = userDetails.getPhone();
System.out.println("going to query order table here now");
this.orders = userDetails.getOrderDetails();
}
// getters and setters
}With the default LAZY, the log shows the user SELECT, then "going to map...", and only when the orders are used, a second SELECT on order_details. With fetch = FetchType.EAGER, the orders are loaded together with the user.
Cascade types for one-to-many
| Cascade type | Effect |
|---|---|
PERSIST | Saving the user also saves its orders. |
MERGE | Updating the user also updates its orders. |
REMOVE | Deleting the user also deletes its orders. |
ALL | All cascade operations. |
Orphan removal
What if you remove an order from the user's collection (not from the database) and save the user?
java
@GetMapping("/user/{id}")
public UserDetails testOrphan(@PathVariable Long id) {
UserDetails output = userDetailsService.findByID(id);
output.getOrderDetails().remove(0);
userDetailsService.saveUser(output);
return output;
}With orphanRemoval = false (the default):
update order_details set user_id_fk=null where user_id_fk=? and id=?The order stays in the table with no parent: an orphan. With
java
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "user_id_fk", referencedColumnName = "userId")
private List<OrderDetails> orderDetails = new ArrayList<>();the same call also runs delete from order_details where id=?: orphans are deleted automatically.
Cascade REMOVE vs orphan removal, two easily confused settings: cascade remove deletes the children when you delete the parent. Orphan removal deletes a child when you remove it from the parent's collection, while the parent stays.
@OneToMany bidirectional
The parent refers to its children, and each child refers back to its parent.
- Owning side: the child,
OrderDetails. It holds the foreign key, via@ManyToOneand@JoinColumn. - Inverse side: the parent,
UserDetails. No foreign key;mappedBynames the field on the owning side.
java
@Table(name = "user_details")
@Entity
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "userId")
public class UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userId;
private String name;
private String phone;
@OneToMany(mappedBy = "userDetails", cascade = CascadeType.ALL)
private List<OrderDetails> orderDetails = new ArrayList<>();
public List<OrderDetails> getOrderDetails() {
return orderDetails;
}
public void setOrderDetails(List<OrderDetails> orderDetails) {
this.orderDetails = orderDetails;
for (OrderDetails order : orderDetails) {
order.setUserDetails(this); // set the back reference
}
}
// other getters and setters
}java
@Table(name = "order_details")
@Entity
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
public class OrderDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String productName;
@ManyToOne
@JoinColumn(name = "user_id_owing_fk", referencedColumnName = "userId")
private UserDetails userDetails;
// getters and setters
}Two things matter here:
- The owning side decides the foreign key. JPA writes
user_id_owing_fkfromorder.userDetails, not from the user's list. The loop insetOrderDetailsfills that field when Jackson builds the user from JSON. With a plain setter, the orders are saved withuser_id_owing_fk = null. - No extra updates. The foreign key goes straight into each insert:
insert into order_details (product_name, user_id_owing_fk, id) ....
@JsonIdentityInfo stops the infinite JSON recursion (part 6): each order shows "userDetails":1.
@ManyToOne unidirectional
The same relationship seen from the child: many orders can be placed by one user. The user is still the parent, but only the orders refer to it; the user has no list.
java
@Table(name = "user_details")
@Entity
public class UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userId;
private String name;
private String phone;
// getters and setters
}java
@Table(name = "order_details")
@Entity
public class OrderDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String productName;
@ManyToOne(cascade = CascadeType.PERSIST)
@JoinColumn(name = "user_id_owing_fk", referencedColumnName = "userId")
private UserDetails userDetails;
// getters and setters
}java
@PostMapping("/order")
public OrderDetails insertOrder(@RequestBody OrderDetails orderDetails) {
return orderService.saveOrder(orderDetails);
}Posting {"productName": "pen", "userDetails": {"name": "B"}} inserts the user, then the order with the foreign key.
Do not use CascadeType.ALL (or REMOVE) on @ManyToOne. It includes REMOVE, so deleting one order also deletes its user. If the user has other orders, the delete fails on the foreign key; if not, the user silently disappears.
@ManyToOne bidirectional is the same as @OneToMany bidirectional above.
@ManyToMany unidirectional
An order can contain many products, and a product can appear in many orders. Neither table can hold the other's ids, so a join table is required. Name it and its columns with @JoinTable:
java
@Table(name = "order_details")
@Entity
public class OrderDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long orderNo;
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(
name = "order_product", // the join table
joinColumns = @JoinColumn(name = "order_id"), // FK to this entity (order)
inverseJoinColumns = @JoinColumn(name = "product_id") // FK to the other entity (product)
)
private List<ProductDetails> productDetails = new ArrayList<>();
// getters and setters
}java
@Table(name = "product_details")
@Entity
public class ProductDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long productId;
private String name;
private double price;
// getters and setters
}Products are created first (POST /api/product), and an order then refers to them by id: {"productDetails": [{"productId": 1}, {"productId": 2}]}.
Saving that body directly fails: "detached entity passed to persist: ProductDetails". The products in the body are plain objects with an id, not managed entities, and the cascade tries to persist them as new. So load the real products first:
java
@PostMapping("/order")
public OrderDetails insertOrder(@RequestBody OrderDetails orderDetail) {
List<ProductDetails> managedProducts = orderDetail.getProductDetails().stream()
.map(product -> productService.findByID(product.getProductId()))
.collect(Collectors.toList());
orderDetail.setProductDetails(managedProducts);
return orderService.saveOrder(orderDetail);
}Now each order is saved with rows in order_product linking it to its products, and several orders can share a product.
Avoid CascadeType.ALL (or REMOVE) on @ManyToMany. Deleting one order would try to delete its products, which other orders still use; the delete fails with a referential-integrity error. PERSIST and MERGE are the useful cascades here.
@ManyToMany bidirectional
Either side can be the owner. Keep OrderDetails as the owning side (it has @JoinTable) and add the inverse side to ProductDetails:
java
@ManyToMany(mappedBy = "productDetails")
@JsonIgnore
private List<OrderDetails> orders = new ArrayList<>();@JsonIgnore stops the product → orders → products loop in JSON: from an order you see its products, and the products do not list their orders.
Next: Until now repositories were empty and data was read by id. Part 8 covers querying: derived query methods, JPQL, pagination and sorting, and the N+1 problem.
Check yourself
- What does a unidirectional
@OneToManycreate by default to store the relationship? - How do you store the relationship as a foreign key in the child table instead?
- Why does a unidirectional
@OneToManywith@JoinColumnrunUPDATEstatements after inserting the children? - What is the default fetch type of
@OneToMany, and when are the children loaded? - You remove an order from
user.getOrderDetails()and save. What happens withorphanRemoval = false, and withtrue? - How is orphan removal different from cascade
REMOVE? - In a bidirectional one-to-many, which side owns the relationship, and which uses
mappedBy? - Why does
setOrderDetailsloop over the orders and callsetUserDetails(this)? What happens without it? - Why is
CascadeType.ALLon@ManyToOnedangerous? - Why does a many-to-many always need a join table? What do
joinColumnsandinverseJoinColumnsrefer to? - Why does saving an order with
[{"productId": 1}]fail with "detached entity passed to persist", and how do you fix it? - Which cascade types suit
@ManyToMany, and why notREMOVE?
Answers
- A join table, e.g.
user_details_order_details. @JoinColumn(name = "user_id_fk", referencedColumnName = "userId")on the@OneToManyfield.- The children are inserted without the key (they do not know the parent) and then updated with it.
LAZY; when the collection is first accessed.false: the order's foreign key is set to null and the row stays (an orphan).true: the order is deleted.- Cascade remove deletes children when the parent is deleted; orphan removal deletes a child removed from the parent's collection.
- The child (
@ManyToOne+@JoinColumn) owns it; the parent's@OneToManyhasmappedBy. - The owning side's field decides the foreign key. Without it, orders are saved with a null foreign key.
- It includes
REMOVE: deleting one order deletes (or fails to delete) the shared user. - Neither side can store a list of the other's ids.
joinColumns: the key of the owning entity;inverseJoinColumns: the key of the other entity. - The products in the body are not managed entities, so the cascade tries to persist them as new. Load them with
findByIdfirst and set those on the order. PERSISTandMERGE.REMOVEwould delete products still used by other orders.