Robin Hermans Robin Hermans - 3 months ago 12
Java Question

Spring + Hibernate: How do I efficiently chain two link tables and include resulting data in single entity? (user-role-right)

Short version

I have a basic setup where a user table is linked to a role table and a role table is linked to a right. These are both Many-to-Many relations. The roles are a dynamic entity and not of interest for the application (only for visual aspects). When I fetch a user I want to return the data in the user table including a list of the names of all rights.

To clarify, this is what I want the solution to do:

solution overview

I managed to get the rights in my user object and return them, but it's inefficient due to the extra query calls hibernate makes after the original query was called.

Detailed version

Let me first give you some information on how to entities are linked and what the code looks like. My (simplified) database table structure looks like this:

Database diagram

User.java

@Entity
@Table(name = "user")
public class User {

@Id
@Column(name = "user_id", columnDefinition = "user_id")
private Long userId;

@Transient
private List<String> rights

@ManyToMany
@JoinTable(
name = "user_role",
joinColumns = @JoinColumn(name = "user_id", referencedColumnName = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id", referencedColumnName = "role_id"))
@JsonIgnore
private List<Role> roles;

//Getters and setters
}


Role.java

@Entity
@Table(name = "role")
public class Role {

@Id
@Column(name = "role_id", columnDefinition = "role_id")
private Long roleId;

@ManyToMany
@JoinTable(
name = "user_role",
joinColumns = @JoinColumn(name = "role_id", referencedColumnName = "role_id"),
inverseJoinColumns = @JoinColumn(name = "user_id", referencedColumnName = "user_id"))
@JsonIgnore
private List<Employee> employees;

@ManyToMany
@JoinTable(
name = "role_right",
joinColumns = @JoinColumn(name = "role_id", referencedColumnName = "role_id"),
inverseJoinColumns = @JoinColumn(name = "right_id", referencedColumnName = "right_id"))
@JsonIgnore
private List<Right> rights;


//Getters and setters
}


Right.java

@Entity
@Table(name = "right")
public class Right {

@Id
@Column(name = "right_id", columnDefinition = "right_id")
private Long rightId;

@Column(name = "name", columnDefinition = "name")
private String name;

@ManyToMany
@JoinTable(
name = "role_right",
joinColumns = @JoinColumn(name = "right_id", referencedColumnName = "right_id"),
inverseJoinColumns = @JoinColumn(name = "role_id", referencedColumnName = "role_id"))
@JsonIgnore
private List<Role> roles;

//Getters and setters
}


It's important to know that I use the Java Specifications API to join the tables:

return (root, query, cb) -> {
query.distinct(true);
Join rolesJoin = root.join("roles", JoinType.LEFT);
Join rightsJoin = rolesJoin.join("rights", JoinType.LEFT);
return cb.conjunction();
};


This creates the correct query:

select <columns go here>
from employee user0_
left outer join user_role roles1_ on user0_.user_id=roles1_.user_id
left outer join role role2_ on roles1_.role_id=role2_.role_id
left outer join role_right rights3_ on role2_.role_id=rights3_.role_id
left outer join right right4_ on rights3_.right_id=right4_.right_id


Everything looked to good to me till now. But when I tried to fetch the names of all roles, there where more than two queries (count for page and the original one) being executed

//The original code uses lambda for this
for(Role role : user.getRoles()){
for(Right right: role.getRights()){
user.addRight(right.getName());
}
}


The extra query looks like:

select <column stuff>
from role_right rights0_
inner join right right1_ on rights0_.right_id=right1_.right_id
where rights0_.role_id=?


This makes the call very inefficient to me. In this case it's a single user, but with multiple users it adds up.

Is there a way to have a single query put the names of all rights in the user entity, without adding extra query executions?

Things I tried so far:


  • Using
    @SecondaryTable
    to directly define column from the Right table in my User entity. I could not get to first link the Role to the User and then use fields from the Role table to link the Right table. So in the end I would have to @SecondaryTable annotation on top of my User object and define columns of the Right object below.

  • Using
    @Formula
    in the User entity to insert a native call into the query. This did also not work as the annotation did not understand how to map everything into a list of rights.



There might be other options here, or I did something horribly wrong with implementing the ones above. But for now I don't which way to go in finding a solution for my problem. If someone could tell me, that would be great.

Thanks in advance,

Robin

Answer

You are using Root.join which does just the joining of tables for the purposes of the query; lazy associations in the loaded entities will still not be initialized.

As I see, your intention is to initialize the lazy collections as well. For that you have to use Root.fetch (defined in the interface method inherited from the FetchParent):

Create a fetch join to the specified collection-valued attribute using the given join type.

However, your intention is not a good practice; do not join multiple collections in one query, otherwise the query result set will explode with full Cartesian product between the joined collections. Your result set contains <num of users> * <num of roles per user> * <num of rights per role> rows. So, each user data is repeated <num of roles per user> * <num of rights per role> times in the generated result set.

The approach I find to be the best and most straightforward is to specify batch size on lazy associations.