Many-To-Many with Micronaut Data JDBC and MySQL
Learn how to map a many-to-many association with Micronaut Data JDBC and MySQL.
Authors: Sergio del Amo
Micronaut Version: 4.10.2
1. Getting Started
In this guide, we will create a Micronaut application written in Java.
2. What you will need
To complete this guide, you will need the following:
-
Some time on your hands
-
A decent text editor or IDE (e.g. IntelliJ IDEA)
-
JDK 21 or greater installed with
JAVA_HOMEconfigured appropriately
3. Solution
We recommend that you follow the instructions in the next sections and create the application step by step. However, you can go right to the completed example.
-
Download and unzip the source
4. Many-To-Many Relationship
A relationship is a connection between two types of entities. In the case of a many-to-many relationship, both sides can relate to multiple instances of the other side.
In this guide, you are going to create a many-to-many relationship between User and Role entities using Micronaut Data JDBC.
A user can have many roles and the same role can be applied to multiple users.
The application consumes a database schema with the following structure:
5. Writing the Application
Create an application using the Micronaut Command Line Interface or with Micronaut Launch.
mn create-app example.micronaut.micronautguide \
--features=data-jdbc,liquibase,mysql,validation,graalvm \
--build=maven \
--lang=java \
--test=junit
If you don’t specify the --build argument, Gradle with the Kotlin DSL is used as the build tool. If you don’t specify the --lang argument, Java is used as the language.If you don’t specify the --test argument, JUnit is used for Java and Kotlin, and Spock is used for Groovy.
|
The previous command creates a Micronaut application with the default package example.micronaut in a directory named micronautguide.
If you use Micronaut Launch, select Micronaut Application as application type and add data-jdbc, liquibase, mysql, validation, and graalvm features.
| If you have an existing Micronaut application and want to add the functionality described here, you can view the dependency and configuration changes from the specified features, and apply those changes to your application. |
The previous command creates a Micronaut application with the default package example.micronaut in a directory named micronautguide.
| If you have an existing Micronaut application and want to add the functionality described here, you can view the dependency and configuration changes from the specified features, and apply those changes to your application. |
5.1. Micronaut Data JDBC
Add Micronaut Data JDBC dependencies to the project:
<!-- Add the following to your annotationProcessorPaths element -->
<path>
<groupId>io.micronaut.data</groupId>
<artifactId>micronaut-data-processor</artifactId>
</path>
<dependency>
<groupId>io.micronaut.data</groupId>
<artifactId>micronaut-data-jdbc</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.micronaut.sql</groupId>
<artifactId>micronaut-jdbc-hikari</artifactId>
<scope>compile</scope>
</dependency>
5.2. MySQL Driver
Add also the MySQL Driver
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
5.3. Database Configuration
And the database configuration:
datasources.default.schema-generate=NONE
(1)
datasources.default.driver-class-name=com.mysql.cj.jdbc.Driver
(2)
datasources.default.db-type=mysql
(3)
datasources.default.dialect=MYSQL
| 1 | Use MySQL driver. |
| 2 | In order for the database to be properly detected by Micronaut Test Resources. |
| 3 | Configure the MySQL dialect. |
5.4. Database Migration with Liquibase
We need a way to create the database schema. For that, we use Micronaut integration with Liquibase.
Add the following snippet to include the necessary dependencies:
<dependency>
<groupId>io.micronaut.liquibase</groupId>
<artifactId>micronaut-liquibase</artifactId>
<scope>compile</scope>
</dependency>
Configure the database migrations directory for Liquibase in application.properties.
liquibase.datasources.default.change-log=classpath\:db/liquibase-changelog.xml
Create the following files with the database schema creation:
<?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.1.xsd">
<include file="changelog/01-schema.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
<?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.1.xsd">
<changeSet id="01" author="username">
<createTable tableName="users">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" primaryKeyName="pk_user" nullable="false"/>
</column>
<column name="username" type="VARCHAR(255)">
<constraints nullable="false" unique="true" uniqueConstraintName="uk_user_username"/>
</column>
</createTable>
<createTable tableName="role">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" primaryKeyName="pk_role" nullable="false"/>
</column>
<column name="authority" type="VARCHAR(255)">
<constraints nullable="false" unique="true" uniqueConstraintName="uk_role_authority"/>
</column>
</createTable>
<createTable tableName="user_role">
<column name="id_user_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="id_role_id" type="BIGINT">
<constraints nullable="false"/>
</column>
</createTable>
<addPrimaryKey tableName="user_role" constraintName="pk_user_role" columnNames="id_user_id, id_role_id"/>
<addForeignKeyConstraint
baseTableName="user_role"
baseColumnNames="id_user_id"
constraintName="fk_user_role_user"
referencedTableName="users"
referencedColumnNames="id"
onDelete="CASCADE"/>
<addForeignKeyConstraint
baseTableName="user_role"
baseColumnNames="id_role_id"
constraintName="fk_user_role_role"
referencedTableName="role"
referencedColumnNames="id"
onDelete="CASCADE"/>
</changeSet>
</databaseChangeLog>
5.5. Entities
5.5.1. User
package example.micronaut;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.data.annotation.GeneratedValue;
import io.micronaut.data.annotation.Id;
import io.micronaut.data.annotation.MappedEntity;
import jakarta.validation.constraints.NotBlank;
@MappedEntity("users") (1)
public record UserEntity(@Nullable
@Id (2)
@GeneratedValue (3)
Long id,
@NotBlank (4)
String username
) {
}
| 1 | Annotate the class with @MappedEntity to map the class to the table defined in the schema. |
| 2 | Specifies the ID of an entity |
| 3 | Specifies that the property value is generated by the database and not included in inserts |
| 4 | Use jakarta.validation.constraints Constraints to ensure the data matches your expectations. |
5.5.2. Role
Create Role domain class to store authorities within our application.
package example.micronaut;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.data.annotation.GeneratedValue;
import io.micronaut.data.annotation.Id;
import io.micronaut.data.annotation.MappedEntity;
import jakarta.validation.constraints.NotBlank;
@MappedEntity (1)
public record Role(@Nullable
@Id (2)
@GeneratedValue (3)
Long id,
@NotBlank (4)
String authority) {
}
| 1 | Annotate the class with @MappedEntity to map the class to the table defined in the schema. |
| 2 | Specifies the ID of an entity |
| 3 | Specifies that the property value is generated by the database and not included in inserts |
| 4 | Use jakarta.validation.constraints Constraints to ensure the data matches your expectations. |
5.5.3. UserRole
Create a UserRole which stores a many-to-many relationship between User and Role.
package example.micronaut;
import io.micronaut.data.annotation.EmbeddedId;
import io.micronaut.data.annotation.MappedEntity;
@MappedEntity (1)
public class UserRole {
@EmbeddedId (2)
private final UserRoleId id;
public UserRole(UserRoleId id) {
this.id = id;
}
public UserRole(UserEntity user, Role role) {
this(new UserRoleId(user, role));
}
public UserRoleId getId() {
return id;
}
}
| 1 | Annotate the class with @MappedEntity to map the class to the table defined in the schema. |
| 2 | Composite primary keys can be defined using @EmbeddedId annotation. |
package example.micronaut;
import io.micronaut.data.annotation.Embeddable;
import io.micronaut.data.annotation.Relation;
import java.util.Objects;
@Embeddable (1)
public class UserRoleId {
@Relation(value = Relation.Kind.MANY_TO_ONE) (2)
private final UserEntity user;
@Relation(value = Relation.Kind.MANY_TO_ONE) (2)
private final Role role;
public UserRoleId(UserEntity user, Role role) {
this.user = user;
this.role = role;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
UserRoleId userRoleId = (UserRoleId) o;
return role.id().equals(userRoleId.getRole().id()) &&
user.id().equals(userRoleId.getUser().id());
}
@Override
public int hashCode() {
return Objects.hash(role.id(), user.id());
}
public UserEntity getUser() {
return user;
}
public Role getRole() {
return role;
}
}
| 1 | Specifies that the bean is embeddable. |
| 2 | You can specify a relationship (one-to-one, one-to-many, etc.) with the @Relation annotation. |
5.6. Projection
Create User record which represents and user and his assigned roles.
package example.micronaut;
import io.micronaut.core.annotation.Introspected;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import jakarta.validation.constraints.NotBlank;
import java.util.List;
@Introspected (1)
public record User(@NonNull @NotBlank Long id,
@NonNull @NotBlank String username,
@Nullable List<String> authorities) {
}
| 1 | Annotate the class with @Introspected to generate BeanIntrospection metadata at compilation time. This information can be used, for example, to render the POJO as JSON using Jackson without using reflection. |
5.7. JDBC Repositories
Create various JDBC Repositories:
5.7.1. User Repository
package example.micronaut;
import io.micronaut.data.annotation.Query;
import io.micronaut.data.jdbc.annotation.JdbcRepository;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.repository.CrudRepository;
import java.util.Optional;
@JdbcRepository(dialect = Dialect.MYSQL) (1)
public interface UserJdbcRepository extends CrudRepository<UserEntity, Long> { (2)
UserEntity save(String username);
(3)
@Query(value = """
SELECT
u.id,
u.username,
GROUP_CONCAT(DISTINCT r.authority ORDER BY r.authority SEPARATOR ',') AS authorities
FROM users AS u
LEFT JOIN user_role AS ur ON ur.id_user_id = u.id
LEFT JOIN role AS r ON r.id = ur.id_role_id
WHERE u.username = :username
GROUP BY u.id, u.username;
""")
Optional<User> findByUsername(String username);
}
| 1 | @JdbcRepository with a specific dialect. |
| 2 | By extending CrudRepository you enable automatic generation of CRUD (Create, Read, Update, Delete) operations. |
| 3 | You can use the @Query annotation to specify an explicit query. |
5.7.2. Role Repository
package example.micronaut;
import io.micronaut.data.jdbc.annotation.JdbcRepository;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.repository.CrudRepository;
import java.util.Optional;
@JdbcRepository(dialect = Dialect.MYSQL) (1)
public interface RoleJdbcRepository extends CrudRepository<Role, Long> { (2)
Role save(String authority);
Optional<Role> findByAuthority(String authority);
void deleteByAuthority(String authority);
}
| 1 | @JdbcRepository with a specific dialect. |
| 2 | By extending CrudRepository you enable automatic generation of CRUD (Create, Read, Update, Delete) operations. |
5.7.3. UserRole Repository
package example.micronaut;
import io.micronaut.data.jdbc.annotation.JdbcRepository;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.repository.CrudRepository;
@JdbcRepository(dialect = Dialect.MYSQL) (1)
public interface UserRoleJdbcRepository extends CrudRepository<UserRole, UserRoleId> { (2)
}
| 1 | @JdbcRepository with a specific dialect. |
| 2 | By extending CrudRepository you enable automatic generation of CRUD (Create, Read, Update, Delete) operations. |
| 3 | You can use the @Query annotation to specify an explicit query. |
A domain class fulfills the M in the Model View Controller (MVC) pattern and represents a persistent entity that is mapped onto an underlying database table.
5.8. Test
Add a test that verifies the many-to-many relationship:
package example.micronaut;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@MicronautTest(startApplication = false) (1)
class ManyToManyTest {
private static final String ROLE_USER = "ROLE_USER";
private static final String ROLE_ADMIN = "ROLE_ADMIN";
private static final String U_SERGIO = "sergio";
private static final String U_TIM = "tim";
@Test
void testManyToManyPersistence(RoleJdbcRepository roleRepo,
UserJdbcRepository userRepo,
UserRoleJdbcRepository userRoleRepo) {
Role roleUser = roleRepo.save(ROLE_USER);
Role roleAdmin = roleRepo.save(ROLE_ADMIN);
assertFalse(userRepo.findByUsername(U_SERGIO).isPresent());
UserEntity sergio = userRepo.save(U_SERGIO);
assertUser(userRepo.findByUsername(U_SERGIO).orElse(null), U_SERGIO, null);
userRoleRepo.save(new UserRole(sergio, roleUser));
userRoleRepo.save(new UserRole(sergio, roleAdmin));
assertUser(userRepo.findByUsername(U_SERGIO).orElse(null), U_SERGIO, List.of(ROLE_ADMIN, ROLE_USER));
UserEntity tim = userRepo.save(U_TIM);
userRoleRepo.save(new UserRole(tim, roleUser));
assertUser(userRepo.findByUsername(U_TIM).orElse(null), U_TIM, List.of(ROLE_USER));
userRoleRepo.delete(new UserRole(tim, roleUser));
userRoleRepo.delete(new UserRole(sergio, roleUser));
userRoleRepo.delete(new UserRole(sergio, roleAdmin));
userRepo.delete(sergio);
userRepo.delete(tim);
roleRepo.deleteByAuthority(ROLE_ADMIN);
roleRepo.deleteByAuthority(ROLE_USER);
}
void assertUser(User user, String expectedUsername, List<String> expectedAuthorities) {
assertNotNull(user);
assertNotNull(user.id());
assertEquals(expectedUsername, user.username());
assertEquals(expectedAuthorities, user.authorities());
}
}
| 1 | Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context. This test does not need the embedded server. Set startApplication to false to avoid starting it. |
6. Testing the Application
To run the tests:
./mvnw test
If you run the test, you will see a MySQL container being started by Micronaut Test Resources through integration with Testcontainers to provide throwaway containers for testing.
6.1. Micronaut Test Resources Goals
zero-configuration: without adding any configuration, test resources should be spawned and the application configured to use them. Configuration is only required for advanced use cases.
classpath isolation: use of test resources shouldn’t leak into your application classpath, nor your test classpath
compatible with GraalVM native: if you build a native binary, or run tests in native mode, test resources should be available
easy to use: the Micronaut build plugins for Gradle and Maven should handle the complexity of figuring out the dependencies for you
extensible: you can implement your own test resources, in case the built-in ones do not cover your use case
technology agnostic: while lots of test resources use Testcontainers under the hood, you can use any other technology to create resources
7. Native Tests
The Maven plugin for GraalVM Native Image building allows you to run native tests.
This plugin supports running tests on the JUnit Platform as native images. This means that tests will be compiled and executed as native code.
First, add the following profile to pom.xml:
<profile>
<id>native</id>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<extensions>true</extensions>
<executions>
<execution>
<id>test-native</id>
<goals>
<goal>test</goal>
</goals>
<phase>test</phase>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
Then, to execute the native tests, execute:
./mvnw -Pnative test
INFO: A test may be disabled within a GraalVM native image via the @DisabledInNativeImage annotation.
8. Next Steps
Explore more features with Micronaut Guides.
9. License
| All guides are released with an Apache license 2.0 license for the code and a Creative Commons Attribution 4.0 license for the writing and media (images…). |