1. Introduction.
The OneToMany relationship is the most common JPA association and foreign key is controlled by the child-side directly including unidirectional and bidirectional association. This note presents how to configure bidirectional @OneToMany association
2. Bidirectional ManyToOne / OneToMany Relationships.
Assuming that:
-
- Event entity references a single instance of User entity
- User entity references a collection of Event entity
In database, table Event contains a foreign key to table User. The foreign key column has the same type as the primary key of table User.
A demo example is written by Spring Boot, JPA, Liquibase, HikariDataSource, PostgresSQL, and Projectlombok (no need to write another getter or equals method,..).
2.1 Implementation.
Create two domain entities: UserEntity and EventEntity
src/main/java/com/example/onetomany/domain/UserEntity.java
package com.example.onetomany.domain; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.ToString; import org.hibernate.annotations.GenericGenerator; import org.hibernate.annotations.Type; import org.springframework.util.CollectionUtils; import javax.persistence.CascadeType; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.Id; import javax.persistence.OneToMany; import javax.persistence.Table; import java.io.Serializable; import java.util.HashSet; import java.util.Set; import java.util.UUID; @Data @Entity @Table(name = "user_entity") @Builder @EqualsAndHashCode(of = {"id"}) @ToString(of = {"id","email", "firstName", "lastName"}) @NoArgsConstructor @AllArgsConstructor public class UserEntity implements Serializable { @Id @Type(type = "pg-uuid") @GenericGenerator(name = "uuid-gen", strategy = "uuid2") @GeneratedValue(generator = "uuid-gen") @Column(name = "user_id") private UUID id; private String email; private String firstName; private String lastName; @OneToMany(mappedBy = "user" , cascade = CascadeType.ALL) private Set<EventEntity> eventEntities = new HashSet<>(); public void addEvent(EventEntity eventEntity) { if (CollectionUtils.isEmpty(eventEntities)) { eventEntities = new HashSet<>(); } eventEntities.add(eventEntity); eventEntity.setUser(this); } public void removeEvent(EventEntity eventEntity) { eventEntities.remove(eventEntity); eventEntity.setUser(null); } }
Check eventEntities is not null due to Caused by: java.lang.NullPointerException: null
src/main/java/com/example/onetomany/domain/EventEntity.java
package com.example.onetomany.domain; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.ToString; import org.hibernate.annotations.GenericGenerator; import org.hibernate.annotations.Type; import javax.persistence.CascadeType; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.FetchType; import javax.persistence.GeneratedValue; import javax.persistence.Id; import javax.persistence.JoinColumn; import javax.persistence.ManyToOne; import javax.persistence.Table; import java.io.Serializable; import java.time.Instant; import java.util.UUID; @Data @Entity @Table(name = "event_entity") @Builder @EqualsAndHashCode(of = {"id"}) @ToString(of = {"id","description", "creationTime"}) @NoArgsConstructor @AllArgsConstructor public class EventEntity implements Serializable { @Id @Type(type = "pg-uuid") @GenericGenerator(name = "uuid-gen", strategy = "uuid2") @GeneratedValue(generator = "uuid-gen") @Column(name = "event_id") private UUID id; private String description; @Column(columnDefinition = "TIMESTAMP WITH TIME ZONE NOT NULL") private Instant creationTime; @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) @JoinColumn(name = "user_id") private UserEntity user; }
For security reasons, primary key should be unpredictable so that UUID is a good candidate, although performance issue (not indexing UUID) is slow in comparison with Long, Integer.
Add a liquibase file base on the domains above
resources/db-changelog.xml
<?xml version="1.0" encoding="utf-8"?> <databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.5.xsd"> <changeSet id="create_user_entity" author="khiem"> <createTable tableName="user_entity"> <column name="user_id" type="UUID"> <constraints nullable="false" primaryKey="true"/> </column> <column name="email" type="VARCHAR(255)"/> <column name="first_name" type="VARCHAR(255)"/> <column name="last_name" type="VARCHAR(255)"/> </createTable> </changeSet> <changeSet id="create_event_entity" author="khiem"> <createTable tableName="event_entity"> <column name="event_id" type="UUID"> <constraints nullable="false" primaryKey="true"/> </column> <column name="description" type="VARCHAR(255)"/> <column name="creation_time" type="TIMESTAMP WITH TIME ZONE"> <constraints nullable="false"/> </column> <column name="user_id" type="UUID" /> </createTable> </changeSet> <changeSet author="khiem" id="addForeignKeyConstraint-user-event"> <addForeignKeyConstraint baseColumnNames="user_id" baseTableName="event_entity" constraintName="fk_user_event" deferrable="false" initiallyDeferred="false" onDelete="NO ACTION" onUpdate="NO ACTION" referencedColumnNames="user_id" referencedTableName="user_entity"/> </changeSet> </databaseChangeLog>
Simple userReporsitory to handle insert or update user data.
src/main/java/com/example/onetomany/repository/UserRepository.java
package com.example.onetomany.repository; import com.example.onetomany.domain.UserEntity; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; import java.util.UUID; public interface UserRepository extends JpaRepository<UserEntity, UUID> { Optional<UserEntity> findByEmail(String email); }
2.2 Test.
Using maven to package and deploy the application. In practice, it should be separate different environments such as development, staging, and production.
mvn clean package -DskipTests -Pdev
Then run it by command:
java -jar target/onetomany-0.0.1-SNAPSHOT.jar --spring.profiles.active=dev
Try to insert a user and user’s event log:
if (!userRepository.findByEmail("test1@domain.com").isPresent()) { UserEntity userEntity = userRepository.save(UserEntity.builder() .email("test1@domain.com") .firstName("Richard") .lastName("Thaler") .build()); userEntity.addEvent(EventEntity.builder() .description("Login successfully") .creationTime(Instant.now()) .build()); userRepository.save(userEntity); }
Check result in database. FK of EventEntity is the same value of PK of UserEntity
user_id | email | first_name | last_name 46f532d4-f4b7-44d1-b273-271b800332a2 test1@domain.com Richard Thaler event_id | description | creation_time | user_id 41e52671-6903-4bbf-b138-cb6297990b21 Login successfully 2018-08-06 03:48:41.747+07 46f532d4-f4b7-44d1-b273-271b800332a2
Retrieve all events
Optional.ofNullable(userRepository.findByEmail("test1@domain.com")).ifPresent( t -> t.get().getEventEntities().forEach(System.out::println) );
Output: EventEntity(id=41e52671-6903-4bbf-b138-cb6297990b21, description=Login successfully, creationTime=2018-08-05T20:48:41.747Z)
Remove event logs from the user:
UserEntity userEntity = userRepository.findByEmail("test1@domain.com") .orElseThrow(() -> new EntityNotFoundException()); Set<EventEntity> eventEntities = userEntity.getEventEntities(); if (!CollectionUtils.isEmpty(eventEntities)) { Iterator<EventEntity> iterator = eventEntities.iterator(); while (iterator.hasNext()) { userEntity.removeEvent(iterator.next()); } } userRepository.save(userEntity);
Check event log in database. Actually, PK of a event entity is deleted (no reference to UserEntity).
event_id | description | creation_time | user_id 41e52671-6903-4bbf-b138-cb6297990b21 Login successfully 2018-08-06 03:48:41.747+07 NULL
2.3 Example logs.
Some issues happened and fixed when running the example as following:
1. Method org.postgresql.jdbc.PgConnection.createClob() is not yet implemented
Caused by: java.sql.SQLFeatureNotSupportedException: Method org.postgresql.jdbc.PgConnection.createClob() is not yet implemented.
Fixed by adding: spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation: true to application.yml file
2. Spring boot app always shutdown immediately after starting
[restartedMain] com.example.onetomany.Application : Started Application in 4.337 seconds (JVM running for 4.851) [Thread-9] s.c.a.AnnotationConfigApplicationContext : Closing org.springframework.context.annotation.AnnotationConfigApplicationContext@2e75d95d: startup date [Sun Aug 05 22:34:52 ICT 2018]; root of context hierarchy [Thread-9] o.s.j.e.a.AnnotationMBeanExporter : Unregistering JMX-exposed beans on shutdown [Thread-9] o.s.j.e.a.AnnotationMBeanExporter : Unregistering JMX-exposed beans [Thread-9] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default' [Thread-9] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown initiated... [Thread-9] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown completed. [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 7.223 s [INFO] Finished at: 2018-08-05T22:34:56+07:00 [INFO] Final Memory: 26M/437M [INFO] ------------------------------------------------------------------------
Fixed by adding a dependency:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>
3. [WARNING] The requested profile “dev” could not be activated because it does not exist.
This is because of missing development profile. It should be defined in pom.xml. For example:
<profiles> <profile> <id>dev</id> <activation> <activeByDefault>true</activeByDefault> </activation> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-undertow</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <optional>true</optional> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>${maven-compiler-plugin.version}</version> <configuration> <source>${java.version}</source> <target>${java.version}</target> </configuration> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-war-plugin</artifactId> <configuration> </configuration> </plugin> </plugins> </build> <properties> <!-- log configuration --> <logback.loglevel>DEBUG</logback.loglevel> <!-- default Spring profiles --> <spring.profiles.active>dev</spring.profiles.active> </properties> </profile> </profiles>
References:
- Oracle, JSR 338: JavaTM Persistence API, Version 2.1 Final Release.
- Vlad Mihalcea, High-Performance Java Persistence, 2015-2016