Simple ENUMs first
Before we dive into the realm of value objects, let's see, how JPA deals with enumerator objects. Let's add genders to our customers. For now, we'll be conservative and support only traditional gender codes for Male and Female:
public enum GenderCode { M, F }
JPA supports 2 types of ENUM serialization out of the box:
- EnumType.ORDINAL (default) will map values to integers by using order of appearance in class definition. In our case "M" will get mapped to 0 and "F" will get mapped to 1. So in the database it'll get saved as 0/1, while in code and for serialization purposes you can still use string name. Downside to this approach is that if you add another gender, say, "U" for "Unknown", as first item, all your data gets messed up - all existing male clients will show up as unknowns and females as males. Plus, when you start inserting new records, your data gets messed up, if you forget to update all the existing records. Maintenance nightmare
- EnumType.STRING will save values as they are defined in ENUM itself. Meaning, that our males will get saved as 'M', and females as 'F'. Adding unknown gender will just add another type without breaking existing code/dataset.
Third serialization method is writing a custom serializer (converter), which does whatever fancy things you want to do. Note, that this is supported since JPA 2.1. We'll look at it a bit later. But now, let us add genders to our customers:
@Entity @ToString public class Customer { @Id @GeneratedValue(strategy= GenerationType.AUTO) private Long id; private String firstName; private String lastName; @Enumerated(value = EnumType.STRING) private GenderCode gender; protected Customer() {} public Customer(String firstName, String lastName) { this(firstName, lastName, GenderCode.M); } public Customer(String firstName, String lastName, GenderCode gender) { this.firstName = firstName; this.lastName = lastName; this.gender = gender; } }
Since our Customer is growing in size, I added Lombok to the project, to avoid dealing with boilerplate code. @ToString annotation generates human-readable output of class, instead of java standard class and memory address notation.
Schema has to support customer genders, so we add that to the schema:
and update our initial SQL dataset to include genders for existing customers:
Mark gets null value so we can see, how JPA deals with serializing nulls:
As we can see, data output using Lombok looks a bit different from manual version we had before. Genders are loaded correctly, even Mark's null value. Michelle got her gender assigned manually in application, while customers with ids 6-9 had automatically assigned default values from (name, last name) constructor.
Schema has to support customer genders, so we add that to the schema:
gender VARCHAR(1),
INSERT INTO customer (id, first_name, last_name, gender) VALUES (1, 'Jon', 'Doe', 'M'), (2, 'Jack', 'Dawson', 'M'), (3, 'Jack', 'Doe', 'M'), (4, 'Joanna', 'Doe', 'F'), (5, 'Mark', 'Pawson', null);
2017-12-17 13:31:46.637 INFO 12860 --- [ main] com.shirtec.playground.Application : Customer(id=1, firstName=Jon, lastName=Doe, gender=M) 2017-12-17 13:31:46.637 INFO 12860 --- [ main] com.shirtec.playground.Application : Customer(id=2, firstName=Jack, lastName=Dawson, gender=M) 2017-12-17 13:31:46.637 INFO 12860 --- [ main] com.shirtec.playground.Application : Customer(id=3, firstName=Jack, lastName=Doe, gender=M) 2017-12-17 13:31:46.637 INFO 12860 --- [ main] com.shirtec.playground.Application : Customer(id=4, firstName=Joanna, lastName=Doe, gender=F) 2017-12-17 13:31:46.637 INFO 12860 --- [ main] com.shirtec.playground.Application : Customer(id=5, firstName=Mark, lastName=Pawson, gender=null) 2017-12-17 13:31:46.637 INFO 12860 --- [ main] com.shirtec.playground.Application : Customer(id=6, firstName=Jack, lastName=Bauer, gender=M) 2017-12-17 13:31:46.637 INFO 12860 --- [ main] com.shirtec.playground.Application : Customer(id=7, firstName=Chloe, lastName=O'Brian, gender=M) 2017-12-17 13:31:46.637 INFO 12860 --- [ main] com.shirtec.playground.Application : Customer(id=8, firstName=Kim, lastName=Bauer, gender=M) 2017-12-17 13:31:46.637 INFO 12860 --- [ main] com.shirtec.playground.Application : Customer(id=9, firstName=David, lastName=Palmer, gender=M) 2017-12-17 13:31:46.637 INFO 12860 --- [ main] com.shirtec.playground.Application : Customer(id=10, firstName=Michelle, lastName=Dessler, gender=F)
New Values for the old world
So, along with id and names of customers, let us define their incomes. Since we don't know any better, we'll use literals (wrapped) for that:@Entity @ToString public class Customer { @Id @GeneratedValue(strategy= GenerationType.AUTO) private Long id; private String firstName; private String lastName; @Enumerated(value = EnumType.STRING) private GenderCode gender; private Integer income; private String currency; protected Customer() {} public Customer(String firstName, String lastName) { this(firstName, lastName, GenderCode.M, 0, "USD"); } public Customer(String firstName, String lastName, GenderCode gender, Integer income, String currency) { this.firstName = firstName; this.lastName = lastName; this.income = income; this.currency = currency; this.gender = gender; } }
Had to modify constructors, but print out as String is handled by Lombok. Since I'm using predefined (hardcoded) H2 database, I'll have to update that as well. First, we update schema, by adding columns to our customer table:
CREATE TABLE customer ( id INTEGER AUTO_INCREMENT PRIMARY KEY, first_name VARCHAR(64) NOT NULL, last_name VARCHAR(64) NOT NULL, gender VARCHAR(1), income INTEGER, currency VARCHAR(3) );
INSERT INTO customer (id, first_name, last_name, gender, income, currency) VALUES (1, 'Jon', 'Doe', 'M', 100, 'USD'), (2, 'Jack', 'Dawson', 'M', 105, 'USD'), (3, 'Jack', 'Doe', 'M', 50000, 'RUR'), (4, 'Joanna', 'Doe', 'F', null, null), (5, 'Mark', 'Pawson', null, 100, 'EUR');
In the shape our application is right now, it should be compile-able and executable, with output showing people's income along with their name. That was pretty straightforward.
Now, for some reason, we've been getting garbage data from user input. Doesn't matter, if it's typos, trolls or honest people from weird places, we would like to limit available currency options to set of "USD", "EUR", "RUR", since those we know and accept.
ENUMs to the rescue!
Currencies are a nice candidate for becoming ENUMs, because they change seldom, if ever in our timescale. I won't bother here with giving an another example of simple enumerators, instead let's create a separate data for storage and code. Because we might know codes for dollars and rubles, but who knows what's the code for Swiss Frank? SWF? Nope, it's CHF! Anyhow, let's start with our simplified Currency enum:
For now we'll support 3 currencies, we can add them to the enumerator later, as we need them. Or eventually even read from database, but this should suffice for now. Let's replace String literal in our Customer class with our new shiny Currency:
Currency got replaced with an enum, both constructors did get an update too. Note, that I did not provide @Enumerated annotation for Currency. We will create a special currency converter, which will convert to and from database values:
The AttributeConverter interface is quite clearly and unambiguously stated - you should define how to create object from String and how to convert String value to your object. The interface itself is even more generic, requiring that you define conversion from X to Y, which in our case is just bound to String and Currency. Spring scans classes for @Converter annotations with autoApply set and applies them to the fields.
For simplicity of example, we use switch-casing here, but this might become unwieldy if you actually deal with all the currencies in the world (180 of them?). We could define a more general enum to String converter, but that we can leave as an exercise to the reader.
Ok, this is getting a bit long already, let's leave Value Objects for the next post
public enum Currency { US_DOLLAR("USD"), RUSSIAN_RUBLE("RUR"), EURO("EUR"); private String code; Currency(String c) {} @Override public String toString() { return this.name(); } }
@Entity @ToString public class Customer { @Id @GeneratedValue(strategy= GenerationType.AUTO) private Long id; private String firstName; private String lastName; @Enumerated(value = EnumType.STRING) private GenderCode gender; private Integer income; private Currency currency; protected Customer() {} public Customer(String firstName, String lastName) { this(firstName, lastName, GenderCode.M, 0, Currency.US_DOLLAR); } public Customer(String firstName, String lastName, GenderCode gender, Integer income, Currency currency) { this.firstName = firstName; this.lastName = lastName; this.income = income; this.currency = currency; this.gender = gender; } }
@Converter(autoApply = true) public class CurrencyConverter implements AttributeConverter<Currency, String> { @Override public String convertToDatabaseColumn(Currency attribute) { if (attribute != null) { switch (attribute) { case US_DOLLAR: return "USD"; case EURO: return "EUR"; case RUSSIAN_RUBLE: return "RUR"; default: throw new IllegalArgumentException("Unknown " + attribute); } } return null; } @Override public Currency convertToEntityAttribute(String dbData) { if (dbData != null) { switch (dbData) { case "USD": return Currency.US_DOLLAR; case "EUR": return Currency.EURO; case "RUR": return Currency.RUSSIAN_RUBLE; default: throw new IllegalArgumentException("Cannot map db value " + dbData + " to Currency enum"); } } return null; } }
For simplicity of example, we use switch-casing here, but this might become unwieldy if you actually deal with all the currencies in the world (180 of them?). We could define a more general enum to String converter, but that we can leave as an exercise to the reader.
Ok, this is getting a bit long already, let's leave Value Objects for the next post
No comments:
Post a Comment