Pull to refresh

JPA: Хранение перечислений в базе данных

Reading time 6 min
Views 44K
Наверняка многие из вас сталкивались с проблемой хранения перечислений в базе данных, возникающей при попытке реализации удобного способа работы с разного рода служебными справочниками — статусами, типами объектов и так далее.

Суть её очень проста: если хранить перечисления как сущности (@Entity), то с ними получается крайне неудобно работать, база данных нагружается лишними запросами даже несмотря на кэширование, а сами запросы к БД усложняются лишними JOIN'ами. Если же перечисление определять как enum, то с ними становится удобно работать, но возникает проблема синхронизации с базой данных и отслеживания ошибок таковой синхронизации.

Особенно актуально это в том случае, когда поле, содержащее перечисление, аннотировано как @Enumerated(EnumType.ORDINAL) — всё мгновенно ломается при смене порядка объявления значений. Если же мы храним значения в строковом виде — как @Enumerated(EnumType.STRING) — возникает проблема скорости доступа, так как индексы по строковым полям менее эффективны и занимают больше места. Более того, вне зависимости от способа хранения значения поля при отсутствии в базе данных таблицы со списком допустимых значений мы никак не защищены от некорректных или устаревших данных и, как следствие, проблем.

Однако сама идея хранения в базе данных заманчива простотой построения запросов вручную, очень ценной при отладке ПО или решении сложных ситуаций. Когда можно написать не просто SELECT id, title FROM product WHERE status = 5, а, скажем, SELECT id, title FROM product JOIN status ON status.id = product.status_id WHERE status.code = 'NEW' — это очень ценно. В том числе и тем, что мы всегда можем быть уверены в том, что status_id содержит корректное значение, если поставим FOREIGN KEY.

На самом деле, существует очень простое и изящное решение этой проблемы, убивающее сразу всех зайцев.


Решение это основывается на простом хаке, который, хоть и хак, не привносит никаких сайд-эффектов. Как известно, перечисления в Java — всего лишь синтаксический сахар, внутри представленый всё теми же экземплярами классов, порождённых от java.lang.Enum. И вот как раз в последнем есть чудесное поле ordinal, объявленное как private, которое и хранит значение, возвращаемое методом ordinal() и используемое ORM для помещения в базу.

Нам всего лишь надо прочитать из справочника в базе данных актуальный идентификатор элемента перечисления и поместить его в это поле. Тогда мы сможем использовать штатным образом EnumType.ORDINAL для хранения в базе с быстрым и удобным доступом, сохраняя таким образом все прелести собственно Enum'ов в Java, и не иметь проблем с синхронизацией идентификаторов и их актуальностью.

Тут может показаться, что такой подход рождает проблемы с сериализацией объектов, однако это не так, ибо спецификация платформы Java дословно говорит нам следующее:

1.12. Serialization of Enum Constants
Enum constants are serialized differently than ordinary serializable or externalizable objects. The serialized form of an enum constant consists solely of its name; field values of the constant are not present in the form.


То есть при сериализации перечисления всегда преобразуются в строковую форму, а числовое значение игнорируется. Вуаля!

А теперь немного практики. Для начала, определим модель данных для нашего примера:

CREATE SEQUENCE status_id;
CREATE SEQUENCE product_id;

CREATE TABLE status (
    id INTEGER NOT NULL DEFAULT NEXT VALUE FOR status_id,
    code CHARACTER VARYING (32) NOT NULL,

    CONSTRAINT status_pk PRIMARY KEY (id),
    CONSTRAINT status_unq1 UNIQUE KEY (code)
);

INSERT INTO status (code) VALUES ('NEW');
INSERT INTO status (code) VALUES ('ACTIVE');
INSERT INTO status (code) VALUES ('DELETED');

CREATE TABLE product (
    id INTEGER NOT NULL DEFAULT NEXT VALUE FOR product_id,
    status_id INTEGER NOT NULL,
    title CHARACTER VARYING (128) NOT NULL,

    CONSTRAINT product_pk PRIMARY KEY (id),
    CONSTRAINT product_unq1 UNIQUE KEY (title),
    CONSTRAINT product_fk1 FOREIGN KEY (status_id)
        REFERENCES status (id) ON UPDATE CASCADE ON DELETE RESTRICT
);

CREATE INDEX product_fki1 ON product (status_id);


Теперь опишем эту же схему данных на Java. Обратите внимание, что в данном случае определяется и перечисление, и класс сущности для справочника. Чтобы избежать повторения однообразного кода, справочники для перечислений наследуются от SystemDictionary. Также обратите внимание на аннотацию @MappedEnum, которая будет нами использоваться в дальнейшем, чтобы определять, какие перечисления отражены на базу данных.

public enum Status {
    NEW,
    ACTIVE,
    DELETED
}

@Retention(value = RetentionPolicy.RUNTIME)
public @interface MappedEnum {
	Class<? extends Enum> enumClass();
}

@MappedSuperclass
public class SystemDictionary {
    @Id
    @GeneratedValue(generator = "entityIdGenerator")
    @Column(name = "id", nullable = false, unique = true)
    private Integer id;

    @Column(name = "code", nullable = false, unique = true, length = 32)
    private String code;

    public Integer getId() {
        return id;
    }
    public void setId(Integer id) {
        this.id = id;
    }
    public String getCode() {
        return code;
    }
    public void setCode(String code) {
        this.code = code;
    }
}

@Entity
@Table(name = "status")
@SequenceGenerator(name = "entityIdGenerator", sequenceName = "status_id")
@MappedEnum(enumClass = Status.class)
public class StatusEx extends SystemDictionary {
}

@Entity
@Table(name = "product")
@SequenceGenerator(name = "entityIdGenerator", sequenceName = "product_id")
public class Product {
    @Id
    @GeneratedValue(generator = "entityIdGenerator")
    @Column(name = "id", nullable = false, unique = true)
    private Integer id;

    @Column(name = "status_id", nullable = false, unique = false)
    @Enumerated(EnumType.ORDINAL)
    private Status status;

    @Column(name = "title", nullable = false, unique = true)
    private String title;

    public Integer getId() {
        return id;
    }
    public void setId(Integer id) {
        this.id = id;
    }
    public Status getStatus() {
        return status;
    }
    public void setStatus(Status status) {
        this.status = status;
    }
    public String getTitle() {
        return title;
    }
    public void setTitle(String title) {
        this.title = title;
    }
}


Теперь нам всего лишь осталось прочитать значения из базы данных и записать их в поле ordinal, а также не забыть обновить ещё и массив values, чтобы можно было получать экземпляры перечисления по индексу из getEnumConstants() — это не только используется тем же Hibernate при работе с перечислениями, но и просто местами очень удобно. Сделать это можно сразу после инициализации подключения к базе данных использованием примерно такого кода:

public interface SessionAction {
    void run(Session session);
}

public class EnumLoader implements SessionAction {
    @Override
    public void run(Session session) {
        Iterator<PersistentClass> mappingList = configuration.getClassMappings();
        while (mappingList.hasNext()) {
            PersistentClass mapping = mappingList.next();

            Class<?> clazz = mapping.getMappedClass();
            if (!SystemDictionary.class.isAssignableFrom(clazz))
                continue;
            if (!clazz.isAnnotationPresent(MappedEnum.class))
                continue;

            MappedEnum mappedEnum = clazz.getAnnotation(MappedEnum.class);
            updateEnumIdentifiers(session, mappedEnum.enumClass(), (Class<SystemDictionary>) clazz);
        }
    }

    private void updateEnumIdentifiers(
            Session session,
            Class<? extends Enum> enumClass,
            Class<? extends SystemDictionary> entityClass) {
        List<SystemDictionary> valueList =
            (List<SystemDictionary>) session.createCriteria(entityClass).list();

        int maxId = 0;
        Enum[] constants = enumClass.getEnumConstants();
        Iterator<SystemDictionary> valueIterator = valueList.iterator();
        while (valueIterator.hasNext()) {
            SystemDictionary value = valueIterator.next();

            int valueId = value.getId().intValue();
            setEnumOrdinal(Enum.valueOf(enumClass, value.getCode()), valueId);
            if (valueId > maxId)
                maxId = valueId;
        }

        Object valuesArray = Array.newInstance(enumClass, maxId + 1);
        for (Enum value : constants)
            Array.set(valuesArray, value.ordinal(), value);

        Field field;
        try {
            field = enumClass.getDeclaredField("$VALUES");
            field.setAccessible(true);

            Field modifiersField = Field.class.getDeclaredField("modifiers");
            modifiersField.setAccessible(true);
            modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL);

            field.set(null, valuesArray);
        } catch (Exception ex) {
            throw new Exception("Can't update values array: ", ex);
        }
    }

    private void setEnumOrdinal(Enum object, int ordinal) {
        Field field;
        try {
            field = object.getClass().getSuperclass().getDeclaredField("ordinal");
            field.setAccessible(true);
            field.set(object, ordinal);
        } catch (Exception ex) {
            throw new Exception("Can't update enum ordinal: " + ex);
        }
    }
}


Как видно, мы просто получаем из Hibernate полный список классов, отражённых на базу данных, отбираем из них все, наследуемые от объявленного выше SystemDictionary и, одновременно, содержащие аннотацию @MappedEnum, после чего обновляем числовые значения экземпляров класса перечисления. Собственно, на этом всё. Теперь мы спокойно можем:

  1. Хранить перечисления как Java Enum и объявлять содержащие их поля как @Enumerated(EnumType.ORDINAL)
  2. Автоматически контролировать синхронизацию справочников в коде и базе данных
  3. Не заботиться о порядке объявления идентификаторов в коде и соответствия их идентификаторам в базе данных
  4. Выполнять удобные запросы к базе данных, содержащие доступ к значениям перечислений по их строковому названию
  5. ...
  6. PROFIT!

Для достижения полного дзена можно (и нужно) добавить также проверку того, что в базе данных не содержится лишних значений, то есть что таблица-справочник и объявление enum в коде синхронизированы.

Данный подход используется нами (Open Source Technologies) в достаточно крупных системах (от полумиллиона строк исходного кода и больше) с распределённой сервис-ориентированной архитектурой на базе JMS и очень хорошо себя показал — как в части удобства использования, так и в части надёжности. Чего и вам желаю :)
Tags:
Hubs:
+25
Comments 23
Comments Comments 23

Articles