Spring JPA tips and tricks, Pt II: Enums

In describing real world objects as a parametrized sets of data, we often bump into specific sets of exclusive parameters, of which there's a limited amount, but they are mutually exclusive. One example could be gender. Essentially we have 2 of those and will ignore all the gender politics of last century. Other examples often mentioned, are types of car bodies - hatchback, truck, universal, etc. Or types of fruit. A fruit can't be an apple and a banana at the same time, so enumerators fit the bill quite well.

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:
    gender       VARCHAR(1),
and update our initial SQL dataset to include genders for existing customers:
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);
Mark gets null value so we can see, how JPA deals with serializing nulls:
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)
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.

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)
);
then we can add to our dataset as well:
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');
Note, that our Jack Doe is a Russian spy and gets paid in rubles, Mark lives in Europe and get's stipend in Euros, but Joanna has no income, which has been defined as a set of nulls. For extra fun. Also I have limited length of currency field to 3 characters in hopes, that there are no currencies with more than 3 character codes.

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:
public enum Currency {
    US_DOLLAR("USD"),
    RUSSIAN_RUBLE("RUR"),
    EURO("EUR");

    private String code;

    Currency(String c) {}

    @Override
    public String toString() {
        return this.name();
    }
}
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:
@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;
    }
}
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:
@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;
    }
}
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

No comments:

Post a Comment