For a project I’m currently working on, we are using an external system for user authentication. Authorization however, is being handled by application specific database tables. To maintain the data of these authorization tables, we wanted to set up JPA entities and Spring Data JPA repositories. However, there was 1 issue. We weren’t able to set up the “principal” entity, because it wasn’t a database table.
In the above image, you can see the systems and entities that we have:
The relation between the “principal_roles” and “role” table, can just be achieved with a foreign key. However, because the “principal” is not a database table, we can’t specify a foreign key relation here. Still, the “principal_name” column should refer to an existing principal in the external system.
The goal was to have support for (at least) the following CRUD operations:
The first attempt was to use the “principal_roles” table both as entity and as join table within the entity. Below you can find an example of what the entity looked like.
package nl.mikeheeren.joinwithoutparententity.model;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.JoinTable;
import javax.persistence.ManyToMany;
import javax.persistence.Table;
import java.util.List;
@Entity
@Table(name = "principal_roles")
public class Principal {
@Id
@Column(name = "principal_name")
private String principalName;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "principal_roles",
joinColumns = {@JoinColumn(name = "principal_name")},
inverseJoinColumns = {@JoinColumn(name = "role_id")}
)
private List<Role> roles;
public String getPrincipalName() {
return principalName;
}
public void setPrincipalName(String principalName) {
this.principalName = principalName;
}
public List<Role> getRoles() {
return roles;
}
public void setRoles(List<Role> roles) {
this.roles = roles;
}
}
Even though some things were (partially) working, this was not a satisfying solution:
org.hibernate.HibernateException: More than one row with the given identifier was found: ******, for class: nl.mikeheeren.joinwithoutparententity.model.Principal
Now that we found out that the previous solution doesn’t satisfy all our needs, we decided to go for another approach. This time we just created a simple entity object for the “principal_roles” table. Besides this entity, we also created a simple Principal DTO. In the JpaRepository (which by default provides operations for the PrincipalRoles entity) we then specify a couple of additional methods, where we will bundle/transform PrincipalRoles into a Principal and vice versa.
We started by setting up the PrincipalRoles entity object. This will have 2 fields that represent the role relation. The first one is just the ID. This one is required because we need to use this field in the composite ID. The other one also actually fetches the role details from the database. We can’t combine these into a single field, because @Id can’t be applied in combination with the @ManyToOne annotation.
package nl.mikeheeren.joinwithoutparententity.model;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.IdClass;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
import java.io.Serializable;
@Entity
@Table(name = "principal_roles")
@IdClass(PrincipalRoles.PrimaryKey.class)
public class PrincipalRoles {
@Id
@Column(name = "principal_name")
private String principalName;
@Id
@Column(name = "role_id")
private int roleId;
@ManyToOne
@JoinColumn(name = "role_id", insertable = false, updatable = false)
private Role role;
public String getPrincipalName() {
return principalName;
}
public void setPrincipalName(String principalName) {
this.principalName = principalName;
}
public Role getRole() {
return role;
}
public void setRole(Role role) {
this.role = role;
this.roleId = role == null ? 0 : role.getId();
}
static class PrimaryKey implements Serializable {
private String principalName;
private int roleId;
}
}
Now that the entity object is in place, it’s time to see if we can satisfy all of our goals in the JpaRepository:
@Transactional
default Principal save(Principal principal) {
String principalName = principal.getPrincipalName();
List<Role> roles = principal.getRoles();
if (roles == null || roles.isEmpty()) {
throw new DataIntegrityViolationException("A principal should always contain at least 1 role.");
}
List<PrincipalRoles> principalRoles = roles.stream()
.map(role -> {
var pr = new PrincipalRoles();
pr.setPrincipalName(principalName);
pr.setRole(role);
return pr;
})
.collect(Collectors.toList());
// Delete existing records
deleteAllByPrincipalName(principalName);
// (Re)insert roles
List<PrincipalRoles> savedPrincipalRoles = saveAll(principalRoles);
return toPrincipal(savedPrincipalRoles);
}
Finally, we convert the (successfully) stored PrincipalRoles List back into the Principal DTO using the below method:
private static Principal toPrincipal(List<PrincipalRoles> principalRoles) {
String principalName = principalRoles.get(0).getPrincipalName();
List<Role> roles = principalRoles.stream().map(PrincipalRoles::getRole).collect(Collectors.toList());
return new Principal(principalName, roles);
}
default Optional<Principal> findPrincipalById(String principalName) {
List<PrincipalRoles> principalRoles = findAllByPrincipalName(principalName);
if (principalRoles.isEmpty()) {
return Optional.empty();
}
return Optional.of(toPrincipal(principalRoles));
}
@Query("SELECT principalRoles FROM PrincipalRoles principalRoles WHERE LOWER(principalRoles.principalName) = LOWER(:principalName)")
List<PrincipalRoles> findAllByPrincipalName(@Param("principalName") String principalName);
default List<Principal> findAllPrincipals() {
Map<String, List<PrincipalRoles>> caseInsensitiveBundle = findAll().stream()
.collect(Collectors.groupingBy(principalRoles -> principalRoles.getPrincipalName().toLowerCase(), Collectors.toList()));
return caseInsensitiveBundle.values().stream()
.map(PrincipalRepository::toPrincipal)
.collect(Collectors.toList());
}
@Modifying
@Transactional
@Query("DELETE FROM PrincipalRoles principalRoles WHERE LOWER(principalRoles.principalName) = LOWER(:principalName)")
void deleteAllByPrincipalName(@Param("principalName") String principalName);
We have tried 2 ways of creating a join table with a parent entity in Spring Data JPA. The first attempt was to use the join table both as the entity and the join table. Even though it didn’t satisfy all our needs, this way can probably still be used when you want to set up a read-only join table without a parent entity in JPA.
However, when you also want to be able to create, update and delete these records, going for the approach where you specify the join table as entity only, and adding some custom code to the repository, seems like the way to go.
The full example code can be found in Bitbucket.
Geen reacties
Geef jouw mening
Reactie plaatsenReactie toevoegen