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

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

    Суть её очень проста: если хранить перечисления как сущности (@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 и очень хорошо себя показал — как в части удобства использования, так и в части надёжности. Чего и вам желаю :)
    Поделиться публикацией
    Похожие публикации
    Ой, у вас баннер убежал!

    Ну. И что?
    Реклама
    Комментарии 23
      +10
      У Вас невероятно познавательный сайт!:)
        0
        А нам он другой и не нужен на текущий момент :)
        0
        Еще бы под EclipseLink бы кто-нибудь адаптировал :-)
          +1
          Дык, тут особых Hibernate-специфичных моментов и нет, а те что есть отвечают только за листинг базы. Используйте вместо Hibernate Criteria — Criteria API из JPA 2.0.
          +2
          спасибо, полезный пост!
            0
            если кто-то удалит или изменит значение в enum'е, то как это синхронизируетс с базой?
            (честно говоря с явой знаком плохо и статью детально не осилил, но узнать про этот момент очень интересно)
              0
              сам новичок, но по моему достаточно обыкновенного обновления… я так делал с HSQL. если не прав — поправьте, пожалуйста
                0
                UPD: у меня небольшое приложение было, на сильной базе думаю будет какой нибудь TimeLimit или MemoryExc
                0
                Никак. Тут тоже самое, что и с фикстурами. Напишите — миграции — всё будет отлично.
                  0
                  В базе никак, но если добавить в код проверку на совпадение множества значений в коде и в базе, то приложение может при запуске ругаться на несинхронизацию базы и, таким образом, минимизировать геморрой.
                    0
                    О, спасибо за интересное решение!
                  0
                  Я наверное не понимаю, но зачем столько кода, что бы хранить в поле БД одну цифру?
                    0
                    Одну? У вас в системе один справочник с единственным значением? Я вам искренне завидую :-) У нас их несколько десятков с кучей значений в каждом. И работать с ними таким способом — очевиднее, проще, удобнее и надёжнее с точки зрения страховки от ошибок.
                      0
                      Эм, а разве нельзя создавать Enum-ы при запуске приложения, я так понимаю, их заполняют из словаря в БД? А если нужно использовать их в запросе, так делать это через биндинг значений?
                        0
                        Если создавать их при запуске приложения, тогда их нельзя будет использовать из кода. Смысл данной затеи в частности в том, чтобы можно было просто написать

                        session.createCriteria(Person.class).add(Restrictions.eq("status", Status.NEW)
                        

                        а не морочиться с JOIN'ами вроде вот такого:

                        session.createCriteria(Person.class).createAlias("status", "st").add(Restrictions.eq("status_id", "id")).add(Restrictions.eq("st.id", "NEW")
                        


                        Не говоря уже про постоянную выборку по строковому идентификатору, не защищённому от опечаток. Обращение же к enum'ам валидируется во время компиляции, в этом огромный плюс и это защищает от тонн ошибок и опечаток.

                        По сути же этот код и делает выборку из базы при подключении и биндинг enum'ов к идентификаторам из базы данных.
                          0
                          Эм, я правильно понял, что вы объявляете enum-ы с их значениями в коде, а потом заполняете их поле ordinal из БД?

                          А в запросе использовать Status.NEW.ordinal()?
                            0
                            Да. Разве что Status.NEW.ordinal() писать совсем не обязательно, можно просто Status.NEW — Hibernate (и JPA в целом) отлично это переваривает, причём внутри оно использует не метод ordinal(), а getEnumConstants() — это внезапно выяснилось в ходе отладки, после чего была добавлена замена массива $VALUES.

                            Кроме того, данный код сохраняет возможность обращения с Enum'ами произвольным образом — как выборок по индексам из getEnumConstants(), так и valueOf() и всего остального их функционала. Чем и удобен — no side effects at all.
                            0
                            Т.е. почему нельзя использовать Status.NEW.ordinal() по полю status_id?
                              0
                              Почему нельзя? Можно :-) Только зачем писать лишний вызов метода, удлинняющий код, когда логика JPA/Hibernate спокойно скушает и без него?
                            0
                            прошу прощения, во втором Restrictions.eq() в варианте с JOIN'ом конечно же st.code, а не st.id:
                            session.createCriteria(Person.class).createAlias("status", "st").add(Restrictions.eq("status_id", "id")).add(Restrictions.eq("st.code", "NEW")
                            
                        +2
                        проходил случайно мимо, никак не могу не оставить комментарий :)

                        все последущее, несмотря на категоричность является исключительно личным мнением (хоть и проверенным во многих проектах) не претендующим на истину в последней инстанции

                        1.) никогда никогда никогда не используйте ordinals в «перeчислениях» в Яве. Это очень опасно, так как значения (literals) часто меняются. Если где-то используется порядковое значение как ключ, то это очень плохой знак, можно даже сказать, плохой дизайн системы. Гораздо лучше использовать в перичислениях собственные идентификаторы, основанные на предметной области, хотя бы строковые. Пример:

                        public enum CustomerStatusType implements MyEnum {
                        ACTIVE("ACT", "Active"),
                        DELETED("DEL", "Deleted"),
                        SUSPENDED("SUS", "Suspended");
                        
                        private String id;
                        private String description;
                        
                        private CustomerStatusType(String id, String description) {
                        this.id = id;
                        this.description = description;
                        }
                        
                        @Override public String getId() {return this.id;}
                        @Override public String getDescription() {return this.description;}
                        public static CustomerStatusType getInstanceById(String id) {...}
                        }
                        


                        Если этот энум расширить, к примеру добавить до ACTIVE статус CREATED(«CRT», «Created»), то порядковое значение «старых» элементов изменится, но не их идентификаторы. При использовании порядкового значения (ordinal) как идентификатора, произойдет неименуемый крах. Несомненно лучше использовать getId и getInstanceById.

                        2) EclipseLink и Hibernate позволяют использовать так называемые CustomMapper (UserType). С их помощью можно добится «прозрачного» писания и считывания идентификаторов в базу. К тому же иногда бывает, что нужно сохранять не одно значение, а список. Желательно этот список сохранять как список идентификаторов в одной строке, а не как foreign keys в n-to-m-relation-таблице.

                        Короткий пример использования, насчет реализации должен к сожалению отослать к документации Hibernate или EclipseLink, там есть примеры.

                        @Entity
                        public class Customer {
                        
                        @Type(type = "example.EnumerationIdMapper", 
                        parameters = { @Parameter(name = "classname", value = "example.CustomerStatusType") })
                        @Column(name = "status", nullable = false)
                        private CustomerStatusType status;
                        
                        @Type(type = "example.EnumerationIdListMapper", 
                        parameters = { @Parameter(name = "classname", value = "example.CustomerStatusType") })
                        @Column(name = "statuses", nullable = true)
                        private List<CustomerStatusType> statuses;
                        
                        }
                        
                        /**
                        * пример 
                        */
                        public class EnumerationIdMapper implements UserType, ParameterizedType
                        ...
                            protected Class myEnum = null;
                            protected Method getInstanceById =null;
                        
                            /**
                             * @see org.hibernate.usertype.UserType#nullSafeGet(ResultSet, java.lang.String[],
                             *      java.lang.Object)
                             */
                            public Object nullSafeGet(ResultSet resultSet, String[] names, Object owner)
                                    throws HibernateException, SQLException {
                                MyEnum targetObject = null;
                                try {
                                    String id = resultSet.getString(names[0]);
                                    if (!resultSet.wasNull()) {
                                        targetObject = (MyEnum) this.getInstanceById.invoke(
                                                myEnum, new Object[] { id.trim() });
                                    }
                               catch (Exception e) {
                                 // do something...
                                }
                                return targetObject;
                            }
                        
                            /**
                             * @see org.hibernate.usertype.UserType#nullSafeSet(PreparedStatement, java.lang.Object, int)
                             */
                            public void nullSafeSet(PreparedStatement statement, Object value, int index)
                                    throws HibernateException, SQLException {
                                if (value == null) {
                                    statement.setNull(index, Types.VARCHAR);
                                } else {
                                    statement.setString(index, ((MyEnum) value).getId());
                                }
                            }
                        
                            /**
                             * @see org.hibernate.usertype.ParameterizedType#setParameterValues(java.util.Properties)
                             */
                            public void setParameterValues(Properties properties) {
                                if (properties == null) {
                                    return;
                                }
                                String className = properties.getProperty("classname");
                                if (className == null || className.length() == 0) {
                                    return;
                                }
                                try {
                                    ClassLoader cl = Thread.currentThread().getContextClassLoader();
                                    Class<?> clazz = cl.loadClass(className);
                                    this.myEnum = (Class<T>) clazz.asSubclass(Enum.class);
                                    this.getInstanceById = myEnum.getMethod("getInstanceById",
                                            new Class[] { String.class });
                                } catch (ClassNotFoundException cnfe) {
                                    throw new HibernateException("class not found", cnfe);
                                }
                            }
                        
                        ...
                        }
                        
                        


                        Ну и плюс можно использовать JPQL.:

                        Query query = em.createQuery("select c from Customer c where c.status:=statusValue");
                        query.setParameter("statusValue", CustomerStatusType.ACTIVE);
                        


                        Успехов и приятных экспериментов!
                          0
                          Чего то я не могу понять, почему у меня не происходит инъекция энума в Set
                          public enum AccountRole {
                                  // ordinal соответсвенно 10, 20, 30
                          	ROOT, USERMANAGER, USERVIEWER
                          }
                          
                          @Entity
                          @Table(name = "ACCOUNT_GROUP")
                          public class AccountGroup {
                          	
                          	@Id
                          	@Column(name = "AG_ID", nullable = false, unique = true)
                          	private Integer groupId;
                          	
                          	@Column(name = "AG_NAME", nullable = false, unique = true, length = 32)
                          	private String groupName;
                          
                          	@Enumerated(EnumType.ORDINAL)
                          	@ElementCollection(targetClass = AccountRole.class) 
                          	@CollectionTable(name = "GROUP_ROLE", 
                          		joinColumns = @JoinColumn(name = "AG_ID"))
                          	@Column(name = "ROLE_ID") 
                          	private Set<AccountRole> roles = new HashSet<>();
                          
                          ...
                          
                          }
                          


                          Создаю группу
                          		AccountGroup group = new AccountGroup();
                          		group.setGroupName("ABCDE");
                          		group.setGroupId(101);
                          		group.getRoles().add(AccountRole.ROOT);
                          		group.getRoles().add(AccountRole.USERMANAGER);
                          		accountGroupRepository.save(group);
                          


                          В БД сохраняется все как надо… в таблицу GROUP_ROLE падает две строки — [101;10] и [101,20]

                          При чтении же мне выдается InvocationException и пишет, что не может найти enum c ordinal 10 и 20.

                          На что можно обратить внимание, подскажите пожалуйста?
                            0
                            Поиски проблемы привели меня к классу org.hibernate.type.EnumType

                            в котором enumClass.getEnumConstants() возвращает массив со всего тремя элементами с индексами 0,1 и 2 и он же используется хибернейтом дальше

                            private Enum[] enumsByOrdinal() {
                            			if ( enumsByOrdinal == null ) {
                            				enumsByOrdinal = enumClass.getEnumConstants();
                            				if ( enumsByOrdinal == null ) {
                            					throw new HibernateException( "Failed to init enum values" );
                            				}
                            			}
                            			return enumsByOrdinal;
                            		}
                            


                            А дальше в методе идет проверка по размеру массива, естественно в нем всего 3 элемента, а ordinal имею значения 10,20 и 30.
                            Кроме того, выборка проиходит по индексу в массиве, а не ordinal
                            private Enum fromOrdinal(int ordinal) {
                            			final Enum[] enumsByOrdinal = enumsByOrdinal();
                            			if ( ordinal < 0 || ordinal >= enumsByOrdinal.length ) {
                            				throw new IllegalArgumentException(
                            						String.format(
                            								"Unknown ordinal value [%s] for enum class [%s]",
                            								ordinal,
                            								enumClass.getName()
                            						)
                            				);
                            			}
                            			return enumsByOrdinal[ordinal];
                            
                            		}
                            


                            Что нужно переделать, чтобы этот массив соответствовал ordinal?

                          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                          Самое читаемое