JPA + Hibernate에서 DDL script 파일로 저장하기

Page content

JPA를 사용할 때부터 계속 하려고 했던건데, 이번에 소기의 목적에서 아주 일부분은 달성하여 진행했던 과정을 기록차원에서 남긴다.

선요약

  • 지정한 패키지의 @Entity annotation이 선언된 클래스를 검색하여 DDL script를 파일로 생성한다.
  • DDL script 생성시 column comment도 함께 생성한다(제일 고민을 많이 함).
  • @Embedded annotation이 달린 항목도 comment를 생성한다.

TODO

  • Table comment 추가
  • Embedded 순환 처리

구현부

작업의 흐름

  1. 우선 Reflections을 이용하여 지정한 package안에 존재하는 클래스 중 @Entity annotation이 선언된 클래스를 검색한다.
  2. 검색된 클래스 목록에서 클래스내에 정의된 Field를 추출한다.
  3. 추출된 클래스별 Field에서 @Column, @JoinColumn@Embedded annotation이 선언된 Field를 검사한다.
  4. 3가지의 annotation중 하나가 선언된 Field에서 내가 작성한 annotation(@ColumnComment)이 선언되어 있는지 확인한다. 만약 @ColumnComment annotation이 선언되어 있다면 Hibernate가 가지고 있는 org.hibernate.mapping.Column 객체의 setComment(String) 함수를 호출하여 column comment를 할당한다.
  5. 위의 과정을 거친 org.hibernate.boot.spi.MetadataImplementor 객체를 org.hibernate.tool.hbm2ddl.SchemaExport 클래스를 이용하여 지정된 파일로 저장한다.

1. @Entity annotation이 선언된 클래스 목록 추출하기

String _basePackage = "net.clfif3.sample";
ConfigurationBuilder _builder = new ConfigurationBuilder();
FilterBuilder _filterBuilder = new FilterBuilder();

_builder.addUrls(ClassPathHelper.forPackage(_basePackage));
_filterBuilder = _filterBuilder.includePackage(_basePackage));

_builder.filterInputsBy(_filterBuilder);
_builder.setExpandSuperTypes(false);

Reflections _reflections = new Reflections(_builder);
//
// @Entity annotation이 선언된 클래스 검색 및 반환
//
Set<Class<?>> _entitySet = _reflections.getTypeAnnotatedWith(Entity.class); 

//
// Meta data 생성
//
MetadataSources _metadata = new MetadataSources(
    new StandardServiceRegistryBuilder()
        .applySetting("hibernate.dialect", MySQL8Dialect.class.getName()).build()
);

//
// 검색된 클래스를 _metadata에 추가
//
_entitySet.forEach(_metadata::addAnnotatedClass);

//
// Meta data 기반으로 Hibernate에서 hbm2ddl을 처리하기 위한 변환된 데이터를 생성
// 해당 결과에서 실제 변환된 정보를 추출(`MetadataImpl#getEntityBindingMap()`)
//
MetadataImplementor _implementor = (MetadataImplementor)_metadata.buildMetadata();
Map<String, PersistentClass> _classMap = ((MetadataImpl)_implementor).getEntityBindingMap();

2. 검색된 클래스내에 정의된 Field 목록 추출

_classMap.forEach((key, value) => {
    Class<?> _mappedClass = value.getMappedClass();
    Field[] _declaredFields = _mappedClass.getDeclaredFields();
});

3. 추출된 Field에서 @Column, @JoinColumn@Embedded annotation이 선언된 항목 검색

중간의 checkExistColumn(org.hibernate.mapping.Column, Field) 함수는 마지막 전체 소스 참고

value.getTable()
     .getColumnIterator()
     .forEachRemaining(hibernateColumn -> {
         //
         // hibernate column에 명시된 이름을 이용하여
         // 정의된 필드에서 동일한 명칭을 사용하는 필드를 검색
         //
         Optional<Field> _target = Arrays.stream(_declaredFields)
                                         .filter(field -> {
                                             return checkExistColumn(hibernateColumn, field);
                                         }).findAny();
});

4. 3번에서 검색된 Field@ColumnComment(내가 만든) annotation의 선언여부 확인

_target.ifPresent(field -> {
    ColumnComment _comment = null;

    if (field.getDeclaredAnnotation(Embedded.class) != null) {
        // @Embedded annotation 선언됨
        Field[] _fields = field.getType().getDeclaredFields();
        Optional<Field> __target = Arrays.stream(_fields).filter(_field -> {
            Column _persistentColumn = _field.getDeclaredAnnotation(Column.class);

            // @Column(name = "") 항목으로 비교
            return _persistentColumn != null && hibernateColumn.getName().equals(_persistentColumn.name());
        }).findAny();

        if (__target.isPresent()) {
            _comment = __target.get().getDeclaredAnnotation(ColumnComment.class);
        }
    } else {
        _comment = field.getDeclaredAnnotation(ColumnComment.class);
    }

    if (_comment != null) {
        // @ColumnComment 선언이 발견되었으면
        hibernateColumn.setComment(_comment.value());
    }
});

5. 4번까지의 작업이 완료된 MetadataImplementor를 이용하여 파일에 저장

SchemaExport _export = new SchemaExport();
String _path = GenerateDataDefinitionLanguage.class.getResource("/").getPath();
String _fileName = String.format("%sddl_script_%d.sql", _path, System.currentTimeMillis());

_export.setDelimiter(";");
_export.setOutputFile(_fileName);
_export.setFormat(true);

EnumSet<TargetType> _targetTypeEnumSet = EnumSet.of(TargetType.SCRIPT);

_export.execute(_targetTypeEnumSet, SchemaExport.Action.CREATE, _implementor);

전체 코드

Sample entity

package net.cliff3.sample;

// import 생략

// 장비정보
@Entity
@Table(name = "sensor")
public class Sensor implements Serializable {
    /**
     * Serial version uid
     */
    private static final long serialVersionUID = -3010495378602325388L;

    @Id
    @Getter
    @Setter
    @Column(name = "seq")
    @ColumnComment(value = "일련번호")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @NotNull(message = "일련번호는 필수 입니다.", groups = {Update.class, Delete.class})
    private Long sequence;

    /**
     * 제조사 정보
     */
    @Getter
    @Setter
    @ColumnComment("제조사 일련번호")
    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "vendor_seq", nullable = false, foreignKey = @ForeignKey(name = "fk_sensor_vendor_seq"))
    private Vendor vendor;

    /**
     * 장비명칭
     */
    @Getter
    @Setter
    @ColumnComment("장비명칭")
    @Column(name = "name", length = 200, nullable = false)
    private String name;

    /**
     * 설치 좌표 정보
     */
    @Getter
    @Setter
    @Embedded
    private EmbeddedCoordinate coordinate = new EmbeddedCoordinate();

    // 기타 컬럼 정의
}
package net.cliff3.sample;

// import 생략

// 위도/경도 좌표 정보
@Embeddable
public class EmbeddedCoordinate implements Serializable {
    /**
     * Serial version uid
     */
    private static final long serialVersionUID = 1415339858077612640L;

    /**
     * 위도
     */
    @Getter
    @Setter
    @ColumnComment("위도")
    @Column(name = "latitude", length = 25)
    private String latitude;

    /**
     * 경도
     */
    @Getter
    @Setter
    @ColumnComment("경도")
    @Column(name = "longitude", length = 25)
    private String longitude;
}

Custom annotation(@ColumnComment)

package net.cliff3.sample;

// import 생략

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(value = ElementType.FIELD)
public @interface ColumnComment {
    String value() default "";
}

DDL 생성 전체 코드

package net.cliff3.sample;

// import 생략

public class GenerateDataDefinitionLanguage {
    public static void main(Strnig[] args) {
        String _basePackage = "net.clfif3.sample";
        ConfigurationBuilder _builder = new ConfigurationBuilder();
        FilterBuilder _filterBuilder = new FilterBuilder();

        _builder.addUrls(ClassPathHelper.forPackage(_basePackage));
        _filterBuilder = _filterBuilder.includePackage(_basePackage));

        _builder.filterInputsBy(_filterBuilder);
        _builder.setExpandSuperTypes(false);

        Reflections _reflections = new Reflections(_builder);
        Set<Class<?>> _entitySet = _reflections.getTypeAnnotatedWith(Entity.class); 
        MetadataSources _metadata = new MetadataSources(
            new StandardServiceRegistryBuilder()
                .applySetting("hibernate.dialect", MySQL8Dialect.class.getName()).build()
        );

        _entitySet.forEach(_metadata::addAnnotatedClass);

        MetadataImplementor _implementor = (MetadataImplementor)_metadata.buildMetadata();
        Map<String, PersistentClass> _classMap = ((MetadataImpl)_implementor).getEntityBindingMap();

        _classMap.forEach((key, value) -> {
            Class<?> _mappedClass = value.getMappedClass();
            Field[] _declaredFields = _mappedClass.getDeclaredFields();

            value.getTable()
                 .getColumnIterator()
                 .forEachRemaining(hibernateColumn -> {
                     Optional<Field> _target = Arrays.stream(_declaredFields)
                                                     .filter(field -> {
                                                         return checkExistColumn(hibernateColumn, field);
                                                     }).findAny();

                     _target.ifPresent(field -> {
                         ColumnComment _comment = null;

                         if (field.getDeclaredAnnotation(Embedded.class) != null) {
                             Field[] _fields = field.getType().getDeclaredFields();
                             Optional<Field> __target = Arrays.stream(_fields).filter(_field -> {
                                 Column _persistentColumn = _field.getDeclaredAnnotation(Column.class);

                                 return _persistentColumn != null && hibernateColumn.getName().equals(_persistentColumn.name());
                             }).findAny();

                             if (__target.isPresent()) {
                                 _comment = __target.get().getDeclaredAnnotation(ColumnComment.clas);
                             }
                         } else {
                             _comment = field.getDeclaredAnnotation(ColumnComment.class);
                         }

                         if (_comment != null) {
                             hibernateColumn.setComment(_comment.value());
                         }
                     });
                 })
        });

        SchemaExport _export = new SchemaExport();
        String _path = GenerateDataDefinitionLanguage.class.getResource("/").getPath();
        String _fileName = String.format("%sddl_script_%d.sql", _path, System.currentTimeMillis());

        _export.setDelimiter(";");
        _export.setOutputFile(_fileName);
        _export.setFormat(true);

        EnumSet<TargetType> _targetTypeEnumSet = EnumSet.of(TargetType.SCRIPT);

        _export.execute(_targetTypeEnumSet, SchemaExport.Action.CREATE, _implementor);
    }

    private static boolean checkExistColumn(org.hibernate.mapping.Column hibernateColumn, Field field) {
        // @Column annotation
        Column _persistentColumn = field.getDeclaredAnnotation(Column.class);

        if (_persistentColumn != null) {
            return _persistentColumn.name().equals(hibernateColumn.getName());
        }

        // @JoinColumn annotation
        JoinColumn _joinColumn = field.getDeclaredAnnotation(JoinColumn.class);

        if (_joinColumn != null) {
            return _joinColumn.name().equals(hibernateColumn.getName());
        }

        // @Embedded annotation
        Embedded _embedded = field.getDeclaredAnnotation(Embedded.class);

        if (_embedded != null) {
            Field[] _embeddedFields = field.getType().getDeclaredFields();
            Optional<Field> _ff = Arrays.stream(_embeddedFields)
                                        .filter(ef -> {
                                            Column _efc = ef.getDeclaredAnnotation(Column.class);

                                            return _efc != null && _efc.name().equals(hibernateColumn.getName());
                                        }).findAny();

            return _ff.isPresent();
        }

        return false;
    }
}
-- 생성된 DDL script
create table sensor (
    seq bigint not null auto_increment comment '일련번호',
    vendor_seq bigint not null comment '제조사 일련번호',
    name varchar(200) not null comment '장비명칭',
    latitude varchar(25) comment '위도',
    longitude varchar(25) comment '경도',
    primary key(seq)
) engine=InnoDB;