- Author: Philippe Collet
We focus here on the setup of the persistence layer using:
- a dockerized Postgresql DB (as a real relational database)
- a H2 in-memory database for testing (which is the default setup in Spring JPA for testing)
We have to first extend the pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId> <!-- JPA + hibernate-core default support -->
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>Since SpringBoot 3.2, we don't have to define persistence.properties files, and everything should be defined in the main application.yaml file in src/main/resources and in src/test/resources.
For the main application.yaml file, we define the following properties:
# postgres DB # IN DOCKER COMPOSE SHOULD BE OVERRIDEN BY ENV VARIABLES
spring:
datasource:
# POSTGRES_USER
username: postgresuser
# POSTGRES_PASSWORD
password: postgrespass
url: jdbc:postgresql://${POSTGRES_HOST}/tcf-db
driver-class-name: org.postgresql.Driver
jpa:
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
show-sql: true
generate-ddl: true
open-in-view: false
While the test/resources/application.yaml file is simply :
spring:
...
jpa:
properties:
hibernate:
format_sql: true
show-sql: true
open-in-view: false
Actually, there is no definition for a datasource as we reuse the default H2 in-memory database provided by SpringBoot for tests.
In our setup, the JPA and H2 configuration enables one to see SQL queries generated on all JPA calls on the console (change the spring.jpa.show-sql to false to switch it off).
Now, our technical setup is complete, once and for all. We can focus on the business part, i.e., storing cookies, customers and related orders.
JPA Entities are simple POJO with annotations. Excepting that the annotations here are related to persistence instead of functional concerns. And that entities need a little bit more than simple annotations:
- An empty constructor
- A proper
equalsmethod that relies on business items - a proper
hashCodemethod to support objects identification in JPA caches.
The equals and hashCode methods must rely on so-called business keys. It is extremely dangerous (and an hideous abstraction leak) to rely at the application level on elements generated by the database layer: you are losing control on object uniqueness. As a consequence, you must be able to define how two entities are equals from a business point of view (the architectural level), and not a technical one (the database one). Here is the example from the Customer class:
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Customer customer)) return false;
return Objects.equals(name, customer.name) && Objects.equals(creditCard, customer.creditCard);
}
@Override
public int hashCode() {
return Objects.hash(name, creditCard);
}An Item does not exist by itself: it is part of a given cart, or a given order. As a consequence, it is an Embeddable entity. Its associated cookie is based on an enumeration, and thus declared as an Enumerated property. For clarity and log purposes, we prefer to use string literals (the cookie's name) instead of ordinals (the cookie's index in the enum) at the persistence level. The quantity cannot be null, so we simply add a validation constraint that will prevent to persist an entity with a null quantity of cookies. The cookie cannot be null too, so we use the same kind of constraints.
@Embeddable
public class Item {
@Enumerated(EnumType.STRING)
@NotNull
private Cookies cookie;
@NotNull
private int quantity;
//...
}The `Order`` class is persisted using the following annotations:
- The
orderStatusfield refers to an enumerated, this is the very same situation than the one encountered before for the cookie stored inside an item; - The
customerattribute refers to another entity, and considering that (i) an order will belong to a single customer and (ii) a customer will hopefully make multiple orders, we annotate this attribute as aManyToOne: many orders related to one single customer; - The
itemsfield is a collection of elements that cannot exist by themselves (as we defined an Item as an embeddable entity). We use the brand newElementCollectionannotation (available since JPA 2.0) that implements this intention. - The
priceneeds to be positive while the StringpayReceiptIdhas to be a real String, which can be specified by@NotBlank.
We are still encountering a big issue. The Order class will be, like the others, automatically translated by OpenJPA into a SQL entity, i.e., a table named ORDER. You're not seeing the point? Imagine the query that will select the data associated to a given customer, and order her orders based on their status. Seeing it? Ordering the orders? ORDER is a SQL keyword, and thus cannot be used as a regular identifier! We need to adapt the way JPA will map the name, using the @Table annotation.
import javax.annotation.processing.SupportedAnnotationTypes;
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue
private Long id;
@ManyToOne
@NotNull
private Customer customer;
@ElementCollection
private Set<Item> items;
@Positive
private double price;
@NotBlank
private String payReceiptId;
@Enumerated(EnumType.STRING)
@NotNull
private OrderStatus status;
// ...
}We make the following changes to the Customer class:
- The
namecannot be blank; - The
creditCardfollows a regular pattern that we should verify to avoid storing inconsistent data. The pattern is defined as a regular expression (10 digits, i.e.,\\d{10}+in java words); - The
ordersfield is actually the counterpart of thecustomerone in theOrderentity. As a consequence it is defined as aOneToManyrelationship: one customer is linked to many orders. As the customer is the owner of the orders, it defines themappedByattribute needed to ensure the bidirectional relationship. - The
equalsmethod changed to be persistent-compliant as shown above
@Entity
public class Customer {
@Id
@GeneratedValue
private Long id;
@NotBlank
@Column(unique = true)
private String name;
@Pattern(regexp = "\\d{10}+", message = "Invalid creditCardNumber")
private String creditCard;
@OneToMany(cascade = {CascadeType.REMOVE}, fetch = FetchType.LAZY, mappedBy = "customer")
private Set<Order> orders = new HashSet<>();
@ElementCollection
private Set<Item> cart = new HashSet<>();
// ...
}We don't need the in-memory implementation anymore. We rely here on the Spring Data repositories for Customers and Orders:
@Repository
public interface CustomerRepository extends JpaRepository<Customer, Long> {
Optional<Customer> findCustomerByName(String name);
}
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
}We can then use them by injection, for example to implement the Orderer with save. Note that this is the DB that gives the ID value.
@Service
public class Orderer implements OrderCreator, OrderFinder, OrderModifier {
final OrderRepository orderRepository;
public Orderer(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Override
@Transactional(propagation = Propagation.MANDATORY) // must be called within a transaction
public Order createOrder(Customer customer, double price, String payReceiptId) {
return orderRepository.save(new Order(customer, customer.getCart(), price, payReceiptId));
}Note that the repository is only used to save the first time the entity or find it back after being outside a transaction (and re-entering a new one).
For transaction principles, object synchronization with the DB and other details on @Transactional in components and in tests, see the lecture!