pyronaut create example.micronaut.micronautguide --features=data-jdbc,liquibase,h2
Table of Contents
One-to-Many with Micronaut Data JDBC
Learn how to map a one-to-many association with Micronaut Data JDBC.
1. Getting Started
In this guide, we will create a Python application built with Pyronaut.
2. What you will need
To complete this guide, you will need the following:
-
Some time on your hands
-
GraalPy installed and the Pyronaut CLI available locally
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. One-to-Many Relationship
In this tutorial, you develop a one-to-many relationship, as illustrated in the following tables.
| id | first_name | last_name |
|---|---|---|
1 |
Sergio |
del Amo |
| id | phone | contact_id |
|---|---|---|
1 |
+14155552671 |
1 |
2 |
+442071838750 |
1 |
5. Writing the Application
Create an application using the Pyronaut CLI (Command Line Interface) or Pyronaut Launch
5.1. Data Source configuration
Define the datasource in config/application.toml.
[datasources.default]
dialect = "H2"
driver-class-name = "org.h2.Driver"
url = "jdbc:h2:mem:devDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE"
username = "sa"
password = ""
5.2. 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:
Configure the database migrations directory for Liquibase in application.toml.
[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">
<changeSet id="01" author="username">
<createTable tableName="contact">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints nullable="false"
unique="true"
primaryKey="true"
primaryKeyName="pk_contact"/>
</column>
<column name="first_name" type="VARCHAR(255)">
<constraints nullable="true"/>
</column>
<column name="last_name" type="VARCHAR(255)">
<constraints nullable="true"/>
</column>
</createTable>
<createTable tableName="phone">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints nullable="false"
unique="true"
primaryKey="true"
primaryKeyName="pk_phone"/>
</column>
<column name="phone" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="contact_id" type="BIGINT">
<constraints nullable="false"/>
</column>
</createTable>
<addForeignKeyConstraint baseTableName="phone"
baseColumnNames="contact_id"
constraintName="fk_phone_contact"
referencedTableName="contact"
referencedColumnNames="id"/>
<rollback>
<dropTable tableName="phone"/>
<dropTable tableName="contact"/>
</rollback>
</changeSet>
</databaseChangeLog>
6. Entities
Create an entity mapping the table contact:
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Annotated
from micronaut.data.annotation import GeneratedValue, Id, MappedEntity, Relation
if TYPE_CHECKING:
from .phone_entity import PhoneEntity
@dataclass
@MappedEntity("contact") (1)
class ContactEntity:
id: Annotated[int | None, Id, GeneratedValue] (2) (3) (4)
firstName: str
lastName: str
phones: Annotated[
list[PhoneEntity],
Relation(value=Relation.Kind.ONE_TO_MANY, mappedBy="contact"),
] = field(default_factory=list) (5)
| 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 | Since Records have immutable constructor arguments, those arguments need to be marked as @Nullable, and you should pass null for those arguments. |
| 5 | You can specify a relationship (one-to-one, one-to-many, etc.) with the @Relation annotation. |
Create an entity mapping the table phone:
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Annotated
from micronaut.data.annotation import GeneratedValue, Id, MappedEntity, Relation
if TYPE_CHECKING:
from .contact_entity import ContactEntity
@dataclass
@MappedEntity("phone") (1)
class PhoneEntity:
id: Annotated[int | None, Id, GeneratedValue] (2) (3) (4)
phone: str
contact: Annotated[ContactEntity, Relation(value=Relation.Kind.MANY_TO_ONE)] (5)
| 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 | Since Records have immutable constructor arguments, those arguments need to be marked as @Nullable, and you should pass null for those arguments. |
| 5 | You can specify a relationship (one-to-one, one-to-many, etc.) with the @Relation annotation. |
7. Projections
Create one projection to return a complete view, phones included, of the contact:
from dataclasses import dataclass
from micronaut.core.annotation import Introspected
@dataclass
@Introspected (1)
class ContactComplete:
id: int | None
firstName: str
lastName: str
phones: list[str] | None = None
| 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. |
Create one projection to preview a contact, no phones:
from dataclasses import dataclass
from micronaut.core.annotation import Introspected
@dataclass
@Introspected (1)
class ContactPreview:
id: int | None
firstName: str
lastName: str
| 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. |
8. Repositories
from micronaut.data.jdbc.annotation import JdbcRepository
from micronaut.data.model.query.builder.sql import Dialect
from micronaut.data.repository import CrudRepository
from .phone_entity import PhoneEntity
@JdbcRepository(dialect=Dialect.H2) (1)
class PhoneRepository(CrudRepository[PhoneEntity, int]): (2)
pass
| 1 | @JdbcRepository with a specific dialect. |
| 2 | By extending CrudRepository you enable automatic generation of CRUD (Create, Read, Update, Delete) operations. |
from java.util import Optional
from micronaut.data.annotation import Join, Query
from micronaut.data.jdbc.annotation import JdbcRepository
from micronaut.data.model.query.builder.sql import Dialect
from micronaut.data.repository import CrudRepository
from .contact_complete import ContactComplete
from .contact_entity import ContactEntity
from .contact_preview import ContactPreview
@JdbcRepository(dialect=Dialect.H2) (1)
class ContactRepository(CrudRepository[ContactEntity, int]): (2)
@Join(value="phones", type=Join.Type.LEFT_FETCH) (3)
def getById(self, id: int) -> Optional[ContactEntity]: ...
@Query(value="select id, first_name, last_name from contact where id = :id") (4)
def findPreviewById(self, id: int) -> Optional[ContactPreview]: ...
@Query(value="""
select c.id, c.first_name, c.last_name, group_concat(p.phone) as phones
from contact c
left outer join phone p on c.id = p.contact_id
where c.id = :id
group by c.id
""") (5)
def findCompleteById(self, id: int) -> Optional[ContactComplete]: ...
| 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 @Join annotation on your repository interface to specify that a JOIN LEFT FETCH should be executed to retrieve the associated phones. |
| 4 | You can use the @Query annotation to specify an explicit query. |
9. Tests
The following tests illustrate the association queries:
import pytest
from pyronaut.test import MicronautTest, micronaut_test_fixture
from example.micronaut.contact_entity import ContactEntity
from example.micronaut.phone_entity import PhoneEntity
@pytest.fixture
def my_context(request):
fixture = micronaut_test_fixture(
request,
MicronautTest(start_application=False, transactional=False), (1)
)
yield fixture
fixture.stop()
@pytest.fixture
def contact_repository(my_context):
return my_context["example.micronaut.ContactRepository"] (2)
@pytest.fixture
def phone_repository(my_context):
return my_context["example.micronaut.PhoneRepository"] (3)
def test_associations_querying(contact_repository, phone_repository):
first_name = "Sergio"
last_name = "del Amo"
contact_count = contact_repository.count()
contact = contact_repository.save(ContactEntity(None, first_name, last_name))
assert contact_repository.count() == contact_count + 1
preview = contact_repository.findPreviewById(contact.id).orElse(None)
assert preview is not None
assert preview.id == contact.id
assert preview.firstName == first_name
assert preview.lastName == last_name
contact_with_join = contact_repository.getById(contact.id).orElse(None)
assert contact_with_join is not None
assert contact_with_join.id == contact.id
assert contact_with_join.firstName == first_name
assert contact_with_join.lastName == last_name
assert list(contact_with_join.phones) == []
complete = contact_repository.findCompleteById(contact.id).orElse(None)
assert complete is not None
assert complete.id == contact.id
assert complete.firstName == first_name
assert complete.lastName == last_name
assert complete.phones is None
american_phone = "+14155552671"
uk_phone = "+442071838750"
phone_count = phone_repository.count()
contact_reference = ContactEntity(contact.id, first_name, last_name)
us_phone = phone_repository.save(PhoneEntity(None, american_phone, contact_reference))
uk_phone_entity = phone_repository.save(PhoneEntity(None, uk_phone, contact_reference))
assert phone_repository.count() == phone_count + 2
preview = contact_repository.findPreviewById(contact.id).orElse(None)
assert preview is not None
assert preview.id == contact.id
assert preview.firstName == first_name
assert preview.lastName == last_name
contact_without_join = contact_repository.findById(contact.id).orElse(None)
assert contact_without_join is not None
assert list(contact_without_join.phones) == []
contact_with_join = contact_repository.getById(contact.id).orElse(None)
assert contact_with_join is not None
phones = list(contact_with_join.phones)
assert {phone.phone for phone in phones} == {american_phone, uk_phone}
assert {phone.id for phone in phones} == {us_phone.id, uk_phone_entity.id}
complete = contact_repository.findCompleteById(contact.id).orElse(None)
assert complete is not None
assert set(complete.phones) == {american_phone, uk_phone}
phone_repository.deleteById(us_phone.id)
phone_repository.deleteById(uk_phone_entity.id)
contact_repository.deleteById(contact.id)
assert phone_repository.count() == phone_count
assert contact_repository.count() == contact_count
| 1 | Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. By default, each @Test method will be wrapped in a transaction that will be rolled back when the test finishes. This behaviour is is changed by setting transaction to false. |
| 2 | Injection for ContactRepository. |
| 3 | Injection for PhoneRepository. |
10. Testing the Application
To run the tests:
pyronaut install
pyronaut validate-config
pyronaut test
11. Next Steps
Explore more features with Micronaut Guides.
Read more about Micronaut Data.
12. License
| All guides are released with an Apache License 2.0 for the code and a Creative Commons Attribution 4.0 license for the writing and media (images). |